diff --git a/libs/code/deepagents_code/_env_vars.py b/libs/code/deepagents_code/_env_vars.py index 4db0c170df..80ea62a3e5 100644 --- a/libs/code/deepagents_code/_env_vars.py +++ b/libs/code/deepagents_code/_env_vars.py @@ -284,6 +284,18 @@ destinations are written today. """ +LAUNCH_TERM_PROGRAM = "DEEPAGENTS_CODE_LAUNCH_TERM_PROGRAM" +"""Internal sentinel recording the `TERM_PROGRAM` present when `dcode` started. + +Not user-facing. The resume hint echoes `TERM_PROGRAM` only when the launch +environment supplied it (an inline `TERM_PROGRAM=x dcode`, a terminal's own +export, or a shell alias), so the value set by a project or global `.env` file +*after* launch must not leak in. The app itself never sets `TERM_PROGRAM`, so +`cli_main` snapshotting the variable here at entry means a set sentinel always +marks an explicit launch value; the update re-exec inherits it unchanged, +which is correct because the relaunch runs the command the user typed. +""" + LEGACY_ENABLED_PROJECT_MCP_SERVERS = "DEEPAGENTS_CODE_ENABLED_PROJECT_MCP_SERVERS" """Removed project MCP allowlist env var retained for migration detection only. diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index ca41525bf4..8b4cfdefbd 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -8080,9 +8080,15 @@ def _refresh_cache_display(self) -> None: inputs += self._inflight_turn_stats.input_tokens reads += self._inflight_turn_stats.cache_read_tokens writes += self._inflight_turn_stats.cache_write_tokens - cache_display = self._status_bar.query_one("#cache-display") - cache_display.visible = self._thread_has_completed_turn and writes > 0 - self._status_bar.set_cache_tokens(reads, writes, input_tokens=inputs) + # The usage worker can fire while `/reload` has the status bar + # mid-compose or mid-teardown, before `#cache-display` is queryable. + # `set_cache_tokens` also touches the DOM, so skip both on that race; + # the next usage update (or `_reset_thread_usage` on the new thread) + # repaints. + with suppress(NoMatches): + cache_display = self._status_bar.query_one("#cache-display") + cache_display.visible = self._thread_has_completed_turn and writes > 0 + self._status_bar.set_cache_tokens(reads, writes, input_tokens=inputs) def _set_session_cost( self, diff --git a/libs/code/deepagents_code/config_manifest.py b/libs/code/deepagents_code/config_manifest.py index 93e6905fbc..3a6a42d3fa 100644 --- a/libs/code/deepagents_code/config_manifest.py +++ b/libs/code/deepagents_code/config_manifest.py @@ -1820,6 +1820,10 @@ def _credential_options() -> tuple[ConfigOption, ...]: # Set by the self-update restart to carry the launched command name into # the re-exec'd process; never user-configured. _env_vars.INVOKED_AS, + # Launch-time snapshot of `TERM_PROGRAM` recorded by `cli_main` so the + # resume hint can distinguish an explicit launch value from a `.env` + # file that sets `TERM_PROGRAM` after launch; never user-configured. + _env_vars.LAUNCH_TERM_PROGRAM, } ) """`_env_vars` constants intentionally excluded from the option catalog.""" diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index ac28231efa..35a2892894 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -41,6 +41,7 @@ # Suppress Pydantic v1 compatibility warnings from langchain on Python 3.14+ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning) +from deepagents_code._env_vars import LAUNCH_TERM_PROGRAM from deepagents_code._version import __version__ logger = logging.getLogger(__name__) @@ -264,6 +265,36 @@ def _should_check_teardown_thread( return bool(thread_id) +def _resume_term_program() -> str | None: + """Return a `TERM_PROGRAM` value safe to echo inside the resume hint. + + The value is read from `LAUNCH_TERM_PROGRAM` — the snapshot `cli_main` + takes at process entry — rather than live `TERM_PROGRAM`, so only a value + the launch environment supplied (inline prefix, terminal export, or shell + alias) is echoed back. A `TERM_PROGRAM` that appears later, from a project + or global `.env` file, never reaches the hint. + + Returns: + The launch-time value when it is set and fully printable, else `None`. + A value carrying control characters is dropped rather than stripped: + stripping would both write raw escape sequences into teardown output + and name a terminal the environment never actually contained. Native + Windows shells also return `None`: VS Code and WezTerm set + `TERM_PROGRAM` on every platform, so its presence under `win32` does + not imply a POSIX shell, and the `VAR=value` prefix would be executed + as a command by `cmd.exe`/PowerShell. POSIX markers (`SHELL` from + git-bash/MSYS, `MSYSTEM`, `WSL_DISTRO_NAME`) restore the prefix there. + """ + raw = os.environ.get(LAUNCH_TERM_PROGRAM, "").strip() + if not raw or not raw.isprintable(): + return None + if sys.platform == "win32" and not any( + os.environ.get(marker) for marker in ("SHELL", "MSYSTEM", "WSL_DISTRO_NAME") + ): + return None + return raw + + def _render_teardown_thread_hints( console: "Console", thread_id: str, @@ -282,6 +313,8 @@ def _render_teardown_thread_hints( thread_id: Thread whose checkpoints back the hints. return_code: Process exit code; failed sessions add a resume safety caveat. """ + import shlex + from rich.style import Style from rich.text import Text @@ -318,10 +351,21 @@ def _render_teardown_thread_hints( 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) + resume_command = shlex.join([invoked_name(), "-r", str(thread_id)]) + # A shell alias that exports `TERM_PROGRAM` (to select a theme, say) is + # invisible to `invoked_name`, since an alias does not change `argv[0]`, so + # the bare command would resume without it. Carry the launch-time value as + # an env prefix to keep the line pasteable as-is; the launch snapshot (not + # the live variable) is what keeps a `.env`-supplied `TERM_PROGRAM` out of + # the hint. The prefix uses POSIX syntax, so `_resume_term_program` + # withholds it on native Windows, where terminals (VS Code, WezTerm) set + # the variable even under `cmd.exe`/PowerShell and those shells cannot + # parse a `VAR=value` command prefix. + term_program = _resume_term_program() + if term_program is not None: + resume_command = f"TERM_PROGRAM={shlex.quote(term_program)} {resume_command}" + console.print(Text(resume_command, style="cyan")) + if return_code != 0: console.print( "[dim]Note: the session exited with a non-zero status. Attempting " @@ -4196,6 +4240,14 @@ def cli_main() -> None: if sys.platform == "darwin": os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "0" + # Snapshot `TERM_PROGRAM` before settings bootstrap loads any `.env` file, + # so the resume hint echoes the variable only when the launch environment + # (inline prefix, terminal export, or shell alias) supplied it. The app + # itself never sets `TERM_PROGRAM`, and the update re-exec inherits this + # sentinel, so a set value here always marks an explicit launch value. + if "TERM_PROGRAM" in os.environ and LAUNCH_TERM_PROGRAM not in os.environ: + os.environ[LAUNCH_TERM_PROGRAM] = os.environ["TERM_PROGRAM"] + # Note: LANGSMITH_PROJECT override is handled lazily by config.py's # _ensure_bootstrap() (triggered on first access of `settings`). # This ensures agent traces use DEEPAGENTS_CODE_LANGSMITH_PROJECT while diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index b71a8e7410..6abdf397fd 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -2615,6 +2615,17 @@ def test_refresh_includes_matching_inflight_input_total(self) -> None: input_tokens=1_500, ) + def test_refresh_swallows_uncomposed_status_bar(self) -> None: + """A usage update racing `/reload` compose/teardown must not raise.""" + app = DeepAgentsApp(thread_id="thread-123") + app._status_bar = MagicMock() + app._status_bar.query_one.side_effect = NoMatches( + "No nodes match '#cache-display'" + ) + + # Should not raise despite the status bar lacking `#cache-display`. + app._refresh_cache_display() + class TestThreadCachePrewarm: """Tests for startup thread-cache prewarming.""" diff --git a/libs/code/tests/unit_tests/test_main.py b/libs/code/tests/unit_tests/test_main.py index 47e76fd581..9f5d2953dd 100644 --- a/libs/code/tests/unit_tests/test_main.py +++ b/libs/code/tests/unit_tests/test_main.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from prompt_toolkit.layout import Layout -from deepagents_code._env_vars import INVOKED_AS +from deepagents_code._env_vars import INVOKED_AS, LAUNCH_TERM_PROGRAM from deepagents_code._invocation import invoked_name from deepagents_code.app import ( AppResult, @@ -1535,6 +1535,51 @@ def test_project_mcp_server_selection_cancel_aborts_before_tui( assert "Aborted; no project MCP servers loaded" in capsys.readouterr().err +class TestLaunchTermProgramSnapshot: + """`cli_main` records launch-time `TERM_PROGRAM` for the resume hint.""" + + def _run_cli_main(self) -> None: + """Run `cli_main` through its early exit, past the snapshot.""" + with ( + patch.object(sys, "argv", ["dcode", "--version"]), + pytest.raises(SystemExit), + ): + cli_main() + + def test_snapshots_launch_term_program( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A `TERM_PROGRAM` present at entry is recorded for the resume hint.""" + monkeypatch.setenv("TERM_PROGRAM", "WezTerm") + monkeypatch.delenv(LAUNCH_TERM_PROGRAM, raising=False) + + self._run_cli_main() + + assert os.environ[LAUNCH_TERM_PROGRAM] == "WezTerm" + + def test_skips_snapshot_when_term_program_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without a launch `TERM_PROGRAM` no sentinel is written.""" + monkeypatch.delenv("TERM_PROGRAM", raising=False) + monkeypatch.delenv(LAUNCH_TERM_PROGRAM, raising=False) + + self._run_cli_main() + + assert LAUNCH_TERM_PROGRAM not in os.environ + + def test_inherited_snapshot_wins_over_launch_value( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The update re-exec's inherited sentinel is not overwritten.""" + monkeypatch.setenv("TERM_PROGRAM", "WezTerm") + monkeypatch.setenv(LAUNCH_TERM_PROGRAM, "iTerm.app") + + self._run_cli_main() + + assert os.environ[LAUNCH_TERM_PROGRAM] == "iTerm.app" + + class TestAutoUpdateDefaultMigration: """First-run consent/migration notice for the auto-update opt-out default.""" @@ -1871,20 +1916,41 @@ def _render( thread_url: str | None, return_code: int = 0, launch_name: str = "dcode", + term_program: str = "", + launch_term_program: str | None = None, ) -> str: - """Render the hints with patched dependencies, returning the output.""" + """Render the hints with patched dependencies, returning the output. + + `term_program` is the live variable; `launch_term_program` is the + launch snapshot the hint actually reads. Passing only `term_program` + exercises the no-snapshot path (hint stays bare); passing both + exercises the launch-time-carry path. + """ buffer = StringIO() console = Console(file=buffer, width=200) # `launch_name` is resolved (and cached) inside the renderer. invoked_name.cache_clear() + env = {INVOKED_AS: launch_name, "TERM_PROGRAM": term_program} + if launch_term_program is not None: + env[LAUNCH_TERM_PROGRAM] = launch_term_program with ( patch("deepagents_code.sessions.thread_exists", thread_exists_mock), patch( "deepagents_code.config.build_langsmith_thread_url", return_value=thread_url, ), - patch.dict(os.environ, {INVOKED_AS: launch_name}), + # Always set explicitly: an ambient `TERM_PROGRAM` or launch + # snapshot from the developer's or CI runner's shell would + # otherwise leak into the rendered command and flake assertions. + patch.dict(os.environ, env), + # These cases describe POSIX behavior; pin the platform so they do + # not take the native-Windows path when run on a Windows machine. + patch.object(sys, "platform", "darwin"), ): + # `patch.dict` only merges, so drop an inherited launch snapshot + # when the case wants no snapshot at all. + if launch_term_program is None: + os.environ.pop(LAUNCH_TERM_PROGRAM, None) _render_teardown_thread_hints(console, "test123", return_code=return_code) return buffer.getvalue() @@ -1918,6 +1984,158 @@ def test_resume_hint_echoes_launch_command(self, return_code: int) -> None: assert "abc -r test123" in output assert "dcode" not in output + @pytest.mark.parametrize("return_code", [0, 1]) + def test_resume_hint_carries_term_program(self, return_code: int) -> None: + """A launch-time `TERM_PROGRAM` rides along as an env prefix. + + A shell alias that exports the variable cannot be recovered from + `argv[0]`, so pasting a bare `dcode -r ...` would drop it (and with it + anything keyed off it, such as theme selection). + """ + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + return_code=return_code, + term_program="WezTerm", + launch_term_program="WezTerm", + ) + + assert "TERM_PROGRAM=WezTerm dcode -r test123" in output + + def test_resume_hint_omits_term_program_without_launch_snapshot(self) -> None: + """A `TERM_PROGRAM` set only after launch (a `.env` file) stays out.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="WezTerm", + ) + + assert "TERM_PROGRAM" not in output + assert "dcode -r test123" in output + + def test_resume_hint_omits_prefix_when_term_program_unset(self) -> None: + """An unset `TERM_PROGRAM` leaves the command bare, with no empty prefix.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render(thread_exists_mock=thread_exists_mock, thread_url=None) + + assert "dcode -r test123" in output + assert "TERM_PROGRAM" not in output + + @pytest.mark.parametrize("term_program", [" ", "\t"]) + def test_resume_hint_omits_blank_term_program(self, term_program: str) -> None: + """A whitespace-only value is treated as unset, matching other readers.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program=term_program, + launch_term_program=term_program, + ) + + assert "TERM_PROGRAM" not in output + + def test_resume_hint_quotes_term_program_needing_quotes(self) -> None: + """A value the shell would split is quoted, keeping the line pasteable.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="Wez Term&whoami", + launch_term_program="Wez Term&whoami", + ) + + assert "TERM_PROGRAM='Wez Term&whoami' dcode -r test123" in output + + def test_resume_hint_drops_term_program_with_control_characters(self) -> None: + """Terminal metadata cannot inject control sequences into teardown output. + + The value is dropped rather than stripped: a stripped value would name a + terminal the environment never contained. + """ + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="Wez\x1b\nTerm", + launch_term_program="Wez\x1b\nTerm", + ) + + assert "TERM_PROGRAM" not in output + assert "\x1b" not in output + assert "dcode -r test123" in output + + def _render_on_platform( + self, + platform: str, + *, + extra_env: dict[str, str] | None = None, + ) -> str: + """Render the resume hint as if running on `platform`. + + `patch.dict` only merges, so POSIX markers the developer's own shell + exports (`SHELL`, at minimum) are deleted first to make the simulated + native Windows environment hermetic. + """ + thread_exists_mock = AsyncMock(return_value=True) + buffer = StringIO() + console = Console(file=buffer, width=200) + invoked_name.cache_clear() + env = { + INVOKED_AS: "dcode", + "TERM_PROGRAM": "vscode", + LAUNCH_TERM_PROGRAM: "vscode", + **(extra_env or {}), + } + with ( + patch("deepagents_code.sessions.thread_exists", thread_exists_mock), + patch( + "deepagents_code.config.build_langsmith_thread_url", + return_value=None, + ), + patch.object(sys, "platform", platform), + patch.dict(os.environ, env), + ): + for marker in ("SHELL", "MSYSTEM", "WSL_DISTRO_NAME"): + if marker not in env: + os.environ.pop(marker, None) + _render_teardown_thread_hints(console, "test123", return_code=0) + return buffer.getvalue() + + def test_resume_hint_omits_prefix_on_native_windows(self) -> None: + """Native `cmd.exe`/PowerShell cannot parse a POSIX `VAR=value` prefix. + + VS Code and WezTerm set `TERM_PROGRAM` on every platform, so its + presence under `win32` says nothing about the user's shell. + """ + output = self._render_on_platform("win32") + + assert "TERM_PROGRAM" not in output + assert "dcode -r test123" in output + + @pytest.mark.parametrize( + "marker", + [ + {"SHELL": "C:\\Program Files\\Git\\bin\\bash.exe"}, + {"MSYSTEM": "MINGW64"}, + {"WSL_DISTRO_NAME": "Ubuntu"}, + ], + ) + def test_resume_hint_keeps_prefix_on_windows_posix_shells( + self, marker: dict[str, str] + ) -> None: + """git-bash/MSYS/WSL expose POSIX markers, so the prefix is valid there.""" + output = self._render_on_platform("win32", extra_env=marker) + + assert "TERM_PROGRAM=vscode dcode -r test123" in output + def test_prints_langsmith_link_when_available(self) -> None: """A configured LangSmith URL is shown alongside the resume hint.""" thread_exists_mock = AsyncMock(return_value=True) diff --git a/libs/code/tests/unit_tests/tui/widgets/test_messages.py b/libs/code/tests/unit_tests/tui/widgets/test_messages.py index 5408227446..0c700fa18e 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_messages.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_messages.py @@ -924,7 +924,10 @@ async def test_execute_shows_took_after_success(self) -> None: async with app.run_test() as pilot: await pilot.pause() app.msg.set_running() - app.msg._start_time -= 5 # ty: ignore + # 4.9 keeps the faked elapsed off the `.05` rounding boundary: the + # wall clock keeps running between the subtraction and + # `set_success`, so an exact `-5` can render as `5.1s`. + app.msg._start_time -= 4.9 # ty: ignore app.msg.set_success("done") await pilot.pause() @@ -933,7 +936,7 @@ async def test_execute_shows_took_after_success(self) -> None: assert status.display is True content = status._Static__content # ty: ignore assert isinstance(content, Content) - assert content.plain == "Took 5s" + assert content.plain == "Took 4.9s" async def test_execute_shows_fractional_seconds(self) -> None: """Sub-minute `execute` runs report tenths — `elapsed` is a float.