diff --git a/src/autoskillit/execution/session/_exit_classification.py b/src/autoskillit/execution/session/_exit_classification.py index 796a06783..7f1edccac 100644 --- a/src/autoskillit/execution/session/_exit_classification.py +++ b/src/autoskillit/execution/session/_exit_classification.py @@ -115,6 +115,7 @@ def classify_infra_exit( if capabilities is None or capabilities.supports_context_exhaustion_detection: if session._is_context_exhausted(): return InfraExitCategory.CONTEXT_EXHAUSTED + # Codex turn.failed arrives on stdout (NDJSON); this stderr branch is non-Codex fallback. if _CODEX_CONTEXT_EXHAUSTION_PATTERN.search(result.stderr): return InfraExitCategory.CONTEXT_EXHAUSTED # Rate limit detection — must precede all API_ERROR checks (including diff --git a/tests/execution/test_exit_classification.py b/tests/execution/test_exit_classification.py index dc07f63d2..ddc4621c2 100644 --- a/tests/execution/test_exit_classification.py +++ b/tests/execution/test_exit_classification.py @@ -56,8 +56,11 @@ def _sr( ) -def _turn_failed_ndjson(error_message: str) -> str: - return json.dumps({"type": "turn.failed", "error": {"message": error_message}}) +def _turn_failed_ndjson(error_message: str, *, error_code: str = "") -> str: + error: dict[str, str] = {"message": error_message} + if error_code: + error["code"] = error_code + return json.dumps({"type": "turn.failed", "error": error}) class TestClassifyInfraExit: @@ -412,6 +415,49 @@ def test_turn_failed_rate_limit_exceeded_jsonl_context_exhausted_false(self) -> adapted = _adapt_agent_result(agent_result) assert adapted.jsonl_context_exhausted is False + def test_turn_failed_error_code_only_classify_returns_context_exhausted(self) -> None: + ndjson = _turn_failed_ndjson( + "Agent terminated unexpectedly", error_code="context_length_exceeded" + ) + agent_result = CodexResultParser().parse_stdout(ndjson) + adapted = _adapt_agent_result(agent_result) + result = _sr(returncode=1) + assert classify_infra_exit(adapted, result) == InfraExitCategory.CONTEXT_EXHAUSTED + + def test_turn_failed_error_code_and_message_classify_returns_context_exhausted(self) -> None: + ndjson = _turn_failed_ndjson( + "The model's context length has been exceeded", + error_code="context_length_exceeded", + ) + agent_result = CodexResultParser().parse_stdout(ndjson) + adapted = _adapt_agent_result(agent_result) + result = _sr(returncode=1) + assert classify_infra_exit(adapted, result) == InfraExitCategory.CONTEXT_EXHAUSTED + + def test_turn_failed_message_only_classify_returns_context_exhausted(self) -> None: + ndjson = _turn_failed_ndjson("context_length_exceeded", error_code="") + agent_result = CodexResultParser().parse_stdout(ndjson) + adapted = _adapt_agent_result(agent_result) + result = _sr(returncode=1) + assert classify_infra_exit(adapted, result) == InfraExitCategory.CONTEXT_EXHAUSTED + + def test_turn_failed_non_exhaustion_error_code_not_context_exhausted(self) -> None: + ndjson = _turn_failed_ndjson("Internal server error", error_code="server_error") + agent_result = CodexResultParser().parse_stdout(ndjson) + adapted = _adapt_agent_result(agent_result) + result = _sr(returncode=1) + assert classify_infra_exit(adapted, result) == InfraExitCategory.API_ERROR + + def test_stderr_non_exhaustion_not_context_exhausted(self) -> None: + session = ClaudeSessionResult( + subtype="success", + is_error=False, + result="", + session_id="s1", + ) + result = _sr(returncode=1, stderr="some transient error") + assert classify_infra_exit(session, result) == InfraExitCategory.COMPLETED + class TestRateLimitClassification: def test_rate_limited_text_classified_as_rate_limited(self) -> None: