diff --git a/CHANGELOG.md b/CHANGELOG.md index cf32c222..4eb6cbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Toggle auto-update from the CLI.** `/update auto on|off` turns silent startup auto-updates on or off (and `/update auto` reports the effective state); the same toggle now appears in the interactive `/settings` panel, and `pythinker info` reports the auto-update status. All three show the *effective* state — an external override (`PYTHINKER_CLI_NO_AUTO_UPDATE` or a source checkout) is surfaced as the reason and renders the `/settings` row read-only, so the toggle is never a silent no-op. + ## 0.43.0 (2026-06-13) - **Silent startup auto-updates (default on).** Managed and native installs now check for and apply updates in the background at startup, surfacing a restart-to-apply notice instead of a blocking prompt. Opt out with `auto_update = false` in config or `PYTHINKER_AUTO_UPDATE=0` in the environment. The Windows update path that replaces the running binary no longer escapes as an uncaught `SystemExit` and crashes the shell. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index ea4aac03..21823566 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -176,6 +176,13 @@ Check for and optionally install the latest Pythinker Code version. Alias: `/upgrade` +Use `/update auto on` or `/update auto off` to turn silent startup auto-updates +on or off (persisted to the `auto_update` config field); `/update auto` with no +argument reports the effective state. When an external override is active — the +`PYTHINKER_CLI_NO_AUTO_UPDATE` kill-switch or a source checkout — it is surfaced +as the reason and outranks the setting. The same toggle is available in +`/settings`, and `pythinker info` reports the auto-update status. + ### `/reload` Reload the configuration file without exiting Pythinker Code. diff --git a/src/pythinker_code/cli/info.py b/src/pythinker_code/cli/info.py index 554b0615..58317af2 100644 --- a/src/pythinker_code/cli/info.py +++ b/src/pythinker_code/cli/info.py @@ -13,6 +13,40 @@ class InfoData(TypedDict): agent_spec_versions: list[str] wire_protocol_version: str python_version: str + auto_update: bool | None + auto_update_config: bool | None + auto_update_override: str | None + + +def _auto_update_info() -> tuple[bool | None, bool | None, str | None]: + """Return ``(effective_enabled, config_value, override_reason)``. + + Every element is ``None`` when the status cannot be resolved. The whole + block is guarded so an unreadable config or any other failure never turns + the always-available ``info`` diagnostic into a crash. + """ + try: + from pythinker_code.config import Config, get_config_file, load_config + from pythinker_code.update_policy import ( + auto_update_enabled, + auto_update_override_reason, + ) + + override = auto_update_override_reason() + # `load_config()` seeds a default config file when none exists; `info` + # must stay read-only, so fall back to in-memory defaults when the user + # has no config file yet rather than creating one as a side effect. + config_exists = get_config_file(create=False).expanduser().exists() + config = load_config() if config_exists else Config() + return auto_update_enabled(config), config.auto_update, override + except (OSError, ValueError, ImportError) as exc: + # Read-only diagnostic: never abort `info`, but log the degraded path + # instead of silently masking a real config/policy failure. ConfigError + # and pydantic validation errors are ValueError subclasses. + from pythinker_code.utils.logging import logger + + logger.debug("Could not resolve auto-update status for `info`: {}", exc) + return None, None, None def _collect_info() -> InfoData: @@ -20,15 +54,32 @@ def _collect_info() -> InfoData: from pythinker_code.constant import ORGANIZATION, get_version from pythinker_code.wire.protocol import WIRE_PROTOCOL_VERSION + auto_update_effective, auto_update_config, auto_update_override = _auto_update_info() + return { "pythinker_code_version": get_version(), "organization": ORGANIZATION, "agent_spec_versions": [str(version) for version in SUPPORTED_AGENT_SPEC_VERSIONS], "wire_protocol_version": WIRE_PROTOCOL_VERSION, "python_version": platform.python_version(), + "auto_update": auto_update_effective, + "auto_update_config": auto_update_config, + "auto_update_override": auto_update_override, } +def _auto_update_line(info: InfoData) -> str: + effective = info["auto_update"] + if effective is None: + return "auto-update: unknown" + state = "enabled" if effective else "disabled" + detail = f"config auto_update={'true' if info['auto_update_config'] else 'false'}" + override = info["auto_update_override"] + if override: + detail += f"; {override}" + return f"auto-update: {state} ({detail})" + + def _emit_info(json_output: bool) -> None: info = _collect_info() if json_output: @@ -43,6 +94,7 @@ def _emit_info(json_output: bool) -> None: f"agent spec versions: {agent_versions_text}", f"wire protocol: {info['wire_protocol_version']}", f"python version: {info['python_version']}", + _auto_update_line(info), ] for line in lines: typer.echo(line) diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index ee3ff005..3603415c 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -1206,9 +1206,13 @@ def _apply_agent_execution_profile(self) -> None: self.ask_user_question_policy = "ask_except_auto" -def get_config_file() -> Path: - """Get the configuration file path.""" - return get_share_dir() / "config.toml" +def get_config_file(*, create: bool = True) -> Path: + """Get the configuration file path. + + Pass ``create=False`` to resolve the path without creating the share + directory, for read-only callers that must avoid filesystem side effects. + """ + return get_share_dir(create=create) / "config.toml" def get_default_config() -> Config: diff --git a/src/pythinker_code/share.py b/src/pythinker_code/share.py index 9f76bdad..c9c92ee7 100644 --- a/src/pythinker_code/share.py +++ b/src/pythinker_code/share.py @@ -5,12 +5,20 @@ from pathlib import Path -def get_share_dir() -> Path: - """Get the share directory path.""" +def get_share_dir(*, create: bool = True) -> Path: + """Get the share directory path. + + Creates and hardens the directory by default. Pass ``create=False`` to + resolve the path without any filesystem side effect — needed by read-only + callers (e.g. ``pythinker info``) that must not materialize ``~/.pythinker`` + just to look something up. + """ if share_dir := os.getenv("PYTHINKER_SHARE_DIR"): share_dir = Path(share_dir) else: share_dir = Path.home() / ".pythinker" + if not create: + return share_dir share_dir.mkdir(parents=True, exist_ok=True) # Harden unconditionally: an older version may have left the dir at 0755, so # only tightening on first-create would leave that secret-bearing dir traversable. diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 79e91b8e..59e597a2 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -73,7 +73,6 @@ _detect_upgrade_command, # pyright: ignore[reportPrivateUsage] _mark_auto_update_check_attempt, # pyright: ignore[reportPrivateUsage] _should_auto_check_for_updates, # pyright: ignore[reportPrivateUsage] - auto_update_enabled, consume_whats_new, format_managed_channel_notice, pending_update_notice, @@ -92,6 +91,7 @@ from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled, motion_disabled from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens from pythinker_code.ui.theme import tui_rich_style +from pythinker_code.update_policy import auto_update_enabled from pythinker_code.utils.aioqueue import QueueShutDown from pythinker_code.utils.envvar import get_env_bool from pythinker_code.utils.logging import logger diff --git a/src/pythinker_code/ui/shell/selectors/settings.py b/src/pythinker_code/ui/shell/selectors/settings.py index c5fd0dfe..69ea8691 100644 --- a/src/pythinker_code/ui/shell/selectors/settings.py +++ b/src/pythinker_code/ui/shell/selectors/settings.py @@ -48,6 +48,9 @@ def _float_values(current: float, presets: list[float]) -> list[str]: def _build_settings_config(config: Config) -> SettingsListConfig: """Build the settings-list config from a Pythinker ``Config`` object.""" + from pythinker_code.update_policy import auto_update_override_reason + + _auto_update_override = auto_update_override_reason() model_values = [_NONE_MODEL_VALUE, *sorted(config.models)] current_model = config.default_model or _NONE_MODEL_VALUE current_model_cfg = config.models.get(config.default_model) if config.default_model else None @@ -160,6 +163,21 @@ def _build_settings_config(config: Config) -> SettingsListConfig: current_value=_bool(config.telemetry), values=_BOOL_VALUES, ), + SettingItem( + id="auto_update", + label="Auto-update", + description=( + "Silently install new releases in the background at startup " + "(applied on next restart)." + if _auto_update_override is None + else f"Auto-update is {_auto_update_override}; that override outranks this setting." + ), + # Show the *effective* state, and make the row read-only when an + # override (env kill-switch / source checkout) forces it off, so the + # panel never offers a no-op toggle. + current_value=(_bool(config.auto_update) if _auto_update_override is None else "false"), + values=_BOOL_VALUES if _auto_update_override is None else None, + ), SettingItem( id="merge_all_available_skills", label="Merge all skills", @@ -343,6 +361,13 @@ def mark(setting_id: str) -> None: if config.telemetry != new: config.telemetry = new mark(setting_id) + case "auto_update": + # Only reached for the live (non-override) row; a read-only row + # never submits a change. + new = value == "true" + if config.auto_update != new: + config.auto_update = new + mark(setting_id) case "merge_all_available_skills": new = value == "true" if config.merge_all_available_skills != new: diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 7843a3ba..87e3b32a 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -2084,11 +2084,15 @@ async def show_memory(app: Shell, args: str): @registry.command(name="update", aliases=["upgrade"]) async def update_command(app: Shell, args: str): - """Check for and optionally install the latest Pythinker version.""" - _ = args, app + """Check for updates, or `auto [on|off]` to toggle silent startup auto-updates.""" from pythinker_code.ui.shell.update import UpdateResult, run_update_prompt from pythinker_code.ui.shell.update_orchestrator import run_update_job + parts = args.strip().split() + if parts and parts[0].lower() in {"auto", "auto-update", "autoupdate"}: + await _auto_update_toggle(app, parts[1:]) + return + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: return await run_update_job( print_output=print_output, check_only=check_only, source="slash" @@ -2099,6 +2103,66 @@ async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: console.print("Updated — restart Pythinker to use the new version.") +async def _auto_update_toggle(app: Shell, args: list[str]) -> None: + """Show or set the silent startup auto-update preference (`/update auto [on|off]`).""" + from pythinker_code.telemetry import track + from pythinker_code.ui.theme import get_tui_tokens as _get_tok + from pythinker_code.update_policy import auto_update_enabled, auto_update_override_reason + + _t = _get_tok() + soul = ensure_pythinker_soul(app) + if soul is None: + return + config = soul.runtime.config + override = auto_update_override_reason() + + def _print_override() -> None: + if override is not None: + console.print(f"[{_t.muted}]Note: {override}; this overrides the setting.[/]") + + # No value → report effective state. + if not args: + effective = "on" if auto_update_enabled(config) else "off" + stored = "on" if config.auto_update else "off" + console.print(f"[{_t.info}]Auto-update: {effective}[/] (config auto_update={stored})") + _print_override() + return + + value = args[0].lower() + if len(args) > 1 or value not in {"on", "off"}: + console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]") + return + enabled = value == "on" + + if config.auto_update == enabled: + console.print(f"[{_t.warning}]Auto-update already {value}.[/]") + _print_override() + return + + config_file = config.source_file + if config_file is None: + console.print( + f"[{_t.warning}]Toggling auto-update requires a config file; " + f"restart without --config (or use --config-file) to persist settings.[/]" + ) + return + try: + config_for_save = load_config(config_file) + config_for_save.auto_update = enabled + save_config(config_for_save, config_file) + except (ConfigError, OSError) as exc: + console.print(f"[{_t.error}]Failed to save config: {_rich_escape(exc)}[/]") + return + # auto_update is only consulted at startup, so nothing live depends on it: + # mirror the saved value into the running config instead of forcing a reload + # (a reload would re-trigger the startup auto-update task we just toggled). + config.auto_update = enabled + + track("settings_update", changed="auto_update", count=1) + console.print(f"[{_t.success}]Auto-update {value}. Takes effect at next startup.[/]") + _print_override() + + @registry.command async def mcp(app: Shell, args: str): """Show MCP servers and tools""" diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 26031991..7c880b95 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -15,10 +15,7 @@ from enum import Enum, auto from pathlib import Path from shutil import which -from typing import TYPE_CHECKING, cast - -if TYPE_CHECKING: - from pythinker_code.config import Config +from typing import cast import aiohttp import typer @@ -35,6 +32,18 @@ from pythinker_code.share import get_share_dir from pythinker_code.ui.shell.console import console from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens + +# Pure policy lives in a shell-free module (`update_policy`) so lightweight +# callers (e.g. `pythinker info`) can resolve auto-update status without +# importing this stack. These two primitives are used internally below; the +# public `auto_update_enabled` / `auto_update_override_reason` are imported +# directly from `update_policy` by their consumers. +from pythinker_code.update_policy import ( + auto_update_disabled as _auto_update_disabled, +) +from pythinker_code.update_policy import ( + is_running_from_source_checkout as _is_running_from_source_checkout, +) from pythinker_code.utils.aiohttp import new_client_session from pythinker_code.utils.logging import logger from pythinker_code.utils.subprocess_env import get_clean_env @@ -238,12 +247,6 @@ async def _get_latest_version(session: aiohttp.ClientSession) -> str | None: return None -def _auto_update_disabled() -> bool: - from pythinker_code.utils.envvar import get_env_bool - - return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE") - - def format_managed_channel_notice( current: str, latest: str, @@ -262,53 +265,6 @@ def format_managed_channel_notice( ) -def _is_running_from_source_checkout() -> bool: - """Return true when invoked from this repository via ``uv run``/editable source. - - In that mode PyPI can legitimately have a newer released version than the - checkout's local ``pyproject.toml`` version. Showing the normal upgrade - banner is noisy and suggests replacing the developer checkout. - """ - try: - import pythinker_code - - package_path = Path(pythinker_code.__file__).resolve() - except Exception: - return False - - for parent in package_path.parents: - pyproject = parent / "pyproject.toml" - git_dir = parent / ".git" - if pyproject.exists() and git_dir.exists(): - try: - text = pyproject.read_text(encoding="utf-8") - except OSError: - return False - return 'name = "pythinker-code"' in text or "name = 'pythinker-code'" in text - return False - - -def auto_update_enabled(config: Config) -> bool: - """Whether startup may silently install a newer release. - - Precedence (highest first): - 1. ``PYTHINKER_CLI_NO_AUTO_UPDATE`` (the hard kill-switch) → disabled. - 2. ``config.auto_update is False`` → disabled. - 3. Source checkout → disabled. - 4. Otherwise → enabled. - - Managed channels (Docker/Nix/Scoop/WinGet) are *not* special-cased here: - they may be "enabled" but ``_do_update`` returns ``UPDATE_AVAILABLE`` and - emits a channel hint instead of swapping the binary, so they never get a - silent install regardless of this result. - """ - if _auto_update_disabled(): - return False - if config.auto_update is False: - return False - return not _is_running_from_source_checkout() - - def _should_auto_check_for_updates(now: float | None = None) -> bool: if _auto_update_disabled() or _is_running_from_source_checkout(): return False diff --git a/src/pythinker_code/update_policy.py b/src/pythinker_code/update_policy.py new file mode 100644 index 00000000..22264dc0 --- /dev/null +++ b/src/pythinker_code/update_policy.py @@ -0,0 +1,89 @@ +"""Pure auto-update policy resolution. + +Kept dependency-light on purpose: it imports nothing from the interactive shell +stack (no ``aiohttp``, no console, no share-directory initialization). That lets +lightweight entry points such as ``pythinker info`` report auto-update status +without importing ``pythinker_code.ui.shell.update`` — which would pull in heavy +dependencies and create the share directory as an import side effect. + +``pythinker_code.ui.shell.update`` re-exports these so existing call sites and +tests keep working. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pythinker_code.config import Config + + +def auto_update_disabled() -> bool: + """True when the hard kill-switch env var disables auto-update entirely.""" + from pythinker_code.utils.envvar import get_env_bool + + return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE") + + +def is_running_from_source_checkout() -> bool: + """Return true when invoked from this repository via ``uv run``/editable source. + + In that mode PyPI can legitimately have a newer released version than the + checkout's local ``pyproject.toml`` version. Showing the normal upgrade + banner is noisy and suggests replacing the developer checkout. + """ + try: + import pythinker_code + + package_path = Path(pythinker_code.__file__).resolve() + except (ImportError, AttributeError, OSError): + return False + + for parent in package_path.parents: + pyproject = parent / "pyproject.toml" + git_dir = parent / ".git" + if pyproject.exists() and git_dir.exists(): + try: + text = pyproject.read_text(encoding="utf-8") + except OSError: + return False + return 'name = "pythinker-code"' in text or "name = 'pythinker-code'" in text + return False + + +def auto_update_enabled(config: Config) -> bool: + """Whether startup may silently install a newer release. + + Precedence (highest first): + 1. ``PYTHINKER_CLI_NO_AUTO_UPDATE`` (the hard kill-switch) → disabled. + 2. ``config.auto_update is False`` → disabled. + 3. Source checkout → disabled. + 4. Otherwise → enabled. + + Managed channels (Docker/Nix/Scoop/WinGet) are *not* special-cased here: + they may be "enabled" but ``_do_update`` returns ``UPDATE_AVAILABLE`` and + emits a channel hint instead of swapping the binary, so they never get a + silent install regardless of this result. + """ + if auto_update_disabled(): + return False + if config.auto_update is False: + return False + return not is_running_from_source_checkout() + + +def auto_update_override_reason() -> str | None: + """Reason auto-update is force-disabled regardless of ``config.auto_update``. + + These overrides sit *above* the config field in :func:`auto_update_enabled`'s + precedence, so toggling the setting cannot change the effective behavior while + one is in effect. Returns ``None`` when the config field is the deciding + factor (the normal case) — callers use that to decide whether a settings + toggle is live or merely cosmetic. + """ + if auto_update_disabled(): + return "disabled by PYTHINKER_CLI_NO_AUTO_UPDATE" + if is_running_from_source_checkout(): + return "disabled for source checkouts" + return None diff --git a/tests/cli/test_info.py b/tests/cli/test_info.py new file mode 100644 index 00000000..c0c0ab2e --- /dev/null +++ b/tests/cli/test_info.py @@ -0,0 +1,67 @@ +"""Tests for `pythinker info` auto-update reporting.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest + +from pythinker_code.cli.info import InfoData, _auto_update_line, _collect_info + + +def _info(**overrides: object) -> InfoData: + data: dict[str, object] = { + "pythinker_code_version": "0.0.0", + "organization": "x", + "agent_spec_versions": ["1"], + "wire_protocol_version": "1", + "python_version": "3.14.0", + "auto_update": None, + "auto_update_config": None, + "auto_update_override": None, + } + data.update(overrides) + return cast(InfoData, data) + + +def test_auto_update_line_enabled() -> None: + line = _auto_update_line(_info(auto_update=True, auto_update_config=True)) + assert line == "auto-update: enabled (config auto_update=true)" + + +def test_auto_update_line_disabled_with_override() -> None: + line = _auto_update_line( + _info( + auto_update=False, + auto_update_config=True, + auto_update_override="disabled for source checkouts", + ) + ) + assert line == ( + "auto-update: disabled (config auto_update=true; disabled for source checkouts)" + ) + + +def test_auto_update_line_unknown_when_unresolved() -> None: + assert _auto_update_line(_info()) == "auto-update: unknown" + + +def test_collect_info_includes_auto_update_keys() -> None: + info = _collect_info() + assert "auto_update" in info + assert "auto_update_config" in info + assert "auto_update_override" in info + + +def test_collect_info_does_not_create_share_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # `info` is read-only: resolving auto-update status must not materialize the + # share directory (regression — `get_config_file()` used to create it). + share = tmp_path / ".pythinker" + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(share)) + + _collect_info() + + assert not share.exists() diff --git a/tests/ui_and_conv/test_settings_selector.py b/tests/ui_and_conv/test_settings_selector.py index 3549c375..6d16f357 100644 --- a/tests/ui_and_conv/test_settings_selector.py +++ b/tests/ui_and_conv/test_settings_selector.py @@ -197,3 +197,49 @@ def test_settings_list_overflow_keeps_selected_row_within_window(): content_rows = (end - start) + (1 if has_scroll_row else 0) assert start <= target < end, f"selected {target} fell outside window {(start, end)}" assert content_rows <= budget, f"content {content_rows} exceeds budget {budget}" + + +def _item(settings: SettingsListConfig, item_id: str) -> SettingItem | None: + return next((item for item in settings.items if item.id == item_id), None) + + +def test_settings_exposes_auto_update_toggle_when_live(monkeypatch): + from pythinker_code import update_policy + + monkeypatch.setattr(update_policy, "auto_update_override_reason", lambda: None) + config = Config() + config.auto_update = True + + item = _item(_build_settings_config(config), "auto_update") + + assert item is not None + assert item.values == ("true", "false") # togglable + assert item.current_value == "true" + + +def test_settings_auto_update_readonly_under_override(monkeypatch): + from pythinker_code import update_policy + + monkeypatch.setattr( + update_policy, + "auto_update_override_reason", + lambda: "disabled by PYTHINKER_CLI_NO_AUTO_UPDATE", + ) + config = Config() + config.auto_update = True # stored true, but override forces effective off + + item = _item(_build_settings_config(config), "auto_update") + + assert item is not None + assert item.values is None # read-only: no no-op toggle + assert item.current_value == "false" # shows the effective state + assert "PYTHINKER_CLI_NO_AUTO_UPDATE" in item.description + + +def test_apply_settings_changes_sets_auto_update(): + config = Config() # auto_update defaults to True + + changed = apply_settings_changes(config, {"auto_update": "false"}) + + assert changed == ["auto_update"] + assert config.auto_update is False diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 3baa3fb7..6adb7ae7 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1586,10 +1586,14 @@ class FakeCompleted: def test_auto_update_enabled_precedence( monkeypatch, env_kill, config_value, source_checkout, expected ): - monkeypatch.setattr(update, "_auto_update_disabled", lambda: env_kill) - monkeypatch.setattr(update, "_is_running_from_source_checkout", lambda: source_checkout) + # auto_update_enabled now lives in the shell-free update_policy module and + # reads its dependencies there; patch the canonical location. + from pythinker_code import update_policy + + monkeypatch.setattr(update_policy, "auto_update_disabled", lambda: env_kill) + monkeypatch.setattr(update_policy, "is_running_from_source_checkout", lambda: source_checkout) config = cast("Config", SimpleNamespace(auto_update=config_value)) - assert update.auto_update_enabled(config) is expected + assert update_policy.auto_update_enabled(config) is expected def test_format_managed_channel_notice_managed(): diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 86289be5..c697805b 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -246,3 +246,27 @@ async def noop(): # Cleanup removed the task from the set and logged the process-exit message. assert fake_task not in shell._background_tasks assert any("process exit" in m for m in logged) + + +def test_auto_update_override_reason_env_killswitch(monkeypatch): + from pythinker_code import update_policy + + monkeypatch.setattr(update_policy, "auto_update_disabled", lambda: True) + monkeypatch.setattr(update_policy, "is_running_from_source_checkout", lambda: False) + assert update_policy.auto_update_override_reason() == "disabled by PYTHINKER_CLI_NO_AUTO_UPDATE" + + +def test_auto_update_override_reason_source_checkout(monkeypatch): + from pythinker_code import update_policy + + monkeypatch.setattr(update_policy, "auto_update_disabled", lambda: False) + monkeypatch.setattr(update_policy, "is_running_from_source_checkout", lambda: True) + assert update_policy.auto_update_override_reason() == "disabled for source checkouts" + + +def test_auto_update_override_reason_none_when_config_decides(monkeypatch): + from pythinker_code import update_policy + + monkeypatch.setattr(update_policy, "auto_update_disabled", lambda: False) + monkeypatch.setattr(update_policy, "is_running_from_source_checkout", lambda: False) + assert update_policy.auto_update_override_reason() is None diff --git a/tests/ui_and_conv/test_update_auto_slash.py b/tests/ui_and_conv/test_update_auto_slash.py new file mode 100644 index 00000000..8397d23e --- /dev/null +++ b/tests/ui_and_conv/test_update_auto_slash.py @@ -0,0 +1,159 @@ +"""Tests for the `/update auto [on|off]` toggle.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import Mock + +import pytest +from pythinker_core.tooling.empty import EmptyToolset + +from pythinker_code import update_policy +from pythinker_code.config import get_default_config, load_config, save_config +from pythinker_code.soul.agent import Agent, Runtime +from pythinker_code.soul.context import Context +from pythinker_code.soul.pythinkersoul import PythinkerSoul +from pythinker_code.ui.shell import Shell +from pythinker_code.ui.shell import slash as shell_slash + + +def _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace: + agent = Agent( + name="Test Agent", + system_prompt="Test system prompt.", + toolset=EmptyToolset(), + runtime=runtime, + ) + soul = PythinkerSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) + return SimpleNamespace(soul=soul) + + +async def _run_update(app: SimpleNamespace, args: str) -> None: + await cast(Awaitable[None], shell_slash.update_command(cast(Shell, app), args)) + + +@pytest.fixture(autouse=True) +def _no_override(monkeypatch: pytest.MonkeyPatch) -> None: + # The suite runs from a source checkout, where the override would otherwise + # be active; neutralize it so the toggle path is the live (non-override) one. + monkeypatch.setattr(update_policy, "auto_update_override_reason", lambda: None) + + +def _seed_config_file(path: Path, *, auto_update: bool) -> None: + config = get_default_config() + config.auto_update = auto_update + save_config(config, path) + + +@pytest.mark.asyncio +async def test_update_auto_on_persists_and_mirrors_live( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTHINKER_AUTO_UPDATE", raising=False) + config_path = (tmp_path / "config.toml").resolve() + _seed_config_file(config_path, auto_update=False) + runtime.config.source_file = config_path + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + + await _run_update(app, "auto on") + + # Behavior-level: the value is actually persisted to disk... + assert load_config(config_path).auto_update is True + # ...and mirrored into the live config (no reload). + assert runtime.config.auto_update is True + + +@pytest.mark.asyncio +async def test_update_auto_off_persists( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTHINKER_AUTO_UPDATE", raising=False) + config_path = (tmp_path / "config.toml").resolve() + _seed_config_file(config_path, auto_update=True) + runtime.config.source_file = config_path + runtime.config.auto_update = True + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + + await _run_update(app, "auto off") + + assert load_config(config_path).auto_update is False + assert runtime.config.auto_update is False + + +@pytest.mark.asyncio +async def test_update_auto_noop_when_already_set( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.auto_update = True + app = _make_shell_app(runtime, tmp_path) + print_mock = Mock() + save_mock = Mock() + monkeypatch.setattr(shell_slash, "save_config", save_mock) + monkeypatch.setattr(shell_slash.console, "print", print_mock) + + await _run_update(app, "auto on") + + save_mock.assert_not_called() + assert "already on" in str(print_mock.call_args.args[0]) + + +@pytest.mark.asyncio +async def test_update_auto_invalid_value_shows_usage( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + app = _make_shell_app(runtime, tmp_path) + print_mock = Mock() + save_mock = Mock() + monkeypatch.setattr(shell_slash, "save_config", save_mock) + monkeypatch.setattr(shell_slash.console, "print", print_mock) + + await _run_update(app, "auto maybe") + + save_mock.assert_not_called() + assert "Usage: /update auto" in str(print_mock.call_args.args[0]) + + +@pytest.mark.asyncio +async def test_update_auto_requires_config_file( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.source_file = None + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + print_mock = Mock() + save_mock = Mock() + monkeypatch.setattr(shell_slash, "save_config", save_mock) + monkeypatch.setattr(shell_slash.console, "print", print_mock) + + await _run_update(app, "auto on") + + save_mock.assert_not_called() + assert "config file" in str(print_mock.call_args.args[0]) + + +@pytest.mark.asyncio +async def test_update_auto_status_surfaces_override( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.auto_update = True + app = _make_shell_app(runtime, tmp_path) + print_mock = Mock() + monkeypatch.setattr( + update_policy, + "auto_update_override_reason", + lambda: "disabled by PYTHINKER_CLI_NO_AUTO_UPDATE", + ) + monkeypatch.setattr(update_policy, "auto_update_enabled", lambda cfg: False) + monkeypatch.setattr(shell_slash.console, "print", print_mock) + + await _run_update(app, "auto") + + printed = " ".join(str(call.args[0]) for call in print_mock.call_args_list) + assert "Auto-update: off" in printed + assert "PYTHINKER_CLI_NO_AUTO_UPDATE" in printed