diff --git a/CHANGELOG.md b/CHANGELOG.md index e310f930..c5d33d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- Added silent startup auto-updates with `auto_update`/`PYTHINKER_AUTO_UPDATE` opt-outs and a restart-to-apply notice. - **designer-skill MCP bridge.** Bundled a `designer-skill` stub skill that routes frontend work to the connected designer-skill MCP tools instead of failing ReadSkill; plugin-style names like `designer-skill:designer-skill` resolve correctly, and ReadSkill falls back to a generic MCP bridge (any user-configured server name) when only the MCP server is connected. - **Always-on best practices.** New `best_practices_always` config option folds the full `/best-practices` engineering guidance into the root session's system prompt at startup, so the guardrails apply to every new session without running the command. Default off. - **Smarter multi-edit errors.** A `StrReplaceFile` batch that fails schema validation (e.g. edit entries collapsed by a streaming glitch) now returns a precise, actionable error naming the bad entries and steering toward single-edit calls, instead of a wall of validation errors. Valid edits are never partially applied. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index ff63e242..828cf339 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -33,6 +33,7 @@ The configuration file contains the following top-level configuration items: | `theme` | `string` | Terminal color theme: `"dark"`, `"light"`, or `"auto"` (detects the terminal background at startup, falling back to dark); defaults to `"dark"` | | `show_thinking_stream` | `boolean` | Whether to stream the raw reasoning text in the live area as a 6-line scrolling preview and commit the full reasoning markdown to history when the block ends (defaults to `true`; set to `false` to show only the compact `Thinking ...` indicator and a one-line trace summary) | | `prevent_idle_sleep` | `boolean` | Whether to prevent the computer from idle-sleeping while an agent turn is running (defaults to `false`; supported on macOS, Linux, and Windows) | +| `auto_update` | `boolean` | Automatically install new releases in the background at startup; the current session keeps running and you restart to apply (defaults to `true`) | | `merge_all_available_skills` | `boolean` | Whether to merge skills from all brand directories (defaults to `true`); see [Skills configuration](../customization/skills.md) | | `providers` | `table` | API provider configuration | | `models` | `table` | Model configuration | @@ -56,6 +57,7 @@ default_editor = "" theme = "dark" show_thinking_stream = true prevent_idle_sleep = false +auto_update = true merge_all_available_skills = true [providers.pythinker-for-coding] diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 78347ea8..b4e6e008 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -139,7 +139,8 @@ export OPENAI_ADMIN_KEY="sk-admin-xxx" | Environment Variable | Description | | --- | --- | | `PYTHINKER_SHARE_DIR` | Customize the share directory path (default: `~/.pythinker`) | -| `PYTHINKER_CLI_NO_AUTO_UPDATE` | Disable proactive update checks and startup update notices | +| `PYTHINKER_CLI_NO_AUTO_UPDATE` | Hard kill-switch: disable silent auto-install, update checks, and startup update notices | +| `PYTHINKER_AUTO_UPDATE` | Toggle silent startup auto-update (`auto_update` config field); the hard kill-switch `PYTHINKER_CLI_NO_AUTO_UPDATE` overrides it | | `PYTHINKER_CLI_PASTE_CHAR_THRESHOLD` | Character threshold for folding pasted text (default: `200`) | | `PYTHINKER_CLI_PASTE_LINE_THRESHOLD` | Line threshold for folding pasted text (default: `5`) | @@ -169,6 +170,32 @@ export PYTHINKER_CLI_NO_AUTO_UPDATE="1" If you installed Pythinker Code via Nix or other package managers, this environment variable is typically set automatically since updates are handled by the package manager. ::: +### `PYTHINKER_AUTO_UPDATE` + +Set to `0`/`false`/`no` to disable silent startup auto-updates, or `1`/`true`/`yes` +to enable them (default). This flips the `auto_update` config field. + +```sh +export PYTHINKER_AUTO_UPDATE="false" +``` + +::: warning Hard kill-switch wins +`PYTHINKER_CLI_NO_AUTO_UPDATE` takes precedence: when it is set, `PYTHINKER_AUTO_UPDATE=1` +cannot re-enable updates, and Pythinker shows no update activity at all. +::: + +#### Per-channel behavior + +When enabled and a newer installable release exists, Pythinker installs it in a +background task and shows a one-line `Updated X → Y. Restart Pythinker to apply.` +notice — the running session continues on the old version until you restart. + +- **Windows** (native installer / pip): the process exits so the installer can + replace the binary. +- **Managed channels** (Docker/Nix/Scoop/WinGet): no binary swap — Pythinker + shows a channel-native upgrade hint instead. +- **Source checkouts**: never auto-update. + ### `PYTHINKER_CLI_PASTE_CHAR_THRESHOLD` In Agent mode, when pasted text exceeds this character count, it is folded into a placeholder (e.g., `[Pasted text #1 +10 lines]`) and expanded to full content on submit. Default: `200`. diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index 74516e67..ee3ff005 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -84,6 +84,7 @@ def find_project_root(cwd: Path) -> Path | None: "PYTHINKER_THEME": ("theme",), "PYTHINKER_SHOW_THINKING_STREAM": ("show_thinking_stream",), "PYTHINKER_PREVENT_IDLE_SLEEP": ("prevent_idle_sleep",), + "PYTHINKER_AUTO_UPDATE": ("auto_update",), "PYTHINKER_TELEMETRY": ("telemetry",), "PYTHINKER_SESSION_RETENTION_DAYS": ("session_retention_days",), "PYTHINKER_MERGE_ALL_AVAILABLE_SKILLS": ("merge_all_available_skills",), @@ -1082,6 +1083,10 @@ class Config(BaseModel): "Supported on macOS, Linux, and Windows. Default: false." ), ) + auto_update: bool = Field( + default=True, + description="Automatically install new releases in the background at startup.", + ) models: dict[str, LLMModel] = Field(default_factory=dict, description="List of LLM models") providers: dict[str, LLMProvider] = Field( default_factory=dict, description="List of LLM providers" diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 90484086..79e91b8e 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -68,13 +68,22 @@ from pythinker_code.ui.shell.slash import SKILL_COMMAND_PREFIX, shell_mode_registry from pythinker_code.ui.shell.slash import registry as shell_slash_registry from pythinker_code.ui.shell.update import ( + MANAGED_CHANNEL_MARKER, + UpdateResult, + _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, refresh_update_cache_if_due, welcome_update_target, ) from pythinker_code.ui.shell.update_orchestrator import ( - prompt_pre_start_update_job as prompt_pre_start_update, + SMOKE_CHECK_FAILED_PREFIX, + read_update_status, + run_update_job, ) from pythinker_code.ui.shell.visualize import ( ApprovalPromptDelegate, @@ -814,19 +823,10 @@ async def run(self, command: str | None = None) -> bool: finally: self._cancel_background_tasks() - # Blocking pre-start update prompt. Must run before _auto_update so the - # same upgrade isn't shown as both a blocking menu and a background - # toast; if the user picks "Skip this session" the toast is suppressed - # by _skipped_version_this_session. May raise typer.Exit on "Update now" - # or "Exit" — that's the documented behavior. prompt_pre_start_update - # self-suppresses for source checkouts and non-TTY sessions. - await prompt_pre_start_update() - - # Start auto-update background task if not disabled. - if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"): - logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable") - else: - self._start_background_task(self._auto_update()) + # Auto-update at startup is silent + non-blocking (default on). The old + # blocking pre-start prompt is intentionally gone; the function remains + # in update_orchestrator.py for future re-wiring. + self._schedule_startup_update_task() if isinstance(self.soul, PythinkerSoul): # Kick off MCP loading before the banner so servers connect in the @@ -2106,6 +2106,100 @@ async def _auto_update(self) -> None: if self._prompt_session is not None: self._prompt_session.invalidate() + async def _silent_auto_update(self) -> None: + """Install a newer release silently in the background at startup.""" + if not _should_auto_check_for_updates(): + return + _mark_auto_update_check_attempt() + + result = await self._run_silent_update_job() + if result is UpdateResult.UPDATED: + self._surface_installed_update_notice() + elif result is UpdateResult.UPDATE_AVAILABLE: + self._surface_managed_channel_notice() + # FAILED / UP_TO_DATE / UNSUPPORTED / None → silent (recorded in the job log). + + async def _run_silent_update_job(self) -> UpdateResult | None: + try: + return await run_update_job(print_output=False, check_only=False, source="startup-auto") + except SystemExit: + raise + except Exception: + # Boundary-only recovery: update failure must not abort the shell, + # and run_update_job has already persisted status/log details. + logger.exception("Silent auto-update failed:") + return None + + def _surface_installed_update_notice(self) -> None: + if self._installed_update_smoke_check_failed(): + self._update_toast( + "Update installed but verification failed; see update.log.", + style="fg:ansiyellow", + ) + return + self._update_toast( + self._installed_update_restart_notice(), + style="fg:ansibrightyellow bold", + ) + + def _installed_update_smoke_check_failed(self) -> bool: + status = read_update_status() + message = status.message if status else None + return bool(message and message.startswith(SMOKE_CHECK_FAILED_PREFIX)) + + def _installed_update_restart_notice(self) -> str: + from pythinker_code.constant import VERSION as current_version + + status = read_update_status() + new_version = ( + (status.target_version if status else None) + or welcome_update_target() + or "the latest version" + ) + return f"Updated {current_version} → {new_version}. Restart Pythinker to apply." + + def _surface_managed_channel_notice(self) -> None: + notice = self._managed_channel_notice() or pending_update_notice() + if notice: + self._update_toast(notice, style="fg:ansibrightyellow bold") + + def _managed_channel_notice(self) -> str | None: + from pythinker_code.constant import VERSION as current_version + + # Managed-channel fast-path: skip the welcome lookup for non-managed + # installs; format_managed_channel_notice re-validates the marker. + if _detect_upgrade_command()[:1] != [MANAGED_CHANNEL_MARKER]: + return None + latest = welcome_update_target() + if latest is None: + return None + return format_managed_channel_notice(current_version, latest) + + def _update_toast(self, notice: str, *, style: str) -> None: + toast(notice, topic="update", duration=30.0, immediate=True, style=style) + if self._prompt_session is not None: + self._prompt_session.invalidate() + + def _schedule_startup_update_task(self) -> None: + """Pick the startup update behavior and schedule it (non-blocking). + + - env kill-switch set → nothing (cache filters already suppress the + toast, matching today's hard-disable behavior). + - enabled → silent background install. + - config-disabled OR source checkout → informational toast only + (`_auto_update`); self-suppresses for source checkouts because + `pending_update_notice()` returns None in that path. + - non-PythinkerSoul → same toast-only path (no runtime config to + consult), matching the prior unconditional `_auto_update` behavior. + """ + if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"): + logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable") + return + if isinstance(self.soul, PythinkerSoul) and auto_update_enabled(self.soul.runtime.config): + self._start_background_task(self._silent_auto_update()) + else: + self._start_background_task(self._auto_update()) + def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: task = asyncio.create_task(coro) self._background_tasks.add(task) @@ -2116,6 +2210,10 @@ def _cleanup(t: asyncio.Task[Any]) -> None: t.result() except asyncio.CancelledError: pass + except SystemExit: + # The silent updater's Windows native/pip path raises SystemExit + # so the installer can replace the binary; don't crash the shell. + logger.info("Background task requested process exit (update installer launched).") except Exception: logger.exception("Background task failed:") diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 9a64f0ee..26031991 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -15,7 +15,10 @@ from enum import Enum, auto from pathlib import Path from shutil import which -from typing import cast +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from pythinker_code.config import Config import aiohttp import typer @@ -241,6 +244,24 @@ def _auto_update_disabled() -> bool: return get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE") +def format_managed_channel_notice( + current: str, + latest: str, + *, + upgrade_command: list[str] | None = None, +) -> str | None: + """One-line channel-native upgrade hint for managed installs, or None.""" + command = upgrade_command if upgrade_command is not None else _detect_upgrade_command() + if command[:1] != [MANAGED_CHANNEL_MARKER] or len(command) < 2: + return None + channel = command[1] + return ( + f"Pythinker is managed by your {channel} channel. " + f"Update {current} → {latest} via {channel} " + "(rebuild/repull the image or run the channel's upgrade command)." + ) + + def _is_running_from_source_checkout() -> bool: """Return true when invoked from this repository via ``uv run``/editable source. @@ -267,6 +288,27 @@ def _is_running_from_source_checkout() -> bool: 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 @@ -1306,16 +1348,17 @@ def _print(message: str) -> None: upgrade_command = _detect_upgrade_command() if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: - channel = upgrade_command[1] try: LATEST_VERSION_FILE.write_text(latest_version, encoding="utf-8") except OSError: logger.exception("Failed to cache latest version:") - _print( - f"[{_t.warning}]Pythinker is managed by your {channel} channel. " - f"Update {current_version} → {latest_version} via {channel} " - "(rebuild/repull the image or run the channel's upgrade command).[/]" + notice = format_managed_channel_notice( + current_version, + latest_version, + upgrade_command=upgrade_command, ) + if notice: + _print(f"[{_t.warning}]{notice}[/]") return UpdateResult.UPDATE_AVAILABLE unavailable_reason = await _update_candidate_unavailable_reason( session, latest_version, upgrade_command diff --git a/src/pythinker_code/ui/shell/update_orchestrator.py b/src/pythinker_code/ui/shell/update_orchestrator.py index cbf928fc..d4ed009e 100644 --- a/src/pythinker_code/ui/shell/update_orchestrator.py +++ b/src/pythinker_code/ui/shell/update_orchestrator.py @@ -32,6 +32,8 @@ _LOCK_MALFORMED_GRACE_SECONDS = 60 _SMOKE_CHECK_TIMEOUT_SECONDS = 10 +SMOKE_CHECK_FAILED_PREFIX = "Updated, but smoke check did not pass: " + class UpdateJobState(StrEnum): IDLE = "idle" @@ -381,7 +383,7 @@ async def run_update_job( message = smoke_message _write_last_success(job_id=job_id, message=message) else: - message = f"Updated, but smoke check did not pass: {smoke_message}" + message = f"{SMOKE_CHECK_FAILED_PREFIX}{smoke_message}" write_update_status( _new_status( diff --git a/tests/core/test_config.py b/tests/core/test_config.py index e1e7add6..09d62ffc 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -44,6 +44,7 @@ def test_default_config_dump(): "theme": "dark", "show_thinking_stream": True, "prevent_idle_sleep": False, + "auto_update": True, "models": {}, "providers": {}, "loop_control": { @@ -777,3 +778,25 @@ def test_statusline_v2_field_validation(): StatusLineConfig(command_timeout_ms=60_001) with pytest.raises(ValidationError): StatusLineConfig(command_timeout_ms=0) + + +def test_apply_env_vars_auto_update(monkeypatch): + monkeypatch.setenv("PYTHINKER_AUTO_UPDATE", "false") + merged: dict = {} + prov: dict = {} + _apply_env_vars(merged, prov) + assert merged["auto_update"] == "false" + assert prov["auto_update"] == "env PYTHINKER_AUTO_UPDATE" + + +def test_auto_update_defaults_true(): + from pythinker_code.config import Config + + assert Config().auto_update is True + + +def test_auto_update_round_trips_false(): + from pythinker_code.config import Config + + cfg = Config.model_validate({"auto_update": False}) + assert cfg.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 9dd2a676..3baa3fb7 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -3,6 +3,7 @@ import asyncio from pathlib import Path from types import SimpleNamespace +from typing import TYPE_CHECKING, cast import pytest import typer @@ -10,6 +11,9 @@ from pythinker_code.ui.shell import update +if TYPE_CHECKING: + from pythinker_code.config import Config + @pytest.mark.asyncio async def test_prompt_pre_start_update_runs_update_and_exits_on_accept(monkeypatch): @@ -1067,13 +1071,13 @@ def test_update_prompt_text_shows_version_and_command(monkeypatch): @pytest.mark.asyncio -async def test_run_awaits_pre_start_update_before_auto_update(runtime, tmp_path, monkeypatch): - """Regression (efe101c/#63): Shell.run() must await prompt_pre_start_update() - — the blocking update menu — before scheduling the _auto_update background - toast. The menu was silently unwired while its unit tests stayed green; this - pins the wiring so it can't regress again unnoticed. +async def test_run_schedules_startup_update_task(runtime, tmp_path, monkeypatch): + """Regression (efe101c/#63, updated for silent auto-update): Shell.run() must + invoke _schedule_startup_update_task() during startup — the silent, non-blocking + auto-update dispatch that replaced the old blocking pre-start prompt. Pins the + wiring so the startup update path can't be silently unwired again. """ - from unittest.mock import AsyncMock, MagicMock + from unittest.mock import MagicMock from pythinker_core.tooling.empty import EmptyToolset @@ -1088,26 +1092,18 @@ async def test_run_awaits_pre_start_update_before_auto_update(runtime, tmp_path, soul = PythinkerSoul(agent, context=Context(file_backend=tmp_path / "h.jsonl")) shell = Shell(soul) - class _PromptReached(Exception): + class _SchedulerReached(Exception): pass - # Patch the name where run() looks it up (imported into the ui.shell module). - prompt_mock = AsyncMock(side_effect=_PromptReached) - monkeypatch.setattr("pythinker_code.ui.shell.prompt_pre_start_update", prompt_mock) - # If auto_update were scheduled before the prompt, this spy would be called. - auto_update_mock = MagicMock(name="_auto_update") - monkeypatch.setattr(shell, "_auto_update", auto_update_mock) - - # The sentinel is the real guard: _PromptReached is only reachable if run() - # actually awaits the (patched) prompt, so it pins prompt-before-auto_update. - # If the wiring were removed, run() would instead schedule the un-awaited - # MagicMock _auto_update and fail at create_task (TypeError) — still a failure, - # just a noisier one. - with pytest.raises(_PromptReached): + # The sentinel is the real guard: _SchedulerReached is only reachable if run() + # actually calls _schedule_startup_update_task during startup, pinning the wiring. + scheduler_mock = MagicMock(side_effect=_SchedulerReached) + monkeypatch.setattr(shell, "_schedule_startup_update_task", scheduler_mock) + + with pytest.raises(_SchedulerReached): await shell.run() - prompt_mock.assert_awaited_once() - auto_update_mock.assert_not_called() + scheduler_mock.assert_called_once() # --------------------------------------------------------------------------- @@ -1574,3 +1570,63 @@ class FakeCompleted: monkeypatch.setattr(update.subprocess, "run", lambda *a, **k: FakeCompleted()) assert update._installed_homebrew_version() is None + + +@pytest.mark.parametrize( + ("env_kill", "config_value", "source_checkout", "expected"), + [ + (False, True, False, True), # default → enabled + (True, True, False, False), # env kill-switch wins over config + (False, False, False, False), # config off + (True, False, False, False), # both off + (False, True, True, False), # source checkout always off + (True, True, True, False), # source checkout + env kill + ], +) +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) + config = cast("Config", SimpleNamespace(auto_update=config_value)) + assert update.auto_update_enabled(config) is expected + + +def test_format_managed_channel_notice_managed(): + notice = update.format_managed_channel_notice( + "0.42.0", + "0.43.0", + upgrade_command=[update.MANAGED_CHANNEL_MARKER, "Nix"], + ) + assert notice is not None + assert "Nix" in notice + assert "0.42.0 → 0.43.0" in notice + # Plain text — no rich markup; the toast applies style separately. + assert "[" not in notice + + +def test_format_managed_channel_notice_non_managed(): + assert update.format_managed_channel_notice("0.42.0", "0.43.0", upgrade_command=["pip"]) is None + + +@pytest.mark.asyncio +async def test_do_update_result_is_print_output_invariant(monkeypatch, tmp_path): + # Managed channel returns UPDATE_AVAILABLE regardless of print_output. + monkeypatch.setattr( + update, + "_detect_upgrade_command", + lambda: [update.MANAGED_CHANNEL_MARKER, "Nix"], + ) + monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt") + + async def fake_latest(session): + return "999.0.0" # force "newer than current" + + monkeypatch.setattr(update, "_get_latest_version", fake_latest) + monkeypatch.setattr(update, "_clear_latest_version_cache", lambda: None) + # Prevent real network I/O: wrap the session context with a no-op stub. + monkeypatch.setattr(update, "new_client_session", lambda timeout: _FakeSessionContext(object())) + + loud = await update.do_update(print_output=True, check_only=False) + quiet = await update.do_update(print_output=False, check_only=False) + assert loud is quiet is update.UpdateResult.UPDATE_AVAILABLE diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py new file mode 100644 index 00000000..86289be5 --- /dev/null +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -0,0 +1,248 @@ +"""Silent auto-update: background install + result surfacing at startup.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from pythinker_core.tooling.empty import EmptyToolset + +import pythinker_code.ui.shell as shell_module +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.update import UpdateResult + + +def _make_shell(runtime: Runtime, tmp_path: Path) -> shell_module.Shell: + 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 shell_module.Shell(soul) + + +@pytest.fixture +def _toasts(monkeypatch): + captured: list[tuple[str, dict]] = [] + monkeypatch.setattr(shell_module, "toast", lambda msg, **kw: captured.append((msg, kw))) + return captured + + +@pytest.mark.asyncio +async def test_silent_update_success_toasts_restart( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) + monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", lambda: None) + monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"]) + + async def fake_job(**kw): + assert kw["print_output"] is False + assert kw["source"] == "startup-auto" + return UpdateResult.UPDATED + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + monkeypatch.setattr( + shell_module, + "read_update_status", + lambda: SimpleNamespace(message="updated", target_version="0.43.0"), + ) + + await shell._silent_auto_update() + + assert any("Restart Pythinker to apply" in m for m, _ in _toasts) + assert any("0.43.0" in m for m, _ in _toasts) + + +@pytest.mark.asyncio +async def test_silent_update_smoke_fail_toasts_verification_failed( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) + monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", lambda: None) + monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"]) + + async def fake_job(**kw): + return UpdateResult.UPDATED + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + monkeypatch.setattr( + shell_module, + "read_update_status", + lambda: SimpleNamespace( + message="Updated, but smoke check did not pass: boom", + target_version="0.43.0", + ), + ) + + await shell._silent_auto_update() + + assert any("verification failed" in m for m, _ in _toasts) + assert not any("Restart Pythinker to apply" in m for m, _ in _toasts) + + +@pytest.mark.asyncio +async def test_silent_update_failed_is_silent( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) + monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", lambda: None) + monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"]) + + async def fake_job(**kw): + return UpdateResult.FAILED + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + await shell._silent_auto_update() + assert _toasts == [] + + +@pytest.mark.asyncio +async def test_silent_update_managed_channel_toasts_channel_hint( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) + monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", lambda: None) + monkeypatch.setattr( + shell_module, + "_detect_upgrade_command", + lambda: [shell_module.MANAGED_CHANNEL_MARKER, "Nix"], + ) + + async def fake_job(**kw): + return UpdateResult.UPDATE_AVAILABLE + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "0.43.0") + monkeypatch.setattr( + shell_module, + "format_managed_channel_notice", + lambda cur, latest: f"managed Nix {cur} -> {latest}", + ) + + await shell._silent_auto_update() + assert any("managed Nix" in m for m, _ in _toasts) + + +@pytest.mark.asyncio +async def test_silent_update_respects_throttle( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts +): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: False) + called = False + + async def fake_job(**kw): + nonlocal called + called = True + return UpdateResult.UPDATED + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + await shell._silent_auto_update() + assert called is False + assert _toasts == [] + + +def _scheduling_shell(runtime, tmp_path, monkeypatch): + shell = _make_shell(runtime, tmp_path) + scheduled: list[str] = [] + + def fake_start(coro): + # Identify which coroutine was scheduled, then close it to avoid a + # "coroutine was never awaited" warning. + scheduled.append(coro.__name__ if hasattr(coro, "__name__") else repr(coro)) + coro.close() + return None + + monkeypatch.setattr(shell, "_start_background_task", fake_start) + return shell, scheduled + + +def test_dispatch_enabled_schedules_silent(runtime, tmp_path, monkeypatch): + shell, scheduled = _scheduling_shell(runtime, tmp_path, monkeypatch) + monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False) + monkeypatch.setattr(shell_module, "auto_update_enabled", lambda cfg: True) + + shell._schedule_startup_update_task() + assert scheduled == ["_silent_auto_update"] + + +def test_dispatch_config_disabled_schedules_toast_only(runtime, tmp_path, monkeypatch): + shell, scheduled = _scheduling_shell(runtime, tmp_path, monkeypatch) + monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False) + monkeypatch.setattr(shell_module, "auto_update_enabled", lambda cfg: False) + + shell._schedule_startup_update_task() + assert scheduled == ["_auto_update"] + + +def test_dispatch_env_killswitch_schedules_nothing(runtime, tmp_path, monkeypatch): + shell, scheduled = _scheduling_shell(runtime, tmp_path, monkeypatch) + monkeypatch.setenv("PYTHINKER_CLI_NO_AUTO_UPDATE", "1") + + shell._schedule_startup_update_task() + assert scheduled == [] + + +def test_background_task_systemexit_does_not_crash(runtime, tmp_path, monkeypatch): + """_cleanup swallows SystemExit from t.result() and logs instead of crashing. + + Python 3.14 propagates SystemExit out of asyncio.run() when a task raises it, + so we test the done-callback in isolation: intercept the callback registered by + _start_background_task and invoke it with a mock task whose .result() raises + SystemExit. + """ + shell = _make_shell(runtime, tmp_path) + logged: list[str] = [] + monkeypatch.setattr(shell_module.logger, "info", lambda msg, *a, **k: logged.append(msg)) + + # A thin stand-in for asyncio.Task that captures the done-callback. + registered: list = [] + + class _CapturingTask: + def add_done_callback(self, fn): + registered.append(fn) + + capturing_task = _CapturingTask() + + def fake_create_task(coro, **kw): + coro.close() # avoid "coroutine never awaited" warning + return capturing_task # type: ignore[return-value] + + monkeypatch.setattr(asyncio, "create_task", fake_create_task) + + async def noop(): + pass + + shell._start_background_task(noop()) + + assert len(registered) == 1, "expected one done-callback to be registered" + cleanup = registered[0] + + # capturing_task only needs add_done_callback() to grab the real _cleanup + # closure; fake_task is the argument _cleanup actually operates on, so it + # needs the .cancelled() / .result() interface that _cleanup calls. + fake_task: MagicMock = MagicMock(spec=asyncio.Task) + fake_task.cancelled.return_value = False + fake_task.result.side_effect = SystemExit(0) + # Pre-populate the set so _cleanup's discard(t) has something to remove, + # mirroring what _start_background_task does in production. + shell._background_tasks.add(fake_task) + + # Invoke _cleanup with the fake task — must NOT raise. + cleanup(fake_task) + + # 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)