From 6b054089c179fa7cd9bd04c9d49d9684fd37ed7e Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:45:13 +0000 Subject: [PATCH 1/3] fix(code): show resume hint after crashes Co-authored-by: open-swe[bot] --- libs/code/deepagents_code/main.py | 54 ++++++++-------- libs/code/tests/unit_tests/test_main.py | 82 ++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 31 deletions(-) diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 7c9863f13bd..f5f1fc470bd 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -282,8 +282,7 @@ def _render_teardown_thread_hints( Args: console: Console to print the hints to. thread_id: Thread whose checkpoints back the hints. - return_code: Process exit code; the resume hint is shown only on a clean - exit (`0`). + return_code: Process exit code; failed sessions add a resume safety caveat. """ from rich.style import Style from rich.text import Text @@ -317,15 +316,19 @@ def _render_teardown_thread_hints( exc_info=True, ) - if return_code == 0: - console.print() - console.print("[dim]Resume this thread with:[/dim]") - # Echo the command the user actually launched (a shim or the - # `deepagents-code` alias), not a hardcoded `dcode` they may not have. - hint = Text(invoked_name(), style="cyan") - hint.append(" -r ", style="cyan") - hint.append(str(thread_id), style="cyan") - console.print(hint) + console.print() + console.print("[dim]Resume this thread with:[/dim]") + # Echo the command the user actually launched (a shim or the + # `deepagents-code` alias), not a hardcoded `dcode` they may not have. + hint = Text(invoked_name(), style="cyan") + hint.append(" -r ", style="cyan") + hint.append(str(thread_id), style="cyan") + console.print(hint) + if return_code != 0: + console.print( + "[dim]Note: the session ended in an error; the last turn may be " + "incomplete and resume may be unsafe.[/dim]" + ) def _confirm_update_after_restart(console: "Console", version: str) -> None: @@ -4911,6 +4914,7 @@ def cli_main() -> None: # Run Textual TUI return_code = 0 + request_count = 0 try: interpreter_ptc = _parse_interpreter_tools_flag( getattr(args, "interpreter_tools", None) @@ -4976,26 +4980,28 @@ def cli_main() -> None: # The user may have switched threads via /threads during the # session; use the final thread ID for teardown messages. thread_id = result.thread_id or thread_id + request_count = result.session_stats.request_count _print_session_stats(result.session_stats, console) except Exception as e: # noqa: BLE001 # Top-level error handler for the application + return_code = 1 error_msg = Text("\nApplication error: ", style="red") error_msg.append(str(e)) console.print(error_msg) console.print(Text(traceback.format_exc(), style="dim")) sys.exit(1) - - # Show LangSmith thread link and resume hint for threads with - # checkpointed content. The `thread_id is not None` check narrows the - # type to `str` for the helper; `_should_check_teardown_thread` gates - # whether the teardown lookup runs at all. - if thread_id is not None and _should_check_teardown_thread( - thread_id, - request_count=result.session_stats.request_count, - resume_thread=args.resume_thread, - ): - _render_teardown_thread_hints( - console, thread_id, return_code=return_code - ) + finally: + # Show LangSmith thread link and resume hint for threads with + # checkpointed content. The `thread_id is not None` check narrows the + # type to `str` for the helper; `_should_check_teardown_thread` gates + # whether the teardown lookup runs at all. + if thread_id is not None and _should_check_teardown_thread( + thread_id, + request_count=request_count, + resume_thread=args.resume_thread, + ): + _render_teardown_thread_hints( + console, thread_id, return_code=return_code + ) # Warn about available update on exit try: diff --git a/libs/code/tests/unit_tests/test_main.py b/libs/code/tests/unit_tests/test_main.py index cf9165bcc11..263a663b327 100644 --- a/libs/code/tests/unit_tests/test_main.py +++ b/libs/code/tests/unit_tests/test_main.py @@ -1746,13 +1746,15 @@ def test_queries_thread_exists_at_most_once(self) -> None: assert "Resume this thread with:" in output assert "dcode -r test123" in output - def test_resume_hint_echoes_launch_command(self) -> None: + @pytest.mark.parametrize("return_code", [0, 1]) + def test_resume_hint_echoes_launch_command(self, return_code: int) -> None: """The hint names the shim the user launched, not a hardcoded `dcode`.""" thread_exists_mock = AsyncMock(return_value=True) output = self._render( thread_exists_mock=thread_exists_mock, thread_url=None, + return_code=return_code, launch_name="abc", ) @@ -1770,11 +1772,16 @@ def test_prints_langsmith_link_when_available(self) -> None: assert "Resume this thread with:" in output thread_exists_mock.assert_awaited_once() - def test_no_hints_without_checkpoints(self) -> None: - """No checkpoint means no link and no resume hint.""" + @pytest.mark.parametrize("return_code", [0, 1]) + def test_no_hints_without_checkpoints(self, return_code: int) -> None: + """No checkpoint means no link, resume hint, or crash caveat.""" thread_exists_mock = AsyncMock(return_value=False) - output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None) + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + return_code=return_code, + ) assert output == "" thread_exists_mock.assert_awaited_once() @@ -1788,17 +1795,78 @@ def test_lookup_failure_is_swallowed(self) -> None: assert output == "" thread_exists_mock.assert_awaited_once() - def test_resume_hint_omitted_on_error_exit(self) -> None: - """The resume hint is only shown on a clean exit (return_code 0).""" + def test_error_exit_prints_resume_hint_with_caveat(self) -> None: + """A crashed checkpointed thread remains resumable with a safety caveat.""" thread_exists_mock = AsyncMock(return_value=True) output = self._render( thread_exists_mock=thread_exists_mock, thread_url=None, return_code=1 ) - assert "Resume this thread with:" not in output + assert "Resume this thread with:" in output + assert "dcode -r test123" in output + assert "the last turn may be incomplete and resume may be unsafe" in output thread_exists_mock.assert_awaited_once() + def test_clean_exit_prints_resume_hint_without_caveat(self) -> None: + """Clean teardown output retains the resume hint without a caveat.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, thread_url=None, return_code=0 + ) + + assert "Resume this thread with:" in output + assert "dcode -r test123" in output + assert "the last turn may be incomplete and resume may be unsafe" not in output + thread_exists_mock.assert_awaited_once() + + +class TestTeardownHintsOnCrash: + """Test crash handling still renders checkpoint-backed resume guidance.""" + + def test_runner_crash_prints_resume_hint_and_exits_nonzero( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """An unhandled TUI exception renders teardown hints before exiting.""" + launch = AsyncMock(side_effect=RuntimeError("boom")) + thread_exists_mock = AsyncMock(return_value=True) + invoked_name.cache_clear() + + with ( + patch("sys.argv", ["dcode"]), + patch("sys.stdin", SimpleNamespace(isatty=lambda: True)), + patch("deepagents_code.main._install_termination_signal_handlers"), + patch("deepagents_code.main._run_startup_auto_update"), + patch("deepagents_code.main._resolve_agent_arg", return_value="agent"), + patch( + "deepagents_code.main._resolve_interpreter_enabled", return_value=False + ), + patch("deepagents_code.main._check_mcp_project_trust", return_value=None), + patch("deepagents_code.main._check_project_hooks_trust", return_value=None), + patch( + "deepagents_code.sessions.generate_thread_id", return_value="test123" + ), + patch("deepagents_code.main.run_textual_cli_async", launch), + patch("deepagents_code.sessions.thread_exists", thread_exists_mock), + patch( + "deepagents_code.config.build_langsmith_thread_url", return_value=None + ), + patch.dict(os.environ, {INVOKED_AS: "dcode"}), + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + + assert exc_info.value.code == 1 + launch.assert_awaited_once() + thread_exists_mock.assert_awaited_once_with("test123") + output = capsys.readouterr().out + flattened = output.replace("\n", "") + assert "Application error: boom" in output + assert "Resume this thread with:" in output + assert "dcode -r test123" in output + assert "the last turn may be incomplete and resume may be unsafe" in flattened + class TestLangSmithTeardownUrl: """Test LangSmith thread URL display logic on teardown.""" From 72dc75e1daf2c500609e36d1a36f95991f849e22 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 10 Aug 2026 23:31:42 -0700 Subject: [PATCH 2/3] fix(code): carry crashed session's thread into teardown hints Address review on #5412: - Crash teardown lost the active thread: on a `-r` launch the caller's `thread_id` local is `None` (resolution is async), and a `/threads` switch never reaches it. `run_textual_app` now wraps `run_async` failures in `TextualAppError` carrying an `AppResult` snapshot of the app's final state, and `run_textual_cli_async` returns that snapshot so the resume hint targets the thread that was actually active. - Signal exits omitted the safety caveat: `KeyboardInterrupt` and the termination handler's `SystemExit(128+signum)` bypass the `except Exception` assignment, leaving `return_code == 0`. The TUI launch block now marks those paths non-zero before re-raising, so an interrupted session prints the "last turn may be incomplete" warning. --- libs/code/deepagents_code/app.py | 38 ++++++ libs/code/deepagents_code/main.py | 30 ++++- libs/code/tests/unit_tests/test_main.py | 159 +++++++++++++++++++++++- 3 files changed, 223 insertions(+), 4 deletions(-) diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index 9a6596ca492..93c8b3c99d3 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -26170,6 +26170,26 @@ class AppResult: """`(is_available, latest_version)` for post-exit update warning.""" +class TextualAppError(Exception): + """`run_textual_app` failure that still carries the app's final state. + + The TUI resolves resume intent and `/threads` switches asynchronously, so + only the app knows which thread was active when it crashed. Callers catch + this to render teardown hints against the right thread. + """ + + def __init__(self, message: str, result: AppResult) -> None: + """Store the partial result alongside the original error message. + + Args: + message: The underlying exception's message. + result: Snapshot of the app's return code, thread ID, and session + stats at the moment of the crash. + """ + super().__init__(message) + self.result = result + + async def run_textual_app( *, agent: Any = None, # noqa: ANN401 @@ -26265,6 +26285,11 @@ async def run_textual_app( Returns: An `AppResult` with the return code and final thread ID. + + Raises: + TextualAppError: The app crashed; the exception carries an `AppResult` + snapshot with the final thread ID so callers can still render + teardown hints for the thread that was active at the crash. """ app = DeepAgentsApp( agent=agent, @@ -26295,6 +26320,19 @@ async def run_textual_app( ) try: await app.run_async() + except Exception as e: + # The app resolves resume intent and `/threads` switches internally, so + # only it knows which thread was active at the crash. Attach that state + # so callers can aim teardown resume hints at the right thread. + raise TextualAppError( + str(e), + AppResult( + return_code=app.return_code or 1, + thread_id=app._lc_thread_id, + session_stats=app._session_stats, + update_available=app._update_available, + ), + ) from e finally: # Guarantee server cleanup regardless of how the app exits. # Covers both the pre-started server_proc path and the deferred diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index f5f1fc470bd..fc80ae17abc 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -2793,6 +2793,7 @@ async def run_textual_cli_async( ) except Exception as e: logger.debug("App error", exc_info=True) + from deepagents_code.app import TextualAppError from deepagents_code.config import console error_text = Text("Application error: ", style="red") @@ -2800,7 +2801,13 @@ async def run_textual_cli_async( console.print(error_text) if logger.isEnabledFor(logging.DEBUG): console.print(Text(traceback.format_exc(), style="dim")) - return AppResult(return_code=1, thread_id=None) + # The app resolves resume intent and `/threads` switches asynchronously, + # so the crashed session's final thread ID only exists on the exception. + # Returning its snapshot lets the caller's teardown print a resume hint + # for the thread that was actually active when the session died. + if isinstance(e, TextualAppError): + return e.result + return AppResult(return_code=1, thread_id=thread_id) return result @@ -3956,7 +3963,15 @@ def _verify_interpreter_or_exit() -> None: def cli_main() -> None: - """Entry point for console script.""" + """Entry point for console script. + + Raises: + SystemExit: On shutdown, with the session's exit code (0 on success, + 1 on error, 128+signum when a terminating signal unwound the + process). + KeyboardInterrupt: Re-raised out of the TUI teardown block so the + outer handler can print the interruption notice and exit 130. + """ # Fix for gRPC fork issue on macOS # https://github.com/grpc/grpc/issues/37642 if sys.platform == "darwin": @@ -4989,6 +5004,17 @@ def cli_main() -> None: console.print(error_msg) console.print(Text(traceback.format_exc(), style="dim")) sys.exit(1) + except KeyboardInterrupt: + # Ctrl+C; the outer handler prints "Interrupted" and exits 130. + # Mark non-zero so the teardown hint carries the safety caveat. + return_code = 130 + raise + except SystemExit as e: + # The termination-signal handler raises SystemExit(128+signum); + # forward non-zero codes so the teardown hint adds the caveat. + if isinstance(e.code, int) and e.code != 0: + return_code = e.code + raise finally: # Show LangSmith thread link and resume hint for threads with # checkpointed content. The `thread_id is not None` check narrows the diff --git a/libs/code/tests/unit_tests/test_main.py b/libs/code/tests/unit_tests/test_main.py index 263a663b327..0ce6cb8e459 100644 --- a/libs/code/tests/unit_tests/test_main.py +++ b/libs/code/tests/unit_tests/test_main.py @@ -20,7 +20,12 @@ from deepagents_code._env_vars import INVOKED_AS from deepagents_code._invocation import invoked_name -from deepagents_code.app import AppResult, DeepAgentsApp, run_textual_app +from deepagents_code.app import ( + AppResult, + DeepAgentsApp, + TextualAppError, + run_textual_app, +) from deepagents_code.config import build_langsmith_thread_url, reset_langsmith_url_cache from deepagents_code.main import ( _auto_install_ripgrep_cli, @@ -1867,6 +1872,131 @@ def test_runner_crash_prints_resume_hint_and_exits_nonzero( assert "dcode -r test123" in output assert "the last turn may be incomplete and resume may be unsafe" in flattened + async def test_crash_preserves_final_thread_id(self) -> None: + """A crash surfaces the thread the app resolved, not the pre-launch ID. + + On a `-r` launch the caller's `thread_id` local is `None` (resolution + is async), and a `/threads` switch never reaches the caller; the crash + snapshot is the only place the active thread survives. + """ + result_snapshot = AppResult(return_code=1, thread_id="resolved-thread") + msg = "boom" + + async def _run_textual_app_stub(**kwargs: Any) -> AppResult: + del kwargs + await asyncio.sleep(0) + raise TextualAppError(msg, result_snapshot) + + with patch("deepagents_code.app.run_textual_app", new=_run_textual_app_stub): + result = await run_textual_cli_async( + "agent", + thread_id=None, + resume_thread="resolved-thread", + no_mcp=True, + ) + + assert result is result_snapshot + + async def test_crash_without_app_state_falls_back_to_launch_thread( + self, + ) -> None: + """A failure before/without app state keeps the launch-time thread ID.""" + msg = "boom" + + async def _run_textual_app_stub(**kwargs: Any) -> AppResult: + del kwargs + await asyncio.sleep(0) + raise RuntimeError(msg) + + with patch("deepagents_code.app.run_textual_app", new=_run_textual_app_stub): + result = await run_textual_cli_async( + "agent", + thread_id="launch-thread", + no_mcp=True, + ) + + assert result.return_code == 1 + assert result.thread_id == "launch-thread" + + def test_keyboard_interrupt_prints_hint_with_caveat( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Ctrl+C teardown shows the resume hint with the incomplete-turn caveat.""" + launch = AsyncMock(side_effect=KeyboardInterrupt) + thread_exists_mock = AsyncMock(return_value=True) + invoked_name.cache_clear() + + with ( + patch("sys.argv", ["dcode"]), + patch("sys.stdin", SimpleNamespace(isatty=lambda: True)), + patch("deepagents_code.main._install_termination_signal_handlers"), + patch("deepagents_code.main._run_startup_auto_update"), + patch("deepagents_code.main._resolve_agent_arg", return_value="agent"), + patch( + "deepagents_code.main._resolve_interpreter_enabled", return_value=False + ), + patch("deepagents_code.main._check_mcp_project_trust", return_value=None), + patch("deepagents_code.main._check_project_hooks_trust", return_value=None), + patch( + "deepagents_code.sessions.generate_thread_id", return_value="test123" + ), + patch("deepagents_code.main.run_textual_cli_async", launch), + patch("deepagents_code.sessions.thread_exists", thread_exists_mock), + patch( + "deepagents_code.config.build_langsmith_thread_url", return_value=None + ), + patch.dict(os.environ, {INVOKED_AS: "dcode"}), + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + + assert exc_info.value.code == 130 + thread_exists_mock.assert_awaited_once_with("test123") + output = capsys.readouterr().out + flattened = output.replace("\n", "") + assert "Resume this thread with:" in output + assert "dcode -r test123" in output + assert "the last turn may be incomplete and resume may be unsafe" in flattened + + def test_signal_exit_prints_hint_with_caveat( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """A termination-signal SystemExit shows the caveat, not a clean hint.""" + launch = AsyncMock(side_effect=SystemExit(143)) + thread_exists_mock = AsyncMock(return_value=True) + invoked_name.cache_clear() + + with ( + patch("sys.argv", ["dcode"]), + patch("sys.stdin", SimpleNamespace(isatty=lambda: True)), + patch("deepagents_code.main._install_termination_signal_handlers"), + patch("deepagents_code.main._run_startup_auto_update"), + patch("deepagents_code.main._resolve_agent_arg", return_value="agent"), + patch( + "deepagents_code.main._resolve_interpreter_enabled", return_value=False + ), + patch("deepagents_code.main._check_mcp_project_trust", return_value=None), + patch("deepagents_code.main._check_project_hooks_trust", return_value=None), + patch( + "deepagents_code.sessions.generate_thread_id", return_value="test123" + ), + patch("deepagents_code.main.run_textual_cli_async", launch), + patch("deepagents_code.sessions.thread_exists", thread_exists_mock), + patch( + "deepagents_code.config.build_langsmith_thread_url", return_value=None + ), + patch.dict(os.environ, {INVOKED_AS: "dcode"}), + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + + assert exc_info.value.code == 143 + thread_exists_mock.assert_awaited_once_with("test123") + output = capsys.readouterr().out + flattened = output.replace("\n", "") + assert "Resume this thread with:" in output + assert "the last turn may be incomplete and resume may be unsafe" in flattened + class TestLangSmithTeardownUrl: """Test LangSmith thread URL display logic on teardown.""" @@ -2189,13 +2319,38 @@ async def test_server_proc_stopped_even_on_crash(self) -> None: patch( "deepagents_code.client.launch.server.emit_preserved_log_notices", ) as emit, - pytest.raises(RuntimeError, match="boom"), + pytest.raises(TextualAppError, match="boom"), ): await run_textual_app(server_proc=server_proc, thread_id="t-1") # ty: ignore server_proc.stop.assert_called_once_with() emit.assert_called_once_with() + async def test_crash_carries_app_state(self) -> None: + """A run_async failure wraps the app's final thread ID and return code.""" + msg = "boom" + + async def _crash_after_switch(self: DeepAgentsApp) -> None: + # The app resolved/switched threads before dying (e.g. async `-r` + # resolution or `/threads`); the original launch-time ID is stale. + self._lc_thread_id = "switched-thread" + await asyncio.sleep(0) + raise RuntimeError(msg) + + with ( + patch.object(DeepAgentsApp, "run_async", new=_crash_after_switch), + patch( + "deepagents_code.client.launch.server.emit_preserved_log_notices", + ), + pytest.raises(TextualAppError) as exc_info, + ): + await run_textual_app(thread_id="launch-thread") + + assert exc_info.value.result.thread_id == "switched-thread" + # No clean exit was recorded, so the crash snapshot reports failure. + assert exc_info.value.result.return_code == 1 + assert isinstance(exc_info.value.__cause__, RuntimeError) + async def test_deferred_server_proc_stopped_after_app_exits(self) -> None: """server_proc set by the background worker must still be cleaned up.""" server_proc = SimpleNamespace(stop=MagicMock()) From 5cac2dbd0cb3be5bbda97f08c56ffae10e58d31f Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 09:23:51 -0700 Subject: [PATCH 3/3] fix(code): clarify failed session resume hint --- libs/code/deepagents_code/main.py | 4 ++-- libs/code/tests/unit_tests/test_main.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index fc80ae17abc..88dc0ca2a0e 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -326,8 +326,8 @@ def _render_teardown_thread_hints( console.print(hint) if return_code != 0: console.print( - "[dim]Note: the session ended in an error; the last turn may be " - "incomplete and resume may be unsafe.[/dim]" + "[dim]Note: the session exited with a non-zero status. Attempting " + "to resume this thread may fail.[/dim]" ) diff --git a/libs/code/tests/unit_tests/test_main.py b/libs/code/tests/unit_tests/test_main.py index 0ce6cb8e459..15a3265ca4a 100644 --- a/libs/code/tests/unit_tests/test_main.py +++ b/libs/code/tests/unit_tests/test_main.py @@ -1810,7 +1810,7 @@ def test_error_exit_prints_resume_hint_with_caveat(self) -> None: assert "Resume this thread with:" in output assert "dcode -r test123" in output - assert "the last turn may be incomplete and resume may be unsafe" in output + assert "Attempting to resume this thread may fail" in output thread_exists_mock.assert_awaited_once() def test_clean_exit_prints_resume_hint_without_caveat(self) -> None: @@ -1823,7 +1823,7 @@ def test_clean_exit_prints_resume_hint_without_caveat(self) -> None: assert "Resume this thread with:" in output assert "dcode -r test123" in output - assert "the last turn may be incomplete and resume may be unsafe" not in output + assert "Attempting to resume this thread may fail" not in output thread_exists_mock.assert_awaited_once() @@ -1870,7 +1870,7 @@ def test_runner_crash_prints_resume_hint_and_exits_nonzero( assert "Application error: boom" in output assert "Resume this thread with:" in output assert "dcode -r test123" in output - assert "the last turn may be incomplete and resume may be unsafe" in flattened + assert "Attempting to resume this thread may fail" in flattened async def test_crash_preserves_final_thread_id(self) -> None: """A crash surfaces the thread the app resolved, not the pre-launch ID. @@ -1956,7 +1956,7 @@ def test_keyboard_interrupt_prints_hint_with_caveat( flattened = output.replace("\n", "") assert "Resume this thread with:" in output assert "dcode -r test123" in output - assert "the last turn may be incomplete and resume may be unsafe" in flattened + assert "Attempting to resume this thread may fail" in flattened def test_signal_exit_prints_hint_with_caveat( self, capsys: pytest.CaptureFixture[str] @@ -1995,7 +1995,7 @@ def test_signal_exit_prints_hint_with_caveat( output = capsys.readouterr().out flattened = output.replace("\n", "") assert "Resume this thread with:" in output - assert "the last turn may be incomplete and resume may be unsafe" in flattened + assert "Attempting to resume this thread may fail" in flattened class TestLangSmithTeardownUrl: