diff --git a/libs/code/deepagents_code/_env_vars.py b/libs/code/deepagents_code/_env_vars.py index 686399aae0..42eccb8c24 100644 --- a/libs/code/deepagents_code/_env_vars.py +++ b/libs/code/deepagents_code/_env_vars.py @@ -452,6 +452,15 @@ upgrade/restart loop. Set and read internally across `os.execv`. """ +RESUME_TERM_PROGRAM = "DEEPAGENTS_CODE_RESUME_TERM_PROGRAM" +"""Include launch-time `TERM_PROGRAM` in teardown resume commands. + +Disabled by default and enabled by default in experimental or debug mode. An +explicit boolean (`1`/`true`/`yes`/`on`, or `0`/`false`/`no`/`off`) overrides +that mode-dependent default, as does an empty value, which reads as false. Also +settable as `[features].resume_term_program` in config.toml. +""" + RIPGREP_INSTALLER = "DEEPAGENTS_CODE_RIPGREP_INSTALLER" """Select how ripgrep is provisioned: `managed` (default) or `system`. diff --git a/libs/code/deepagents_code/config_manifest.py b/libs/code/deepagents_code/config_manifest.py index 03b7b29ab2..18edb27bfb 100644 --- a/libs/code/deepagents_code/config_manifest.py +++ b/libs/code/deepagents_code/config_manifest.py @@ -138,8 +138,8 @@ class OptionKind(Enum): """How an option's raw env/TOML value is coerced to a typed value. All kinds flow through `resolve_scalar`. The scalar kinds (`BOOL`, - `BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced inline by - `_coerce_env`/`_coerce_toml`. `LOG_LEVEL_DELEGATE`, `SHELL_LIST_DELEGATE`, + `BOOL_MODE_DEFAULT`, `BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced + inline by `_coerce_env`/`_coerce_toml`. `LOG_LEVEL_DELEGATE`, `SHELL_LIST_DELEGATE`, `SKILLS_DIRS_DELEGATE`, `PTC_DELEGATE`, and `STARTUP_MODE_DELEGATE` defer to bespoke parsers (their semantics — dynamic debug fallback, colon-split Path resolution, comma + `recommended`/`all` sentinels, and the PTC/startup-mode @@ -153,6 +153,11 @@ class OptionKind(Enum): """Recognized truthy (`1`/`true`/`yes`/`on`) or falsy (`0`/`false`/`no`/`off`) tokens; an unrecognized value is logged and skipped to the next layer.""" + BOOL_MODE_DEFAULT = "bool_mode_default" + """Same token handling as `BOOL`, but with no static default: when no env or + TOML value applies, `resolve_scalar` derives the default from debug or + experimental mode. Declaring a `default` is rejected at construction.""" + BOOL_PRESENCE = "bool_presence" """Any non-empty env value enables the flag (e.g. debug injectors).""" @@ -189,6 +194,7 @@ class OptionKind(Enum): _KIND_TYPE_LABEL: dict[OptionKind, str] = { OptionKind.BOOL: "bool", + OptionKind.BOOL_MODE_DEFAULT: "bool", OptionKind.BOOL_PRESENCE: "bool", OptionKind.INT: "int", OptionKind.FLOAT: "float", @@ -213,6 +219,8 @@ class OptionKind(Enum): # Python types accepted for a `ConfigOption.default` of each scalar kind, # enforced by `ConfigOption.__post_init__`. Delegate kinds accept their parser's # output shape and are validated by those parsers, so they are omitted here. +# `BOOL_MODE_DEFAULT` is omitted for the opposite reason: it must not declare a +# default at all, so there is no value here to type-check. _KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = { OptionKind.BOOL: (bool,), OptionKind.BOOL_PRESENCE: (bool,), @@ -336,7 +344,10 @@ def __post_init__(self) -> None: f"strings, got {self.fallback_env_vars!r}" ) raise TypeError(msg) - if self.empty_env_is_false and self.kind is not OptionKind.BOOL: + if self.empty_env_is_false and self.kind not in { + OptionKind.BOOL, + OptionKind.BOOL_MODE_DEFAULT, + }: msg = f"{self.key}: empty_env_is_false requires a bool option kind" raise TypeError(msg) @@ -354,6 +365,16 @@ def __post_init__(self) -> None: if self.kind is OptionKind.STRUCTURED: msg = f"{self.key}: STRUCTURED options must not declare a default" raise TypeError(msg) + if self.kind is OptionKind.BOOL_MODE_DEFAULT: + # `resolve_scalar` computes this kind's default from debug/experimental + # mode and returns before reading `default`, so a declared value would + # be dead -- yet `dcode config` still renders it, advertising a default + # that contradicts the real one. + msg = ( + f"{self.key}: BOOL_MODE_DEFAULT options must not declare a " + "default; the default follows debug/experimental mode" + ) + raise TypeError(msg) if self.invert_toml_bool: self._validate_invert_toml_bool() expected = _KIND_DEFAULT_TYPES.get(self.kind) @@ -377,7 +398,11 @@ def _validate_invert_toml_bool(self) -> None: Raises: TypeError: When the marker is used without a boolean TOML source. """ - if self.kind not in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}: + if self.kind not in { + OptionKind.BOOL, + OptionKind.BOOL_MODE_DEFAULT, + OptionKind.BOOL_PRESENCE, + }: msg = f"{self.key}: invert_toml_bool requires a boolean option kind" raise TypeError(msg) if self.toml_keys is None: @@ -459,7 +484,7 @@ def _coerce_env(option: ConfigOption, raw: str, name: str) -> object: The typed value, or `_INVALID` when the raw value cannot be coerced. """ kind = option.kind - if kind is OptionKind.BOOL: + if kind in {OptionKind.BOOL, OptionKind.BOOL_MODE_DEFAULT}: classified = classify_env_bool(raw) if classified is None: # Unrecognized boolean token: log and fall through like every other @@ -557,7 +582,11 @@ def _coerce_toml(option: ConfigOption, raw: object) -> object: kind = option.kind label = option.toml_path or option.key - if kind in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}: + if kind in { + OptionKind.BOOL, + OptionKind.BOOL_MODE_DEFAULT, + OptionKind.BOOL_PRESENCE, + }: if isinstance(raw, bool): return not raw if option.invert_toml_bool else raw elif kind is OptionKind.INT: @@ -684,7 +713,8 @@ def resolve_scalar( Resolution order is: the prefixed primary `env_var`, then each `fallback_env_vars` name in declaration order, then `config.toml`, then the - typed `default`. + typed `default` -- or, for `BOOL_MODE_DEFAULT`, a default derived from debug + or experimental mode rather than from `option.default`. Returns: `(value, source)`, where `source` is `env ()`, `config.toml`, or @@ -731,6 +761,11 @@ def resolve_scalar( if value is not _INVALID: return value, "config.toml" + if option.kind is OptionKind.BOOL_MODE_DEFAULT: + from deepagents_code._env_vars import DEBUG, EXPERIMENTAL, is_env_truthy + + return is_env_truthy(DEBUG) or is_env_truthy(EXPERIMENTAL), "default" + if option.kind is OptionKind.LOG_LEVEL_DELEGATE: from deepagents_code._env_vars import DEBUG, is_env_truthy @@ -1431,6 +1466,18 @@ def _credential_options() -> tuple[ConfigOption, ...]: default=False, env_var=_env_vars.EXPERIMENTAL, ), + ConfigOption( + key="features.resume_term_program", + group="Tools", + summary=( + "Include launch-time TERM_PROGRAM in resume hints; defaults on in " + "experimental or debug mode." + ), + kind=OptionKind.BOOL_MODE_DEFAULT, + env_var=_env_vars.RESUME_TERM_PROGRAM, + empty_env_is_false=True, + toml_keys=("features", "resume_term_program"), + ), ConfigOption( key="events.external_socket", group="Tools", diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 35a2892894..e4c47f4112 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -266,25 +266,42 @@ def _should_check_teardown_thread( def _resume_term_program() -> str | None: - """Return a `TERM_PROGRAM` value safe to echo inside the resume hint. + """Return the `TERM_PROGRAM` value to echo in the resume hint, if any. - 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. + Gated on `features.resume_term_program` (off unless the user opts in, on by + default in debug or experimental mode), so this reads `config.toml` from + disk. The value comes from `LAUNCH_TERM_PROGRAM` -- the snapshot `cli_main` + takes at process entry -- so a `TERM_PROGRAM` that only 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`. + The printable launch-time value when the feature is enabled, 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. + 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` because they cannot parse the POSIX + `VAR=value` prefix. POSIX markers (`SHELL` from git-bash/MSYS, + `MSYSTEM`, `WSL_DISTRO_NAME`) restore the prefix there. """ + from deepagents_code.config_manifest import ( + get_option, + load_config_toml, + resolve_scalar, + ) + + option = get_option("features.resume_term_program") + if option is None: + # Unreachable unless the manifest key is renamed without updating this + # literal; log so that mismatch surfaces instead of silently defaulting. + logger.warning( + "Unknown config option %r; omitting TERM_PROGRAM from the resume hint", + "features.resume_term_program", + ) + return None + enabled, _ = resolve_scalar(option, toml_data=load_config_toml()) + if not enabled: + return None + raw = os.environ.get(LAUNCH_TERM_PROGRAM, "").strip() if not raw or not raw.isprintable(): return None @@ -354,14 +371,18 @@ def _render_teardown_thread_hints( 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() + # the bare command would resume without it. Carrying the launch-time value + # as an env prefix keeps the line pasteable as-is. Guarded because this + # reads `config.toml`: unlike the rest of this function, it can raise, and + # an exception here would replace whatever is already unwinding. + try: + term_program = _resume_term_program() + except Exception: + logger.debug( + "Could not resolve resume TERM_PROGRAM on teardown", + exc_info=True, + ) + term_program = None if term_program is not None: resume_command = f"TERM_PROGRAM={shlex.quote(term_program)} {resume_command}" console.print(Text(resume_command, style="cyan")) diff --git a/libs/code/tests/unit_tests/test_config_manifest.py b/libs/code/tests/unit_tests/test_config_manifest.py index b62ac37722..90f5877c62 100644 --- a/libs/code/tests/unit_tests/test_config_manifest.py +++ b/libs/code/tests/unit_tests/test_config_manifest.py @@ -429,6 +429,109 @@ def test_invalid_goal_auto_accept_env_falls_through( assert resolve_scalar(option, toml_data=toml_data) == expected +@pytest.mark.parametrize( + ("mode", "expected"), + [(None, False), (_env_vars.DEBUG, True), (_env_vars.EXPERIMENTAL, True)], +) +def test_resume_term_program_resolves_mode_default( + monkeypatch: pytest.MonkeyPatch, + mode: str | None, + expected: bool, +) -> None: + """The resume prefix defaults on only in experimental or debug mode.""" + option = get_option("features.resume_term_program") + assert option is not None + monkeypatch.delenv(_env_vars.RESUME_TERM_PROGRAM, raising=False) + monkeypatch.delenv(_env_vars.DEBUG, raising=False) + monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False) + if mode is not None: + monkeypatch.setenv(mode, "1") + + assert resolve_scalar(option, toml_data={}) == (expected, "default") + + +@pytest.mark.parametrize(("raw", "expected"), [("1", True), ("0", False), ("", False)]) +def test_resume_term_program_env_overrides_mode_default( + monkeypatch: pytest.MonkeyPatch, + raw: str, + expected: bool, +) -> None: + """An explicit feature env value wins over mode and TOML values.""" + option = get_option("features.resume_term_program") + assert option is not None + monkeypatch.setenv(_env_vars.DEBUG, "1") + monkeypatch.setenv(_env_vars.EXPERIMENTAL, "1") + monkeypatch.setenv(_env_vars.RESUME_TERM_PROGRAM, raw) + + assert resolve_scalar( + option, + toml_data={"features": {"resume_term_program": not expected}}, + ) == (expected, f"env ({_env_vars.RESUME_TERM_PROGRAM})") + + +@pytest.mark.parametrize( + ("configured", "mode", "expected"), + [ + (True, None, True), + (False, _env_vars.DEBUG, False), + (False, _env_vars.EXPERIMENTAL, False), + ], +) +def test_resume_term_program_toml_overrides_mode_default( + monkeypatch: pytest.MonkeyPatch, + configured: bool, + mode: str | None, + expected: bool, +) -> None: + """An explicit config.toml value wins over the mode-dependent default.""" + option = get_option("features.resume_term_program") + assert option is not None + monkeypatch.delenv(_env_vars.RESUME_TERM_PROGRAM, raising=False) + monkeypatch.delenv(_env_vars.DEBUG, raising=False) + monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False) + if mode is not None: + monkeypatch.setenv(mode, "1") + + assert resolve_scalar( + option, + toml_data={"features": {"resume_term_program": configured}}, + ) == (expected, "config.toml") + + +def test_resume_term_program_unrecognized_env_falls_through_to_mode_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A typo'd feature flag must not silently defeat debug mode.""" + option = get_option("features.resume_term_program") + assert option is not None + monkeypatch.delenv(_env_vars.EXPERIMENTAL, raising=False) + monkeypatch.setenv(_env_vars.DEBUG, "1") + monkeypatch.setenv(_env_vars.RESUME_TERM_PROGRAM, "maybe") + + assert resolve_scalar(option, toml_data={}) == (True, "default") + + +def test_bool_mode_default_rejects_declared_default() -> None: + """A declared default is dead for this kind, so it is rejected up front. + + `resolve_scalar` derives the default from debug/experimental mode and + returns before reading `default` -- but `dcode config` still renders the + declared value, advertising a default that contradicts the real one. + """ + option = get_option("features.resume_term_program") + assert option is not None + assert option.default is None + + with pytest.raises(TypeError, match="must not declare a default"): + ConfigOption( + key="features.example", + group="Tools", + summary="Example.", + kind=OptionKind.BOOL_MODE_DEFAULT, + default=False, + ) + + def test_debug_log_level_resolves_dynamic_default(monkeypatch) -> None: """The effective log level follows debug mode when no level is explicit.""" option = get_option("debug.log_level") diff --git a/libs/code/tests/unit_tests/test_main.py b/libs/code/tests/unit_tests/test_main.py index 9f5d2953dd..6ad37f355c 100644 --- a/libs/code/tests/unit_tests/test_main.py +++ b/libs/code/tests/unit_tests/test_main.py @@ -18,7 +18,13 @@ if TYPE_CHECKING: from prompt_toolkit.layout import Layout -from deepagents_code._env_vars import INVOKED_AS, LAUNCH_TERM_PROGRAM +from deepagents_code._env_vars import ( + DEBUG, + EXPERIMENTAL, + INVOKED_AS, + LAUNCH_TERM_PROGRAM, + RESUME_TERM_PROGRAM, +) from deepagents_code._invocation import invoked_name from deepagents_code.app import ( AppResult, @@ -1918,39 +1924,45 @@ def _render( launch_name: str = "dcode", term_program: str = "", launch_term_program: str | None = None, + resume_term_program: bool | None = None, + debug: bool = False, + experimental: bool = False, + toml_data: dict | None = None, + toml_error: Exception | None = None, ) -> str: - """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. - """ + """Render teardown hints under controlled feature configuration.""" 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} + env = { + INVOKED_AS: launch_name, + "TERM_PROGRAM": term_program, + DEBUG: "1" if debug else "0", + EXPERIMENTAL: "1" if experimental else "0", + } if launch_term_program is not None: env[LAUNCH_TERM_PROGRAM] = launch_term_program + if resume_term_program is not None: + env[RESUME_TERM_PROGRAM] = "1" if resume_term_program else "0" with ( patch("deepagents_code.sessions.thread_exists", thread_exists_mock), patch( "deepagents_code.config.build_langsmith_thread_url", return_value=thread_url, ), - # 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( + "deepagents_code.config_manifest.load_config_toml", + side_effect=toml_error, + return_value={} if toml_data is None else toml_data, + ), 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) + if resume_term_program is None: + os.environ.pop(RESUME_TERM_PROGRAM, None) _render_teardown_thread_hints(console, "test123", return_code=return_code) return buffer.getvalue() @@ -1969,6 +1981,45 @@ 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_honors_toml_feature_flag(self) -> None: + """`[features] resume_term_program` reaches the hint without an env var. + + The helper otherwise stubs `load_config_toml` to `{}`, so without this + case the entire config.toml route to the prefix could break with the + suite still green. + """ + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + launch_term_program="iTerm.app", + toml_data={"features": {"resume_term_program": True}}, + ) + + assert "TERM_PROGRAM=iTerm.app dcode -r test123" in output + + def test_resume_hint_survives_config_read_failure(self) -> None: + """A raising config read must not take down the exit path. + + `_render_teardown_thread_hints` runs from a bare `finally` in + `cli_main`, so an exception escaping here would replace whatever is + already unwinding -- including the `KeyboardInterrupt` that produces + exit code 130. + """ + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + launch_term_program="iTerm.app", + resume_term_program=True, + toml_error=RecursionError("deeply nested TOML"), + ) + + assert "dcode -r test123" in output + assert "TERM_PROGRAM=" not in output + @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`.""" @@ -1985,13 +2036,10 @@ def test_resume_hint_echoes_launch_command(self, return_code: int) -> None: 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). - """ + def test_resume_hint_carries_term_program_when_enabled( + self, return_code: int + ) -> None: + """An enabled launch-time `TERM_PROGRAM` rides along as an env prefix.""" thread_exists_mock = AsyncMock(return_value=True) output = self._render( @@ -2000,10 +2048,61 @@ def test_resume_hint_carries_term_program(self, return_code: int) -> None: return_code=return_code, term_program="WezTerm", launch_term_program="WezTerm", + resume_term_program=True, + ) + + assert "TERM_PROGRAM=WezTerm dcode -r test123" in output + + def test_resume_hint_omits_term_program_by_default(self) -> None: + """An ambient launch value is not echoed without an enabling mode or flag.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="WezTerm", + launch_term_program="WezTerm", + ) + + assert "TERM_PROGRAM" not in output + assert "dcode -r test123" in output + + @pytest.mark.parametrize("mode", ["debug", "experimental"]) + def test_resume_hint_carries_term_program_in_enabled_modes(self, mode: str) -> None: + """Debug and experimental mode each enable the prefix by default.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="WezTerm", + launch_term_program="WezTerm", + debug=mode == "debug", + experimental=mode == "experimental", ) assert "TERM_PROGRAM=WezTerm dcode -r test123" in output + @pytest.mark.parametrize("mode", ["debug", "experimental"]) + def test_resume_hint_explicit_disable_overrides_enabled_modes( + self, mode: str + ) -> None: + """The feature flag can suppress the mode-dependent opt-in.""" + thread_exists_mock = AsyncMock(return_value=True) + + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + term_program="WezTerm", + launch_term_program="WezTerm", + resume_term_program=False, + debug=mode == "debug", + experimental=mode == "experimental", + ) + + assert "TERM_PROGRAM" not in output + assert "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) @@ -2012,6 +2111,7 @@ def test_resume_hint_omits_term_program_without_launch_snapshot(self) -> None: thread_exists_mock=thread_exists_mock, thread_url=None, term_program="WezTerm", + resume_term_program=True, ) assert "TERM_PROGRAM" not in output @@ -2021,7 +2121,11 @@ 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) + output = self._render( + thread_exists_mock=thread_exists_mock, + thread_url=None, + resume_term_program=True, + ) assert "dcode -r test123" in output assert "TERM_PROGRAM" not in output @@ -2036,6 +2140,7 @@ def test_resume_hint_omits_blank_term_program(self, term_program: str) -> None: thread_url=None, term_program=term_program, launch_term_program=term_program, + resume_term_program=True, ) assert "TERM_PROGRAM" not in output @@ -2049,6 +2154,7 @@ def test_resume_hint_quotes_term_program_needing_quotes(self) -> None: thread_url=None, term_program="Wez Term&whoami", launch_term_program="Wez Term&whoami", + resume_term_program=True, ) assert "TERM_PROGRAM='Wez Term&whoami' dcode -r test123" in output @@ -2066,6 +2172,7 @@ def test_resume_hint_drops_term_program_with_control_characters(self) -> None: thread_url=None, term_program="Wez\x1b\nTerm", launch_term_program="Wez\x1b\nTerm", + resume_term_program=True, ) assert "TERM_PROGRAM" not in output @@ -2092,6 +2199,7 @@ def _render_on_platform( INVOKED_AS: "dcode", "TERM_PROGRAM": "vscode", LAUNCH_TERM_PROGRAM: "vscode", + RESUME_TERM_PROGRAM: "1", **(extra_env or {}), } with ( @@ -2100,6 +2208,7 @@ def _render_on_platform( "deepagents_code.config.build_langsmith_thread_url", return_value=None, ), + patch("deepagents_code.config_manifest.load_config_toml", return_value={}), patch.object(sys, "platform", platform), patch.dict(os.environ, env), ):