"""Tests for SearchEnvironment reset and step functionality.""" from __future__ import annotations from searcharena import SearchAction, SearchEnvConfig, SearchEnvironment class TestEnvironmentReset: """Tests for environment reset functionality.""" def test_reset_produces_clean_state(self, env: SearchEnvironment) -> None: """reset() should produce a clean state with no context.""" obs = env.reset() assert obs.context_token_count == 0 assert len(obs.context_chunks) == 0 assert obs.step_count == 0 assert obs.done is False assert obs.question != "" def test_reset_cycles_through_tasks( self, env: SearchEnvironment, tasks: list ) -> None: """reset() should cycle through available tasks.""" questions = [] for _ in range(len(tasks) + 2): obs = env.reset() questions.append(obs.question) # Should cycle back to first task assert questions[0] == questions[len(tasks)] def test_reset_with_specific_task( self, env: SearchEnvironment, custom_task ) -> None: """reset() should accept a specific task.""" obs = env.reset(task=custom_task) assert obs.question == "What is the meaning of life?" def test_reset_clears_previous_episode_state(self, env: SearchEnvironment) -> None: """reset() should clear state from previous episode.""" # Run an episode env.reset() env.step(SearchAction.make_search("Facebook")) env.step(SearchAction.make_answer("test")) # Reset obs = env.reset() assert obs.step_count == 0 assert obs.context_token_count == 0 assert len(obs.queries_issued) == 0 assert obs.done is False def test_reset_without_tasks_is_internally_terminal(self, empty_corpus) -> None: """reset() with no tasks should also put internal state into done mode.""" env = SearchEnvironment(corpus=empty_corpus, tasks=[]) obs = env.reset() assert obs.done is True assert env._done is True follow_up = env.step(SearchAction.make_search("after reset")) assert follow_up.action_result is not None assert "error" in follow_up.action_result class TestEnvironmentStep: """Tests for environment step functionality.""" def test_concurrent_sessions_flag_is_disabled(self) -> None: """The runtime should not advertise concurrent sessions it cannot isolate.""" assert SearchEnvironment.SUPPORTS_CONCURRENT_SESSIONS is False def test_search_action_returns_results(self, env: SearchEnvironment) -> None: """Search action should return relevant results.""" env.reset() action = SearchAction.make_search("Facebook Instagram acquisition") obs = env.step(action) assert obs.action_type == "search" assert obs.action_result is not None assert "results" in obs.action_result assert len(obs.action_result["results"]) > 0 def test_read_action_adds_to_context(self, env: SearchEnvironment) -> None: """Read action should add chunks to context.""" env.reset() # Search first search_obs = env.step(SearchAction.make_search("Facebook")) assert search_obs.action_result is not None results = search_obs.action_result.get("results", []) assert len(results) > 0 # Read first result chunk_id = results[0]["chunk_id"] obs = env.step(SearchAction.make_read([chunk_id])) assert obs.action_type == "read" assert obs.context_token_count > 0 assert len(obs.context_chunks) == 1 def test_prune_action_removes_from_context(self, env: SearchEnvironment) -> None: """Prune action should remove chunks from context.""" env.reset() # Add chunk to context search_obs = env.step(SearchAction.make_search("Facebook")) assert search_obs.action_result is not None chunk_id = search_obs.action_result["results"][0]["chunk_id"] env.step(SearchAction.make_read([chunk_id])) # Prune it obs = env.step(SearchAction.make_prune([chunk_id])) assert obs.action_type == "prune" assert obs.context_token_count == 0 assert len(obs.context_chunks) == 0 def test_answer_action_ends_episode(self, env: SearchEnvironment) -> None: """Answer action should end the episode.""" env.reset() action = SearchAction.make_answer("test answer") obs = env.step(action) assert obs.done is True assert obs.action_type == "answer" assert obs.action_result is not None assert "final_reward" in obs.action_result def test_step_increments_count(self, env: SearchEnvironment) -> None: """Each step should increment step_count.""" env.reset() for i in range(3): obs = env.step(SearchAction.make_search(f"query {i}")) assert obs.step_count == i + 1 def test_step_after_done_returns_error(self, env: SearchEnvironment) -> None: """Stepping after episode ends should return error.""" env.reset() env.step(SearchAction.make_answer("done")) obs = env.step(SearchAction.make_search("after done")) assert obs.action_result is not None assert "error" in obs.action_result class TestBudgetManagement: """Tests for token budget management.""" def test_budget_warning_at_threshold(self, env_with_config) -> None: """Should show warning when approaching budget limit.""" config = SearchEnvConfig(max_context_tokens=100, soft_budget_threshold=0.5) env = env_with_config(config) env.reset() # Add chunks until warning appears search_obs = env.step(SearchAction.make_search("Facebook")) assert search_obs.action_result is not None for result in search_obs.action_result.get("results", [])[:5]: obs = env.step(SearchAction.make_read([result["chunk_id"]])) if obs.budget_warning: break # Should eventually trigger warning assert env._context_token_count > 0 def test_hard_budget_blocks_actions(self, env_with_config) -> None: """Should block non-prune/answer actions at hard budget limit.""" config = SearchEnvConfig( max_context_tokens=50, # Very small budget hard_budget_threshold=0.5, ) env = env_with_config(config) env.reset() # Fill budget search_obs = env.step(SearchAction.make_search("Facebook")) assert search_obs.action_result is not None for result in search_obs.action_result.get("results", []): env.step(SearchAction.make_read([result["chunk_id"]])) # Try to search when over budget obs = env.step(SearchAction.make_search("more search")) # Should either error or work (depends on implementation) # At minimum, step should not crash assert obs is not None