diff --git a/src/takopi/bridge.py b/src/takopi/bridge.py index c27bdb6..d61c3ae 100644 --- a/src/takopi/bridge.py +++ b/src/takopi/bridge.py @@ -409,7 +409,7 @@ async def handle_message( if resume_token_value is None: resume_token_value = progress_renderer.resume_token progress_renderer.resume_token = resume_token_value - err_body = f"Error:\n{error}" + err_body = str(error) final_md = progress_renderer.render_final(elapsed, err_body, status="error") logger.debug("[error] markdown: %s", final_md) final_msg, edited = await _send_or_edit_markdown( @@ -463,9 +463,9 @@ async def handle_message( final_answer = answer if run_ok is False and run_error: if final_answer.strip(): - final_answer = f"{final_answer}\n\nError:\n{run_error}" + final_answer = f"{final_answer}\n\n{run_error}" else: - final_answer = f"Error:\n{run_error}" + final_answer = str(run_error) status = ( "error" if run_ok is False else ("done" if final_answer.strip() else "error") diff --git a/src/takopi/runners/codex.py b/src/takopi/runners/codex.py index 6e354bd..073aafd 100644 --- a/src/takopi/runners/codex.py +++ b/src/takopi/runners/codex.py @@ -39,6 +39,25 @@ _ACTION_KIND_MAP: dict[str, ActionKind] = { } _RESUME_RE = re.compile(r"(?im)^\s*`?codex\s+resume\s+(?P[^`\s]+)`?\s*$") +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +_TRUSTED_DIR_RE = re.compile(r"not inside a trusted directory", re.IGNORECASE) + + +def _strip_ansi(text: str) -> str: + return _ANSI_ESCAPE_RE.sub("", text) + + +def _extract_stderr_reason(stderr_tail: str) -> str | None: + if not stderr_tail: + return None + cleaned = _strip_ansi(stderr_tail) + lines = [line.strip() for line in cleaned.splitlines() if line.strip()] + if not lines: + return None + for line in lines: + if _TRUSTED_DIR_RE.search(line): + return line + return lines[-1] def _started_event(token: ResumeToken, *, title: str) -> StartedEvent: @@ -558,7 +577,11 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner): stderr_tail: str, state: CodexRunState, ) -> list[TakopiEvent]: - message = f"codex exec failed (rc={rc})." + reason = _extract_stderr_reason(stderr_tail) + if reason: + message = f"codex exec failed (rc={rc}).\n\n{reason}" + else: + message = f"codex exec failed (rc={rc})." resume_for_completed = found_session or resume return [ self.note_event( diff --git a/tests/test_exec_runner.py b/tests/test_exec_runner.py index 5d7cd9c..e673876 100644 --- a/tests/test_exec_runner.py +++ b/tests/test_exec_runner.py @@ -243,6 +243,30 @@ async def test_codex_runner_preserves_warning_order(tmp_path) -> None: assert seen[2].answer == "ok" +@pytest.mark.anyio +async def test_codex_runner_includes_stderr_reason(tmp_path) -> None: + codex_path = tmp_path / "codex" + codex_path.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "\n" + "sys.stderr.write('Not inside a trusted directory and --skip-git-repo-check was not specified.\\n')\n" + "sys.stderr.flush()\n" + "sys.exit(1)\n", + encoding="utf-8", + ) + codex_path.chmod(0o755) + + runner = CodexRunner(codex_cmd=str(codex_path), extra_args=[]) + events = [evt async for evt in runner.run("hi", None)] + + completed = next(evt for evt in events if isinstance(evt, CompletedEvent)) + assert completed.ok is False + assert completed.error is not None + assert "codex exec failed (rc=1)." in completed.error + assert "\n\nNot inside a trusted directory" in completed.error + + @pytest.mark.anyio async def test_run_serializes_two_new_sessions_same_thread( tmp_path, monkeypatch