fix(code): persist Auto approval mode between sessions (#5665)

Auto and Manual approval selections now carry across bare `dcode`
launches, while YOLO remains session-only unless explicitly configured.

---

Approval-mode switches now write an app-managed `[startup].recent`
value, resolved behind the intentional `[startup].mode` default and
validated fail-closed. This mirrors the existing recent model and agent
behavior.

Verified with the focused approval-mode, startup-config,
argument-resolution, and config-manifest tests (353 passed), plus
package format, lint, type, and command-catalog checks.

Made by [Open
SWE](https://openswe.vercel.app/agents/d0bca443-1400-5356-aecc-a6ee03f86560)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-08-21 16:24:42 -04:00
committed by GitHub
parent 9767f420c6
commit 3ac059e157
8 changed files with 935 additions and 30 deletions
+103 -6
View File
@@ -3608,9 +3608,18 @@ class DeepAgentsApp(App):
self._sandbox_type: str | None = raw if raw and raw != "none" else None
"""Normalized sandbox type (or `None`), attached to trace metadata."""
self._auto_mode_eligible = self._sandbox_type is None
self._auto_downgraded_by_sandbox = False
"""Whether a startup Auto was downgraded because a sandbox is active."""
self._persisted_startup_mode: str | None = None
"""Last mode this session wrote to `[startup].recent`, if any."""
if self._approval_mode is ApprovalMode.AUTO and not self._auto_mode_eligible:
self._approval_mode = ApprovalMode.MANUAL
self._auto_approve = False
# `notify` is unavailable this early, so defer the explanation to
# `_notify_auto_mode_not_restored`. A restored `[startup].recent`
# makes this reachable without the user choosing Auto this launch,
# so a silent downgrade reads as the preference being forgotten.
self._auto_downgraded_by_sandbox = True
self._approval_mode_blocked = False
self._auto_mode_notice_pending = False
self._yolo_mode_notice_pending = False
@@ -4626,6 +4635,7 @@ class DeepAgentsApp(App):
self.call_after_refresh(self._notify_interpreter_tools_without_interpreter)
self.call_after_refresh(self._notify_interpreter_disabled_by_sandbox)
self.call_after_refresh(self._notify_orphaned_tracing_disabled)
self.call_after_refresh(self._notify_auto_mode_not_restored)
# Surface a `-m`/`--message` prompt as a queued message right away,
# while the server is still connecting, instead of waiting for
@@ -4660,6 +4670,34 @@ class DeepAgentsApp(App):
except Exception:
logger.exception("Failed to surface orphaned-tracing disabled notice")
def _notify_auto_mode_not_restored(self) -> None:
"""Toast when a remembered Auto mode did not survive startup.
Two independent causes land here, and neither is visible on its own:
the notice gate declined a stored `[startup].recent = "auto"` (recorded
in `model_config`), or a sandbox made Auto ineligible. Both leave the
status bar reading `MANUAL` with nothing to explain it, which reads as
the preference not being remembered at all.
"""
from deepagents_code.model_config import (
consume_recent_auto_not_restored_notice,
)
notice = consume_recent_auto_not_restored_notice()
if self._auto_downgraded_by_sandbox:
self._auto_downgraded_by_sandbox = False
# The sandbox is the operative reason: it blocks Auto for the whole
# session, so it outranks a stale notice the user could re-confirm.
notice = "Auto is unavailable with a sandbox; starting in Manual."
if notice is None:
return
# Best-effort, like the other deferred advisories: the durable channel
# is the `logger.warning` at each mutation site.
try:
self.notify(notice, severity="warning", timeout=8, markup=False)
except Exception:
logger.exception("Failed to surface Auto-not-restored notice")
def _notify_interpreter_tools_without_interpreter(self) -> None:
"""Toast when `--interpreter-tools` was set while the interpreter is off.
@@ -9423,6 +9461,37 @@ class DeepAgentsApp(App):
self._session_state.approval_mode_key = live_key
return True
async def _persist_startup_approval_mode(self, mode: ApprovalMode) -> None:
"""Persist a safe app-selected mode for the next bare launch.
YOLO returns before any await, so callers that must warn before
suspending are unaffected when they select it.
Args:
mode: Mode the user just selected.
"""
from deepagents_code.approval_mode import ApprovalMode
from deepagents_code.model_config import save_recent_startup_mode
if mode is ApprovalMode.YOLO:
return
if mode.value == self._persisted_startup_mode:
# Two entry points can activate Auto from one confirmation modal
# (the awaiting caller and the task the modal result schedules), so
# skip the duplicate round-trip. Without this, a read-only config
# directory reports the same failure twice as two distinct toasts.
return
if not await asyncio.to_thread(save_recent_startup_mode, mode.value):
self.notify(
"Approval mode changed for this session, but the startup "
"preference could not be saved. Check permissions for "
"~/.deepagents/.",
severity="warning",
markup=False,
)
return
self._persisted_startup_mode = mode.value
def _warn_live_approval_mode_unavailable(self, message: str) -> None:
"""Surface live approval-mode degradation to the user."""
self.notify(message, severity="warning", timeout=8, markup=False)
@@ -9555,13 +9624,21 @@ class DeepAgentsApp(App):
self._status_bar.set_approval_mode(ApprovalMode.AUTO.value)
if self._session_state:
self._session_state.approval_mode = ApprovalMode.AUTO
# Notify before the awaits below. State is already committed above, so
# the classifier notice must be attempted before a suspension point that
# can raise or be cancelled.
self._notify_auto_classifier_active()
await self._persist_startup_approval_mode(ApprovalMode.AUTO)
await self._auto_accept_pending_goal_rubric()
return True
async def _switch_to_manual_from_fallback(self) -> bool:
"""Persist Manual before asking again about a fallback action.
Manual also becomes the next launch's startup mode, replacing a stored
Auto. The user chose Manual at this prompt, so the preference follows
the choice.
Returns:
`True` when Manual is active.
"""
@@ -9582,6 +9659,7 @@ class DeepAgentsApp(App):
self._session_state.approval_mode = ApprovalMode.MANUAL
if self._status_bar:
self._status_bar.set_approval_mode(ApprovalMode.MANUAL.value)
await self._persist_startup_approval_mode(ApprovalMode.MANUAL)
return True
async def _remove_inline_prompt_widget( # noqa: PLR6301 # Shared inline-prompt cleanup; kept an instance method for handler symmetry
@@ -20713,6 +20791,12 @@ class DeepAgentsApp(App):
persisted = await self._write_live_approval_mode(ApprovalMode.MANUAL)
self._on_approval_mode_fallback(ApprovalMode.MANUAL.value)
# A stored `[startup].recent = "auto"` would otherwise put the
# next launch straight back into the mode the server just
# refused, discarding this safety decision at the session
# boundary. The user is told about the fallback below, so the
# startup preference has to follow it.
await self._persist_startup_approval_mode(ApprovalMode.MANUAL)
if not persisted:
logger.warning("Could not persist server-requested Manual fallback")
text = f"Auto fell back to Manual: {reason}"
@@ -20853,6 +20937,11 @@ class DeepAgentsApp(App):
async def _set_approval_mode(self, target: ApprovalMode) -> bool:
"""Apply an approval-mode change after optional live Store acknowledgement.
On success this also records Manual or Auto as the startup preference
for the next launch, and can warn that the record could not be written.
YOLO is never recorded. A rejected live write returns early, so nothing
durable is written for a mode the session did not enter.
Args:
target: Mode to select.
@@ -20897,18 +20986,26 @@ class DeepAgentsApp(App):
self._status_bar.set_approval_mode(target.value)
if target is ApprovalMode.AUTO:
self._notify_auto_classifier_active()
if should_persist_live:
await self._auto_accept_pending_goal_rubric()
elif target is ApprovalMode.YOLO:
# Warn before the await below. State is already committed above,
# Warn before the awaits below. State is already committed above,
# so if goal-rubric auto-accept raises, YOLO is active and the "no
# review" warning has already been attempted. AUTO notifies before
# its await for the same reason. Both can legitimately no-op — the
# its awaits for the same reason. Both can legitimately no-op — the
# YOLO toast when suppressed, the AUTO notice after its first run —
# so this ordering guarantees the attempt, not the delivery.
self._warn_yolo_active(timeout=8)
if should_persist_live:
await self._auto_accept_pending_goal_rubric()
# Persist after the live-write gate and the notices, but before the
# goal-rubric await: that helper is cancellable and can raise, which
# would otherwise commit the mode for this session while leaving
# `[startup].recent` on the previous one, so the next bare launch would
# revert a selection that succeeded. `_on_auto_approve_enabled` orders
# these the same way.
await self._persist_startup_approval_mode(target)
if should_persist_live and target in {
ApprovalMode.AUTO,
ApprovalMode.YOLO,
}:
await self._auto_accept_pending_goal_rubric()
return True
def _auto_classifier_display_spec(self) -> str | None:
+9 -7
View File
@@ -392,9 +392,11 @@ def _load_approval_state(path: Path) -> dict[str, object]:
"""Load the install-local approval state file, or an empty dict.
A missing file is the normal first-run case and returns `{}` silently.
Unreadable, corrupt, or non-object state also returns `{}` (so callers fail
closed and re-prompt) but is logged: the next save overwrites the file, so
this warning is the only surviving evidence of the corruption.
Unreadable, corrupt, or non-object state also returns `{}` but is logged:
the next save overwrites the file, so this warning is the only surviving
evidence of the corruption. Callers fail closed on `{}`. Most re-prompt;
`has_auto_mode_notice` callers may instead decline to restore Auto, so the
log must not promise a prompt.
Args:
path: Path to `approval.json`.
@@ -408,16 +410,16 @@ def _load_approval_state(path: Path) -> dict[str, object]:
return {}
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
logger.warning(
"Ignoring unreadable or corrupt approval state at %s; a re-prompt "
"may follow and the file will be overwritten on the next save",
"Ignoring unreadable or corrupt approval state at %s; callers fail "
"closed and the file will be overwritten on the next save",
path,
exc_info=True,
)
return {}
if not isinstance(data, dict):
logger.warning(
"Ignoring non-object approval state at %s; a re-prompt may follow "
"and the file will be overwritten on the next save",
"Ignoring non-object approval state at %s; callers fail closed and "
"the file will be overwritten on the next save",
path,
)
return {}
@@ -252,6 +252,7 @@ def _resolve(
resolve_auto_classifier_model_with_source,
resolve_auto_classifier_timeout_with_source,
resolve_scalar,
resolve_startup_mode_with_source,
)
from deepagents_code.model_config import ProviderAuthSource
@@ -294,6 +295,17 @@ def _resolve(
)
return source != "default", source, timeout
if option.key == "startup.mode":
# The manifest default that `resolve_scalar` returns ignores the
# app-managed `[startup].recent` fallback that `load_startup_mode`
# restores on a bare launch. Report the effective mode instead, so
# introspection matches what the next bare launch reads from the file.
mode, source = resolve_startup_mode_with_source(
toml_data=toml_data,
managed_toml_data=managed_toml_data,
)
return source != "default", source, mode
value, source = resolve_scalar(
option,
toml_data=toml_data,
@@ -1209,6 +1209,74 @@ def resolve_auto_classifier_model_with_source(
return None, source
def resolve_startup_mode_with_source(
*,
toml_data: Mapping[str, Any] | None = None,
managed_toml_data: Mapping[str, Any] | None = None,
) -> tuple[str, str]:
"""Resolve the effective startup approval mode and its source for display.
Mirrors `model_config.load_startup_mode`. An explicit `[startup].mode`
wins, from the user file or from managed policy. Otherwise the app-managed
`[startup].recent` value restores `manual` or notice-approved `auto`. So
`dcode config get startup.mode` reports the mode the next bare launch reads
from configuration, instead of the manifest default.
`startup.mode` declares no environment variable, so no env tier applies
here. The `--auto-approve` flag outranks configuration at launch and is out
of scope for this function.
Args:
toml_data: Parsed `config.toml`; loaded automatically when omitted.
managed_toml_data: Parsed managed TOML; the process snapshot is used when
omitted.
Returns:
`(mode, source)`. `source` credits the managed or user configuration
layer that supplied either the explicit mode or the recent fallback,
and is `"default"` when nothing resolves and when an invalid explicit
mode fails closed.
"""
from deepagents_code.model_config import is_recent_startup_mode_restorable
data = load_config_toml() if toml_data is None else toml_data
option = get_option("startup.mode")
if option is None:
return "manual", "default"
managed_data = (
load_managed_config_toml() if managed_toml_data is None else managed_toml_data
)
value, source = resolve_scalar(
option,
toml_data=data,
managed_toml_data=managed_data,
)
if source != "default":
return value, source
# No explicit mode resolved. An invalid user mode is fail-closed in
# `load_startup_mode`, so introspection must not consult `recent` either.
# Only the user layer is probed: `merge_managed_over_user` drops a managed
# leaf that fails its manifest kind, so a present-but-invalid mode can only
# come from the user file, and the loader cannot see one that this misses.
startup = data.get("startup")
if isinstance(startup, dict) and startup.get("mode") is not None:
return value, source
recent_option = get_option("startup.recent")
if recent_option is None:
return value, source
recent, recent_source = resolve_scalar(
recent_option,
toml_data=data,
managed_toml_data=managed_data,
)
if isinstance(recent, str) and is_recent_startup_mode_restorable(recent):
return recent, recent_source
return value, source
def option_accepts_toml(
option: ConfigOption, value: object, *, source: str = "config.toml"
) -> bool:
@@ -2326,6 +2394,22 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
env_var=_env_vars.READ_PROJECT_DOTENV,
toml_keys=("startup", "read_project_dotenv"),
),
ConfigOption(
key="startup.recent",
group="Startup",
summary=(
"Most recently selected Manual or Auto mode (managed by the app; "
"only `manual` and `auto` are restored)."
),
# Deliberately `STR`, not the `NON_EMPTY_STR` used by the sibling
# app-managed `agents.recent`: that kind strips, while
# `load_startup_mode` matches `recent` exactly. Stripping here would
# make `recent = " auto "` display as Auto while the launch fails closed
# to Manual. Exact coercion keeps introspection and startup on the same
# boundary, and an unmatched value fails closed either way.
kind=OptionKind.STR,
toml_keys=("startup", "recent"),
),
ConfigOption(
key="startup.yolo_switcher",
group="Startup",
+111 -12
View File
@@ -5613,24 +5613,74 @@ STARTUP_MODE_AUTO = "auto"
STARTUP_MODE_YOLO = "yolo"
"""Startup approval mode that executes gated actions without review."""
STARTUP_MODE_DANGEROUSLY_AUTO = "dangerously-auto"
"""Rejected legacy spelling retained only for migration diagnostics."""
VALID_STARTUP_MODES = frozenset(
{STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO, STARTUP_MODE_YOLO}
)
"""Accepted values for the `[startup].mode` config option."""
RECENT_STARTUP_MODES = frozenset({STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO})
"""Modes the app may restore implicitly from `[startup].recent`."""
_RECENT_AUTO_NOT_RESTORED_NOTICE = (
"Auto was not restored for this session because its guidance notice is "
"missing or out of date. Press Shift+Tab to review it and re-enable Auto."
)
"""User-facing copy for a remembered Auto that the notice gate declined."""
_recent_auto_not_restored_notice: str | None = None
"""One-shot TUI notice populated when the notice gate declines a stored Auto."""
def consume_recent_auto_not_restored_notice() -> str | None:
"""Return and clear the pending not-restored notice, if any."""
global _recent_auto_not_restored_notice # noqa: PLW0603
notice = _recent_auto_not_restored_notice
_recent_auto_not_restored_notice = None
return notice
DEFAULT_STARTUP_MODE = STARTUP_MODE_MANUAL
"""Fallback startup mode when `[startup].mode` is missing, unreadable, or invalid."""
"""Fail-closed startup mode.
Returned when no mode resolves, when `[startup]` is absent or is not a table,
when the config is unreadable, and when a stored recent Auto is not restorable.
"""
def is_recent_startup_mode_restorable(mode: str) -> bool:
"""Return whether an app-managed recent mode may be restored.
Auto restoration requires the current versioned education notice. Manual
remains safe to restore without one. No caller prompts: `False` means the
caller falls back to `manual`.
Args:
mode: Candidate value from `[startup].recent`.
Returns:
Whether startup may restore the mode.
"""
if mode not in RECENT_STARTUP_MODES:
return False
if mode != STARTUP_MODE_AUTO:
return True
# Function-local: `approval_mode` imports this module, so a module-level
# import would close the cycle.
from deepagents_code.approval_mode import has_auto_mode_notice
return has_auto_mode_notice()
def load_startup_mode(config_path: Path | None = None) -> str:
"""Load the default startup approval mode from config.toml.
"""Load the startup approval mode from config.toml.
Reads `[startup].mode`, which accepts fail-closed `manual`, classifier-backed
`auto`, or unrestricted `yolo`. The removed `dangerously-auto` spelling is
invalid and falls back to `manual`.
An explicit `[startup].mode` outranks the app-managed `[startup].recent`
value. An invalid explicit mode fails closed to `manual` and never consults
`recent`. `recent` restores `manual`, or classifier-backed `auto` once the
current notice has been shown. Unrestricted `yolo` must stay explicitly
configured.
Args:
config_path: Path to config file.
@@ -5646,10 +5696,13 @@ def load_startup_mode(config_path: Path | None = None) -> str:
try:
data, _ = _load_effective_config_data(config_path)
startup = data.get("startup")
value = startup.get("mode") if isinstance(startup, dict) else None
# `value` may be any TOML type; guard against non-strings (e.g. an
# array or table) before the frozenset membership test, which would
# otherwise raise `TypeError: unhashable type` and crash startup.
if not isinstance(startup, dict):
return DEFAULT_STARTUP_MODE
# TOML values carry any type. The isinstance guards here and on
# `recent` below keep an array or table out of the frozenset membership
# tests, which would raise `TypeError: unhashable type` — uncaught by
# the handler below — and crash startup.
value = startup.get("mode")
if isinstance(value, str) and value in VALID_STARTUP_MODES:
return value
if value is not None:
@@ -5657,11 +5710,57 @@ def load_startup_mode(config_path: Path | None = None) -> str:
"Ignoring [startup].mode=%r (expected 'manual', 'auto', or 'yolo')",
value,
)
return DEFAULT_STARTUP_MODE
recent = startup.get("recent")
# Re-test membership here so only an invalid value takes the warning
# below; a valid-but-notice-blocked Auto is a normal fail-closed, and
# gets its own diagnostic instead of being reported as a config error.
if isinstance(recent, str) and recent in RECENT_STARTUP_MODES:
if is_recent_startup_mode_restorable(recent):
return recent
# The only exit that discards a *valid* user-earned preference.
# Without this it is indistinguishable from the feature not working:
# a notice-version bump silently returns every Auto user to Manual.
global _recent_auto_not_restored_notice # noqa: PLW0603
logger.warning(
"Not restoring [startup].recent=%r: the Auto notice is missing "
"or out of date; starting in %s",
recent,
DEFAULT_STARTUP_MODE,
)
_recent_auto_not_restored_notice = _RECENT_AUTO_NOT_RESTORED_NOTICE
return DEFAULT_STARTUP_MODE
if recent is not None:
logger.warning(
"Ignoring [startup].recent=%r (expected 'manual' or 'auto')",
recent,
)
except (OSError, tomllib.TOMLDecodeError):
logger.debug("Could not read startup mode config", exc_info=True)
return DEFAULT_STARTUP_MODE
def save_recent_startup_mode(mode: str, config_path: Path | None = None) -> bool:
"""Save the most recently selected safe startup approval mode.
Args:
mode: `"manual"` or `"auto"`.
config_path: Path to config file.
Returns:
`True` when the preference was saved, otherwise `False`.
Raises:
ValueError: If `mode` is not `"manual"` or `"auto"`. `yolo` must stay
explicitly configured, so it is never stored as a recent mode.
"""
if mode not in RECENT_STARTUP_MODES:
msg = f"Invalid recent startup mode: {mode!r}"
raise ValueError(msg)
return _save_toml_field("startup", "recent", mode, config_path)
def save_thread_sort_order(sort_order: str, config_path: Path | None = None) -> bool:
"""Save the sort order preference for the thread selector.
+288
View File
@@ -29399,6 +29399,20 @@ class TestLiveApprovalModeWrites:
)
assert app._approval_mode is ApprovalMode.MANUAL
# Flagged for the post-mount toast: a restored preference makes this
# downgrade reachable without the user choosing Auto this launch.
assert app._auto_downgraded_by_sandbox is True
def test_no_sandbox_downgrade_flag_without_auto(self) -> None:
"""Manual under a sandbox has nothing to explain."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp(
approval_mode=ApprovalMode.MANUAL,
server_kwargs={"sandbox_type": "daytona"},
)
assert app._auto_downgraded_by_sandbox is False
async def test_write_live_approval_mode_records_key(self) -> None:
from deepagents_code.approval_mode import (
@@ -30269,6 +30283,239 @@ class TestLiveApprovalModeWrites:
warn.assert_called_once()
assert "YOLO could not be persisted" in str(warn.call_args.args[0])
@pytest.mark.parametrize("mode_value", ["manual", "auto"])
async def test_set_approval_mode_persists_safe_startup_mode(
self, mode_value: str
) -> None:
"""A user-selected Manual or Auto becomes the next launch's mode."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
mode = ApprovalMode(mode_value)
with (
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent,
patch.object(app, "_notify_auto_classifier_active"),
):
assert await app._set_approval_mode(mode) is True
save_recent.assert_called_once_with(mode_value)
async def test_set_approval_mode_warns_when_startup_mode_save_fails(
self,
) -> None:
"""The session change stands; only the durable record is reported lost."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
with (
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=False,
),
patch.object(app, "notify") as notify,
):
assert await app._set_approval_mode(ApprovalMode.MANUAL) is True
notify.assert_called_once_with(
"Approval mode changed for this session, but the startup preference "
"could not be saved. Check permissions for ~/.deepagents/.",
severity="warning",
markup=False,
)
async def test_set_approval_mode_does_not_persist_yolo_as_recent(self) -> None:
"""YOLO stays opt-in every launch, so it is never recorded."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
with (
patch(
"deepagents_code.model_config.save_recent_startup_mode"
) as save_recent,
patch.object(app, "_warn_yolo_active"),
):
assert await app._set_approval_mode(ApprovalMode.YOLO) is True
save_recent.assert_not_called()
async def test_set_approval_mode_persists_before_goal_rubric_can_raise(
self,
) -> None:
"""An accepted mode is recorded even if goal-rubric cleanup fails.
The rubric helper is cancellable and can raise. If it ran first, the
session would sit in Auto while the next launch reverted to the old
mode, undoing a selection that actually succeeded.
"""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
app._agent = object()
with (
patch.object(
app,
"_write_live_approval_mode",
new=AsyncMock(return_value=True),
),
patch.object(
app,
"_auto_accept_pending_goal_rubric",
new=AsyncMock(side_effect=RuntimeError("boom")),
),
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent,
patch.object(app, "_notify_auto_classifier_active"),
pytest.raises(RuntimeError),
):
await app._set_approval_mode(ApprovalMode.AUTO)
save_recent.assert_called_once_with(ApprovalMode.AUTO.value)
async def test_persist_startup_mode_skips_duplicate_write(self) -> None:
"""One confirmation must not write, or fail, twice.
The confirmation modal has two entry points that both activate Auto:
the awaiting caller and the task its result schedules.
"""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
with patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent:
await app._persist_startup_approval_mode(ApprovalMode.AUTO)
await app._persist_startup_approval_mode(ApprovalMode.AUTO)
save_recent.assert_called_once_with(ApprovalMode.AUTO.value)
async def test_persist_startup_mode_retries_after_failure(self) -> None:
"""A failed write is not remembered as done."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
with (
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=False,
) as save_recent,
patch.object(app, "notify"),
):
await app._persist_startup_approval_mode(ApprovalMode.MANUAL)
await app._persist_startup_approval_mode(ApprovalMode.MANUAL)
assert save_recent.call_count == 2
async def test_notify_auto_mode_not_restored_surfaces_notice_gate(self) -> None:
"""A notice-blocked Auto restore is explained after mount."""
app = DeepAgentsApp()
app._auto_downgraded_by_sandbox = False
with (
patch(
"deepagents_code.model_config.consume_recent_auto_not_restored_notice",
return_value="Auto was not restored.",
),
patch.object(app, "notify") as notify,
):
app._notify_auto_mode_not_restored()
notify.assert_called_once_with(
"Auto was not restored.",
severity="warning",
timeout=8,
markup=False,
)
async def test_notify_auto_mode_not_restored_prefers_sandbox_reason(self) -> None:
"""A sandbox blocks Auto all session, so it outranks a stale notice."""
app = DeepAgentsApp()
app._auto_downgraded_by_sandbox = True
with (
patch(
"deepagents_code.model_config.consume_recent_auto_not_restored_notice",
return_value="Auto was not restored.",
),
patch.object(app, "notify") as notify,
):
app._notify_auto_mode_not_restored()
assert "sandbox" in str(notify.call_args.args[0])
# One-shot: a second refresh must not repeat the toast.
assert app._auto_downgraded_by_sandbox is False
async def test_notify_auto_mode_not_restored_stays_quiet_when_restored(
self,
) -> None:
"""Nothing to explain when the mode survived startup."""
app = DeepAgentsApp()
app._auto_downgraded_by_sandbox = False
with (
patch(
"deepagents_code.model_config.consume_recent_auto_not_restored_notice",
return_value=None,
),
patch.object(app, "notify") as notify,
):
app._notify_auto_mode_not_restored()
notify.assert_not_called()
@pytest.mark.parametrize("mode_value", ["auto", "manual"])
async def test_set_approval_mode_skips_persist_when_live_write_fails(
self, mode_value: str
) -> None:
"""A rejected live write must not arm the mode for the next launch."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
target = ApprovalMode(mode_value)
async with app.run_test() as pilot:
await pilot.pause()
app._agent = object()
with (
patch.object(
app,
"_write_live_approval_mode",
new=AsyncMock(return_value=False),
),
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent,
patch.object(app, "_warn_live_approval_mode_unavailable"),
patch.object(app, "_force_interrupt_active_work"),
):
assert await app._set_approval_mode(target) is False
save_recent.assert_not_called()
async def test_switch_to_manual_from_fallback_persists_manual(self) -> None:
"""The live fallback's Manual switch updates the next bare launch."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
with (
patch.object(
app,
"_write_live_approval_mode",
new=AsyncMock(return_value=True),
),
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent,
):
assert await app._switch_to_manual_from_fallback() is True
save_recent.assert_called_once_with(ApprovalMode.MANUAL.value)
async def test_prompt_yolo_push_failure_surfaces_and_allows_retry(self) -> None:
"""A push_screen failure warns the user and never latches the guard."""
app = DeepAgentsApp()
@@ -30411,6 +30658,10 @@ class TestLiveApprovalModeWrites:
"deepagents_code.approval_mode.save_auto_mode_notice",
return_value=True,
) as save_notice,
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent_mode,
):
# The method awaits the modal result, so start it as a task
# and press Enter to confirm.
@@ -30423,10 +30674,12 @@ class TestLiveApprovalModeWrites:
)
assert app._approval_mode is not ApprovalMode.AUTO
save_notice.assert_not_called()
save_recent_mode.assert_not_called()
await pilot.press("enter")
result = await task
assert result is True
save_notice.assert_called_once_with()
save_recent_mode.assert_called_once_with(ApprovalMode.AUTO.value)
assert app._approval_mode is ApprovalMode.AUTO
async def test_on_auto_approve_enabled_skips_modal_when_notice_shown(self) -> None:
@@ -30563,6 +30816,10 @@ class TestLiveApprovalModeWrites:
patch(
"deepagents_code.approval_mode.save_auto_mode_notice",
) as save_notice,
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent_mode,
):
task = asyncio.create_task(app._on_auto_approve_enabled())
await pilot.pause()
@@ -30571,6 +30828,8 @@ class TestLiveApprovalModeWrites:
result = await task
assert result is False
save_notice.assert_not_called()
# A declined confirmation must not persist Auto for next launch.
save_recent_mode.assert_not_called()
assert app._approval_mode is not ApprovalMode.AUTO
write_live.assert_not_awaited()
@@ -30703,6 +30962,35 @@ class TestLiveApprovalModeWrites:
)
mount.assert_awaited_once()
async def test_server_manual_fallback_clears_recent_auto(self) -> None:
"""The server's safety decision must survive the session boundary."""
from deepagents_code.approval_mode import ApprovalMode
app = DeepAgentsApp()
app._approval_mode = ApprovalMode.AUTO
event = {
"event": "fallback",
"mode": "manual",
"reason": "Auto control state was unavailable; using Manual approval.",
}
with (
patch.object(
app,
"_write_live_approval_mode",
new=AsyncMock(return_value=True),
),
patch(
"deepagents_code.model_config.save_recent_startup_mode",
return_value=True,
) as save_recent,
patch.object(app, "_mount_message", new=AsyncMock()),
patch.object(app, "notify"),
):
await app._on_auto_mode_event(event)
save_recent.assert_called_once_with(ApprovalMode.MANUAL.value)
@pytest.mark.parametrize("kind", ["denial", "unavailable"])
async def test_tool_outcome_auto_event_is_not_mounted(self, kind: str) -> None:
app = DeepAgentsApp()
@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse
import logging
import os
import tomllib
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
@@ -336,6 +337,7 @@ def test_toml_only_bool_display_options_declare_no_env_var() -> None:
("agents.default", ("agents", "default")),
("agents.recent", ("agents", "recent")),
("agents.async_subagents", ("async_subagents",)),
("startup.recent", ("startup", "recent")),
("sandboxes.default", ("sandboxes", "default")),
("sandboxes.providers", ("sandboxes", "providers")),
],
@@ -3016,6 +3018,132 @@ def test_resolve_startup_mode_from_toml(caplog) -> None:
assert resolve_scalar(opt, toml_data={}) == (DEFAULT_STARTUP_MODE, "default")
@pytest.mark.parametrize(
("toml_data", "managed_toml_data", "expected"),
[
# Only `recent` set: a bare launch runs Auto, so the display must agree.
({"startup": {"recent": "auto"}}, {}, ("auto", "config.toml")),
# An explicit mode outranks `recent`.
(
{"startup": {"mode": "manual", "recent": "auto"}},
{},
("manual", "config.toml"),
),
# Nothing configured: the typed default stands.
({}, {}, (DEFAULT_STARTUP_MODE, "default")),
# An unsafe `recent` fails closed rather than crediting config.toml.
({"startup": {"recent": "yolo"}}, {}, (DEFAULT_STARTUP_MODE, "default")),
# A non-scalar `recent` cannot reach the membership test.
({"startup": {"recent": ["auto"]}}, {}, (DEFAULT_STARTUP_MODE, "default")),
# An invalid explicit mode is fail-closed: `load_startup_mode` returns
# Manual without consulting `recent`, so the display must too.
(
{"startup": {"mode": "hands-off", "recent": "auto"}},
{},
(DEFAULT_STARTUP_MODE, "default"),
),
# Managed `recent` participates in the same precedence as runtime loading.
({}, {"startup": {"recent": "auto"}}, ("auto", "managed config")),
(
{"startup": {"recent": "auto"}},
{"startup": {"recent": "manual"}},
("manual", "managed config"),
),
],
ids=[
"recent-only",
"explicit-outranks-recent",
"nothing-configured",
"unsafe-recent",
"non-scalar-recent",
"invalid-explicit-mode",
"managed-recent",
"managed-recent-outranks-user",
],
)
def test_resolve_startup_mode_with_source_reports_recent_fallback(
monkeypatch: pytest.MonkeyPatch,
toml_data: dict,
managed_toml_data: dict,
expected: tuple[str, str],
) -> None:
"""`startup.mode` display reflects the `[startup].recent` restore."""
from deepagents_code import approval_mode
from deepagents_code.config_manifest import resolve_startup_mode_with_source
monkeypatch.setattr(approval_mode, "has_auto_mode_notice", lambda: True)
assert (
resolve_startup_mode_with_source(
toml_data=toml_data,
managed_toml_data=managed_toml_data,
)
== expected
)
@pytest.mark.parametrize(
"config_text",
[
"",
"[startup]\n",
"[startup]\nrecent = 'auto'\n",
"[startup]\nrecent = 'manual'\n",
"[startup]\nrecent = 'yolo'\n",
"[startup]\nrecent = ['auto']\n",
# Whitespace and blanks: the display must not accept a value the
# loader's exact match rejects.
"[startup]\nrecent = ' auto '\n",
"[startup]\nrecent = 'AUTO'\n",
"[startup]\nrecent = ''\n",
"[startup]\nmode = 'auto'\n",
"[startup]\nmode = 'yolo'\nrecent = 'manual'\n",
"[startup]\nmode = 'hands-off'\nrecent = 'auto'\n",
"[startup]\nmode = ['auto']\nrecent = 'auto'\n",
"startup = 'nonsense'\n",
],
)
def test_resolve_startup_mode_with_source_agrees_with_loader(
tmp_path, monkeypatch: pytest.MonkeyPatch, config_text: str
) -> None:
"""The display resolver and the runtime loader must never disagree.
Both consult `[startup].mode`, `[startup].recent`, and the Auto notice, in
two separate implementations held together only by a docstring. Per-case
assertions cannot catch drift between them; this can.
"""
from deepagents_code import approval_mode
from deepagents_code.config_manifest import resolve_startup_mode_with_source
from deepagents_code.model_config import load_startup_mode
monkeypatch.setattr(approval_mode, "has_auto_mode_notice", lambda: True)
config = tmp_path / "config.toml"
config.write_text(config_text)
with config.open("rb") as file:
toml_data = tomllib.load(file)
displayed, _ = resolve_startup_mode_with_source(
toml_data=toml_data,
managed_toml_data={},
)
assert displayed == load_startup_mode(config)
def test_resolve_startup_mode_with_source_gates_recent_auto_on_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The displayed fallback matches a launch blocked by a stale notice."""
from deepagents_code import approval_mode
from deepagents_code.config_manifest import resolve_startup_mode_with_source
monkeypatch.setattr(approval_mode, "has_auto_mode_notice", lambda: False)
assert resolve_startup_mode_with_source(
toml_data={},
managed_toml_data={"startup": {"recent": "auto"}},
) == (DEFAULT_STARTUP_MODE, "default")
def test_resolve_toml_float_success_non_bool() -> None:
"""A FLOAT option reads a real number from TOML and coerces an int to float."""
opt = get_option("interpreter.timeout_seconds")
@@ -3478,6 +3606,34 @@ def test_config_resolve_discards_out_of_range_toml_auto_classifier_timeout(
assert value == AUTO_CLASSIFIER_TIMEOUT_SECONDS_DEFAULT
@pytest.mark.parametrize(
("toml_data", "expected"),
[
# A file with no `mode` key still reports the mode the launch will use.
({"startup": {"recent": "auto"}}, (True, "config.toml", "auto")),
({}, (False, "default", DEFAULT_STARTUP_MODE)),
(
{"startup": {"mode": "hands-off", "recent": "auto"}},
(False, "default", DEFAULT_STARTUP_MODE),
),
],
ids=["recent-only", "nothing-configured", "invalid-explicit-mode"],
)
def test_config_resolve_reports_effective_startup_mode(
monkeypatch: pytest.MonkeyPatch,
toml_data: dict,
expected: tuple[bool, str, str],
) -> None:
"""`config get startup.mode` must route through the recent-aware resolver."""
from deepagents_code import approval_mode
monkeypatch.setattr(approval_mode, "has_auto_mode_notice", lambda: True)
option = get_option("startup.mode")
assert option is not None
assert _resolve(option, toml_data) == expected
def test_config_resolve_reports_valid_env_auto_classifier_timeout(
monkeypatch,
) -> None:
+172 -5
View File
@@ -64,6 +64,7 @@ from deepagents_code.model_config import (
save_effort_for_model,
save_recent_agent,
save_recent_model,
save_recent_startup_mode,
save_thread_columns,
suppress_warning,
suppress_warning_reason,
@@ -8572,7 +8573,11 @@ class TestAddEnabledProjectMcpServers:
class TestLoadStartupMode:
"""Tests for `load_startup_mode` reading `[startup].mode` from config.toml."""
"""Tests for the `[startup]` approval-mode read and its recent-mode write.
Covers `load_startup_mode` over both `mode` and `recent`, and
`save_recent_startup_mode`.
"""
def test_missing_file_returns_default(self, tmp_path: Path) -> None:
"""A nonexistent config file falls back to the default mode."""
@@ -8585,6 +8590,165 @@ class TestLoadStartupMode:
config.write_text("[threads]\nsort_order = 'created_at'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
@pytest.mark.parametrize("mode", [STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO])
def test_recent_mode_is_restored(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
mode: str,
) -> None:
"""A stored recent mode is restored on a bare launch."""
from deepagents_code.approval_mode import save_auto_mode_notice
monkeypatch.setattr(model_config, "DEFAULT_STATE_DIR", tmp_path / ".state")
if mode == STARTUP_MODE_AUTO:
assert save_auto_mode_notice()
config = tmp_path / "config.toml"
config.write_text(f"[startup]\nrecent = '{mode}'\n")
assert load_startup_mode(config) == mode
@pytest.mark.parametrize("notice_state", ["missing", "stale"])
def test_recent_auto_requires_current_notice(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
notice_state: str,
) -> None:
"""Implicit Auto restoration fails closed until its notice is current."""
state_dir = tmp_path / ".state"
monkeypatch.setattr(model_config, "DEFAULT_STATE_DIR", state_dir)
if notice_state == "stale":
state_dir.mkdir()
(state_dir / "approval.json").write_text(
'{"auto_notice_shown":true,"auto_notice_version":"old"}\n'
)
config = tmp_path / "config.toml"
config.write_text("[startup]\nrecent = 'auto'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_recent_auto_blocked_by_notice_warns_and_queues_notice(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A declined Auto restore is diagnosable, not silent.
This is the one exit that discards a *valid* preference, so without a
log line and a queued toast an `AUTO_NOTICE_VERSION` bump looks exactly
like the persistence feature being broken.
"""
from deepagents_code.model_config import (
consume_recent_auto_not_restored_notice,
)
monkeypatch.setattr(model_config, "DEFAULT_STATE_DIR", tmp_path / ".state")
config = tmp_path / "config.toml"
config.write_text("[startup]\nrecent = 'auto'\n")
# The notice is module state; clear anything an earlier test queued.
consume_recent_auto_not_restored_notice()
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
assert any(
"Not restoring [startup].recent" in record.getMessage()
for record in caplog.records
)
notice = consume_recent_auto_not_restored_notice()
assert notice is not None
assert "Shift+Tab" in notice
# One-shot: a second consumer must not re-toast the same launch.
assert consume_recent_auto_not_restored_notice() is None
def test_restored_recent_auto_queues_no_notice(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A successful restore leaves nothing to explain."""
from deepagents_code.approval_mode import save_auto_mode_notice
from deepagents_code.model_config import (
consume_recent_auto_not_restored_notice,
)
monkeypatch.setattr(model_config, "DEFAULT_STATE_DIR", tmp_path / ".state")
assert save_auto_mode_notice()
config = tmp_path / "config.toml"
config.write_text("[startup]\nrecent = 'auto'\n")
consume_recent_auto_not_restored_notice()
assert load_startup_mode(config) == STARTUP_MODE_AUTO
assert consume_recent_auto_not_restored_notice() is None
def test_explicit_mode_outranks_recent(self, tmp_path: Path) -> None:
"""An explicit mode is an intentional default and wins."""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = 'manual'\nrecent = 'auto'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
@pytest.mark.parametrize("recent", ["yolo", "hands-off"])
def test_unsafe_or_invalid_recent_mode_fails_closed(
self,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
recent: str,
) -> None:
"""Only Manual and Auto restore; anything else warns and fails closed."""
config = tmp_path / "config.toml"
config.write_text(f"[startup]\nrecent = '{recent}'\n")
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
assert any(
"[startup].recent" in record.getMessage() for record in caplog.records
)
@pytest.mark.parametrize("literal", ["['auto']", "{ a = 'auto' }", "3", "true"])
def test_non_scalar_recent_returns_default(
self, tmp_path: Path, literal: str
) -> None:
"""A non-string `recent` must not reach the frozenset membership test.
`recent in RECENT_STARTUP_MODES` raises `TypeError: unhashable type` on
a list or table, which `except (OSError, TOMLDecodeError)` does not
catch, so dropping the isinstance guard aborts launch. This mirrors
`test_non_scalar_mode_returns_default` for the newer key.
"""
config = tmp_path / "config.toml"
config.write_text(f"[startup]\nrecent = {literal}\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
@pytest.mark.parametrize("mode", [STARTUP_MODE_MANUAL, STARTUP_MODE_AUTO])
def test_save_recent_startup_mode_round_trip(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
mode: str,
) -> None:
"""A saved mode reloads, and neighbouring config keys survive the write."""
from deepagents_code.approval_mode import save_auto_mode_notice
monkeypatch.setattr(model_config, "DEFAULT_STATE_DIR", tmp_path / ".state")
if mode == STARTUP_MODE_AUTO:
assert save_auto_mode_notice()
config = tmp_path / "config.toml"
config.write_text("[models]\ndefault = 'openai:gpt-5.5'\n")
assert save_recent_startup_mode(mode, config) is True
with config.open("rb") as file:
data = tomllib.load(file)
assert data["startup"]["recent"] == mode
assert data["models"]["default"] == "openai:gpt-5.5"
assert load_startup_mode(config) == mode
def test_save_recent_startup_mode_rejects_yolo(self, tmp_path: Path) -> None:
"""YOLO must never be restored implicitly, so it cannot be stored.
The guard is the write-side half of `RECENT_STARTUP_MODES`; the read
side is covered above.
"""
with pytest.raises(ValueError, match="Invalid recent startup mode"):
save_recent_startup_mode(STARTUP_MODE_YOLO, tmp_path / "config.toml")
def test_explicit_manual(self, tmp_path: Path) -> None:
"""`mode = 'manual'` is returned verbatim."""
config = tmp_path / "config.toml"
@@ -8607,15 +8771,18 @@ class TestLoadStartupMode:
config.write_text("[startup]\nmode = 'dangerously-auto'\n")
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
def test_invalid_value_returns_default(
def test_invalid_explicit_mode_ignores_recent(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An unrecognized mode logs a warning and falls back to the default."""
"""An invalid explicit mode fails closed instead of restoring recent Auto."""
config = tmp_path / "config.toml"
config.write_text("[startup]\nmode = 'hands-off'\n")
config.write_text("[startup]\nmode = 'hands-off'\nrecent = 'auto'\n")
with caplog.at_level(logging.WARNING, logger="deepagents_code.model_config"):
assert load_startup_mode(config) == STARTUP_MODE_MANUAL
assert any("startup" in r.getMessage().lower() for r in caplog.records)
# Assert on the `mode` warning specifically: matching bare "startup"
# would also pass on the `recent` warning, which must not fire here.
assert any("[startup].mode" in r.getMessage() for r in caplog.records)
assert not any("[startup].recent" in r.getMessage() for r in caplog.records)
def test_malformed_startup_table_returns_default(self, tmp_path: Path) -> None:
"""A non-table `startup` value does not crash and falls back."""