From 1325aa6b641b34c1cc1021c0d3069df57fc7656a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:17:56 -0400 Subject: [PATCH 01/15] feat(config): add auto_update field + PYTHINKER_AUTO_UPDATE env mapping --- src/pythinker_code/config.py | 5 +++++ tests/core/test_config.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) 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/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 From 6ec43a4b9f92266574edcfa7277052f2ae6ebba5 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:21:51 -0400 Subject: [PATCH 02/15] feat(update): add auto_update_enabled resolver --- src/pythinker_code/ui/shell/update.py | 27 +++++++++++++++++++++++++- tests/ui_and_conv/test_shell_update.py | 24 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 9a64f0ee..4d7492e5 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -15,7 +15,7 @@ from enum import Enum, auto from pathlib import Path from shutil import which -from typing import cast +from typing import TYPE_CHECKING, cast import aiohttp import typer @@ -267,6 +267,31 @@ def _is_running_from_source_checkout() -> bool: return False +if TYPE_CHECKING: + from pythinker_code.config import Config + + +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/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 9dd2a676..20493f16 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1574,3 +1574,27 @@ 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 = SimpleNamespace(auto_update=config_value) + assert update.auto_update_enabled(config) is expected From 84d053a449f562d7e36cff1a6dc45e92f98aac6a Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:25:59 -0400 Subject: [PATCH 03/15] refactor(update): hoist TYPE_CHECKING Config import to module top --- src/pythinker_code/ui/shell/update.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 4d7492e5..c05a9ffd 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -17,6 +17,9 @@ from shutil import which from typing import TYPE_CHECKING, cast +if TYPE_CHECKING: + from pythinker_code.config import Config + import aiohttp import typer from rich.text import Text @@ -267,10 +270,6 @@ def _is_running_from_source_checkout() -> bool: return False -if TYPE_CHECKING: - from pythinker_code.config import Config - - def auto_update_enabled(config: Config) -> bool: """Whether startup may silently install a newer release. From 320d3a22dade47869a1b4b5855a5fe0a3314b6dd Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:28:46 -0400 Subject: [PATCH 04/15] feat(update): factor managed-channel notice into format_managed_channel_notice --- src/pythinker_code/ui/shell/update.py | 31 +++++++++++++++++++++----- tests/ui_and_conv/test_shell_update.py | 22 ++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index c05a9ffd..dd664bd8 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -244,6 +244,26 @@ 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. @@ -1330,16 +1350,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/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 20493f16..701460ce 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1598,3 +1598,25 @@ def test_auto_update_enabled_precedence( ) 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 + ) From e5388c892b86173090da1d9ed76e60c224f5f362 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:36:53 -0400 Subject: [PATCH 05/15] feat(shell): add _silent_auto_update background installer --- src/pythinker_code/ui/shell/__init__.py | 86 ++++++++++ tests/ui_and_conv/test_silent_auto_update.py | 155 +++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/ui_and_conv/test_silent_auto_update.py diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 90484086..98d768c1 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -68,7 +68,13 @@ 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] consume_whats_new, + format_managed_channel_notice, pending_update_notice, refresh_update_cache_if_due, welcome_update_target, @@ -76,6 +82,10 @@ from pythinker_code.ui.shell.update_orchestrator import ( prompt_pre_start_update_job as prompt_pre_start_update, ) +from pythinker_code.ui.shell.update_orchestrator import ( + read_update_status, + run_update_job, +) from pythinker_code.ui.shell.visualize import ( ApprovalPromptDelegate, visualize, @@ -2106,6 +2116,82 @@ 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("Updated, but smoke check")) + + 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 + + # Only managed installs get the channel-native hint; otherwise defer to + # the generic pending-update notice via the caller's fallback. + 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 _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: task = asyncio.create_task(coro) self._background_tasks.add(task) 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..3f07ab37 --- /dev/null +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -0,0 +1,155 @@ +"""Silent auto-update: background install + result surfacing at startup.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +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 import Shell +from pythinker_code.ui.shell.update import UpdateResult + + +def _make_shell(runtime: Runtime, tmp_path: Path) -> 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(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 == [] From 009d0004c85eb4ca9d9401a5fdccf9c49bd02ad8 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:43:39 -0400 Subject: [PATCH 06/15] refactor(update): share smoke-check-failed sentinel as a constant --- src/pythinker_code/ui/shell/__init__.py | 13 +++++++------ src/pythinker_code/ui/shell/update_orchestrator.py | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 98d768c1..941f3fab 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -80,12 +80,13 @@ welcome_update_target, ) from pythinker_code.ui.shell.update_orchestrator import ( - prompt_pre_start_update_job as prompt_pre_start_update, -) -from pythinker_code.ui.shell.update_orchestrator import ( + SMOKE_CHECK_FAILED_PREFIX, read_update_status, run_update_job, ) +from pythinker_code.ui.shell.update_orchestrator import ( + prompt_pre_start_update_job as prompt_pre_start_update, +) from pythinker_code.ui.shell.visualize import ( ApprovalPromptDelegate, visualize, @@ -2157,7 +2158,7 @@ def _surface_installed_update_notice(self) -> None: 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("Updated, but smoke check")) + 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 @@ -2178,8 +2179,8 @@ def _surface_managed_channel_notice(self) -> None: def _managed_channel_notice(self) -> str | None: from pythinker_code.constant import VERSION as current_version - # Only managed installs get the channel-native hint; otherwise defer to - # the generic pending-update notice via the caller's fallback. + # 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() 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( From bce3df5c626950612e7842a9a42f773fc21771d0 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:48:31 -0400 Subject: [PATCH 07/15] feat(shell): silent auto-update at startup, drop blocking prompt --- src/pythinker_code/ui/shell/__init__.py | 40 +++++++++++-------- tests/ui_and_conv/test_shell_update.py | 36 +++++++---------- tests/ui_and_conv/test_silent_auto_update.py | 42 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 38 deletions(-) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 941f3fab..e4f68e9f 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -73,6 +73,7 @@ _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, @@ -84,9 +85,6 @@ read_update_status, run_update_job, ) -from pythinker_code.ui.shell.update_orchestrator import ( - prompt_pre_start_update_job as prompt_pre_start_update, -) from pythinker_code.ui.shell.visualize import ( ApprovalPromptDelegate, visualize, @@ -825,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 (see design doc). + self._schedule_startup_update_task() if isinstance(self.soul, PythinkerSoul): # Kick off MCP loading before the banner so servers connect in the @@ -2193,6 +2182,25 @@ def _update_toast(self, notice: str, *, style: str) -> None: 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 → informational toast only. + - source checkout → schedule the existing toast-only path, which self-suppresses. + """ + if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"): + logger.info( + "Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable" + ) + return + if 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) diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 701460ce..8067bd44 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1067,13 +1067,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 +1088,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() # --------------------------------------------------------------------------- diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 3f07ab37..63f9490e 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -153,3 +153,45 @@ async def fake_job(**kw): 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.setenv("PYTHINKER_CLI_NO_AUTO_UPDATE", "") + 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 == [] From 37c3d6a8c347778d55b7d179597f9f118c81bdc5 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:53:18 -0400 Subject: [PATCH 08/15] docs(shell): clarify startup-update dispatch docstring and drop redundant test env --- src/pythinker_code/ui/shell/__init__.py | 7 ++++--- tests/ui_and_conv/test_silent_auto_update.py | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index e4f68e9f..6730f6c5 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -825,7 +825,7 @@ async def run(self, command: str | None = None) -> bool: # 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 (see design doc). + # in update_orchestrator.py for future re-wiring. self._schedule_startup_update_task() if isinstance(self.soul, PythinkerSoul): @@ -2188,8 +2188,9 @@ def _schedule_startup_update_task(self) -> None: - env kill-switch set → nothing (cache filters already suppress the toast, matching today's hard-disable behavior). - enabled → silent background install. - - config-disabled → informational toast only. - - source checkout → schedule the existing toast-only path, which self-suppresses. + - config-disabled OR source checkout → informational toast only + (`_auto_update`); self-suppresses for source checkouts because + `pending_update_notice()` returns None in that path. """ if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"): logger.info( diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 63f9490e..9d47b1dd 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -172,7 +172,6 @@ def fake_start(coro): def test_dispatch_enabled_schedules_silent(runtime, tmp_path, monkeypatch): shell, scheduled = _scheduling_shell(runtime, tmp_path, monkeypatch) - monkeypatch.setenv("PYTHINKER_CLI_NO_AUTO_UPDATE", "") monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False) monkeypatch.setattr(shell_module, "auto_update_enabled", lambda cfg: True) From ba86826f79be62db860d543de643a2d6bca09193 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 14:58:22 -0400 Subject: [PATCH 09/15] fix(shell): swallow SystemExit in background-task cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows native/pip update path in run_update_job raises SystemExit so the installer can replace the binary. That exception propagates through the asyncio task and into _cleanup via t.result(). Since SystemExit is a BaseException the existing `except Exception` clause did not catch it, allowing it to escape the done-callback and crash the shell. Add an `except SystemExit` clause (before `except Exception`) that logs the event instead of re-raising. Test uses a _CapturingTask stand-in (monkeypatching asyncio.create_task) to intercept the registered done-callback and drive it synchronously with a mock task whose .result() raises SystemExit — necessary because Python 3.14 propagates SystemExit out of asyncio.run() before the callback can be tested via a live event loop. --- src/pythinker_code/ui/shell/__init__.py | 4 ++ tests/ui_and_conv/test_silent_auto_update.py | 51 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 6730f6c5..53caa586 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2212,6 +2212,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/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 9d47b1dd..105a1d5d 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -2,8 +2,10 @@ 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 @@ -194,3 +196,52 @@ def test_dispatch_env_killswitch_schedules_nothing(runtime, tmp_path, monkeypatc 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] + + # Build a finished mock task whose .result() raises SystemExit. + fake_task: MagicMock = MagicMock(spec=asyncio.Task) + fake_task.cancelled.return_value = False + fake_task.result.side_effect = SystemExit(0) + 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) From 706004f621ed5b58c2a6958d2c7843a533d94d1c Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 15:02:46 -0400 Subject: [PATCH 10/15] test(shell): clarify SystemExit cleanup test harness with comments --- tests/ui_and_conv/test_silent_auto_update.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 105a1d5d..90bb632b 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -233,10 +233,14 @@ async def noop(): assert len(registered) == 1, "expected one done-callback to be registered" cleanup = registered[0] - # Build a finished mock task whose .result() raises SystemExit. + # 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. From 221bb7b8a418d086e0d97d8f51a8e8fc7b0df577 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 15:04:45 -0400 Subject: [PATCH 11/15] test(update): guard print_output invariance of do_update result --- tests/ui_and_conv/test_shell_update.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 8067bd44..7624ec2f 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1612,3 +1612,26 @@ def test_format_managed_channel_notice_non_managed(): ) 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 From ffc11f10867ef33d4eb46872b7759c31320e9570 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 15:08:05 -0400 Subject: [PATCH 12/15] docs: document auto_update config + PYTHINKER_AUTO_UPDATE --- CHANGELOG.md | 1 + docs/en/configuration/config-files.md | 2 ++ docs/en/configuration/env-vars.md | 29 ++++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) 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`. From 26ec3ebacc6337cb83fd9c9f2ea5151aa24c5d85 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 15:14:27 -0400 Subject: [PATCH 13/15] fix(shell): satisfy pyright + ruff format for silent auto-update Narrow self.soul to PythinkerSoul before reading runtime.config (falling back to the toast-only path otherwise), cast the resolver test stub to Config, and apply ruff format across the touched files so make check passes. --- src/pythinker_code/ui/shell/__init__.py | 10 ++----- src/pythinker_code/ui/shell/update.py | 4 +-- tests/ui_and_conv/test_shell_update.py | 31 ++++++++------------ tests/ui_and_conv/test_silent_auto_update.py | 4 +-- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 53caa586..bcefdea3 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2121,9 +2121,7 @@ async def _silent_auto_update(self) -> None: async def _run_silent_update_job(self) -> UpdateResult | None: try: - return await run_update_job( - print_output=False, check_only=False, source="startup-auto" - ) + return await run_update_job(print_output=False, check_only=False, source="startup-auto") except SystemExit: raise except Exception: @@ -2193,11 +2191,9 @@ def _schedule_startup_update_task(self) -> None: `pending_update_notice()` returns None in that path. """ if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"): - logger.info( - "Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable" - ) + logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable") return - if auto_update_enabled(self.soul.runtime.config): + 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()) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index dd664bd8..26031991 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -251,9 +251,7 @@ def format_managed_channel_notice( 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() - ) + 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] diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 7624ec2f..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): @@ -1571,24 +1575,20 @@ class FakeCompleted: @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, 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 + (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 = SimpleNamespace(auto_update=config_value) + 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 @@ -1606,12 +1606,7 @@ def test_format_managed_channel_notice_managed(): 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 - ) + assert update.format_managed_channel_notice("0.42.0", "0.43.0", upgrade_command=["pip"]) is None @pytest.mark.asyncio diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 90bb632b..f06648f6 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -32,9 +32,7 @@ def _make_shell(runtime: Runtime, tmp_path: Path) -> Shell: @pytest.fixture def _toasts(monkeypatch): captured: list[tuple[str, dict]] = [] - monkeypatch.setattr( - shell_module, "toast", lambda msg, **kw: captured.append((msg, kw)) - ) + monkeypatch.setattr(shell_module, "toast", lambda msg, **kw: captured.append((msg, kw))) return captured From 0c96e1b6244bc9c111177235c26ed295a3b294be Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 15:17:49 -0400 Subject: [PATCH 14/15] docs(shell): note non-PythinkerSoul fallback in dispatch docstring --- src/pythinker_code/ui/shell/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index bcefdea3..79e91b8e 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2189,6 +2189,8 @@ def _schedule_startup_update_task(self) -> None: - 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") From d94fa1bf616779a82a4e804b37919bc7f60d3f7b Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 16:00:04 -0400 Subject: [PATCH 15/15] test(shell): use single import style for ui.shell in silent-update test --- tests/ui_and_conv/test_silent_auto_update.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index f06648f6..86289be5 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -14,11 +14,10 @@ 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.update import UpdateResult -def _make_shell(runtime: Runtime, tmp_path: Path) -> Shell: +def _make_shell(runtime: Runtime, tmp_path: Path) -> shell_module.Shell: agent = Agent( name="Test Agent", system_prompt="Test system prompt.", @@ -26,7 +25,7 @@ def _make_shell(runtime: Runtime, tmp_path: Path) -> Shell: runtime=runtime, ) soul = PythinkerSoul(agent, context=Context(file_backend=tmp_path / "history.jsonl")) - return Shell(soul) + return shell_module.Shell(soul) @pytest.fixture