mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user