Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- Show an update notice when a newer Pythinker version becomes available mid-session.

## 0.61.0 (2026-07-22)

- Fixed reasoning summaries exposing Markdown delimiters and duplicate terminal rows after refocus, and redesigned agent progress (single `Agent` calls and parallel `RunAgents` fan-outs) as a compact, payload-free agent activity tree.
Expand Down
67 changes: 58 additions & 9 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
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 (
AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS,
AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
MANAGED_CHANNEL_MARKER,
UpdateIntent,
UpdateResult,
Expand Down Expand Up @@ -142,6 +144,33 @@ class _PromptEvent:
"""Explicit skill/flow prefixes that should remain visible in transcript."""


async def _periodic_update_check(check: Callable[[], Awaitable[None]]) -> None:
"""Run an update check immediately and periodically until cancelled.

The check bodies re-consult the shared on-disk throttle, so this loop can
never poll GitHub faster than the throttle allows across concurrent shells.

Each attempt is bounded by a generous watchdog timeout so a single hung
check (a non-network stall the inner per-socket timeouts cannot catch) can
never block every future retry for the session; on timeout the loop logs it
distinctly and continues to the next interval.
"""
while True:
try:
async with asyncio.timeout(AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS):
await check()
except asyncio.CancelledError:
raise
except TimeoutError:
logger.warning(
"Periodic update check timed out after %ss; retrying next interval",
AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS,
)
except Exception:
logger.exception("Periodic update check failed:")
await asyncio.sleep(AUTO_UPDATE_CHECK_INTERVAL_SECONDS)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _background_idle_reminder(active_running: int) -> str:
"""Build the system-reminder injected when background tasks finish while idle.

Expand Down Expand Up @@ -597,6 +626,8 @@ def __init__(
# short TTL so the hot toolbar render path does not stat the update
# cache on every repaint (mirrors the footer's git-branch TTL).
self._update_notice_cache: tuple[float, str | None] = (0.0, None)
self._update_toast_shown_version: str | None = None
self._update_toasts_shown: set[str] = set()
self._running_input_handler: Callable[[UserInput], None] | None = None
self._running_interrupt_handler: Callable[[], None] | None = None
self._active_approval_sink: Any | None = None
Expand Down Expand Up @@ -2135,12 +2166,18 @@ def _pop_next_pending_approval_request(self) -> ApprovalRequest | None:
return None

async def _auto_update(self) -> None:
# Background-refresh the cached latest version (throttled); never blocks startup.
# A target already cached at startup is covered by the welcome-banner chip.
before = welcome_update_target()
await refresh_update_cache_if_due()
# The persistent under-input line renders the cached update hint; refresh
# it when the cache changes instead of duplicating the text as a toast.
if pending_update_notice():
self._refresh_update_notice_line()
# pending_update_notice() has applied dismissal/skip suppression.
notice = pending_update_notice()
if not notice:
return
self._refresh_update_notice_line()
target = welcome_update_target()
if target and target != before and target != self._update_toast_shown_version:
self._update_toast_shown_version = target
self._update_toast(notice, style="fg:ansibrightyellow bold")

async def _silent_auto_update(self) -> None:
"""Download and stage a newer release in the background at startup.
Expand Down Expand Up @@ -2246,6 +2283,12 @@ def _managed_channel_notice(self) -> str | None:
return format_managed_channel_notice(current_version, latest)

def _update_toast(self, notice: str, *, style: str) -> None:
# The periodic check loop re-surfaces update outcomes every interval;
# dedupe by exact notice text so each is toasted once per session
# (a new version produces new text and toasts again).
if notice in self._update_toasts_shown:
return
self._update_toasts_shown.add(notice)
toast(notice, topic="update", duration=30.0, immediate=True, style=style)
if self._prompt_session is not None:
self._prompt_session.invalidate()
Expand Down Expand Up @@ -2292,7 +2335,7 @@ def _compute_update_notice(self) -> str | None:
return text

def _schedule_startup_update_task(self) -> None:
"""Pick the startup update behavior and schedule it (non-blocking).
"""Pick the update behavior and schedule its session-lifetime loop.

- env kill-switch set → nothing (cache filters already suppress the
notice, matching today's hard-disable behavior).
Expand All @@ -2309,16 +2352,22 @@ def _schedule_startup_update_task(self) -> None:
logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable")
return
if not isinstance(self.soul, PythinkerSoul):
self._start_background_task(self._auto_update())
self._start_periodic_update_check(self._auto_update)
return
mode = resolve_auto_update_mode(self.soul.runtime.config)
if mode is AutoUpdateMode.OFF:
logger.info("Startup update task disabled by auto_update policy 'off'")
return
if mode is AutoUpdateMode.NOTIFY:
self._start_background_task(self._auto_update())
self._start_periodic_update_check(self._auto_update)
return
self._start_background_task(self._silent_auto_update())
self._start_periodic_update_check(self._silent_auto_update)

def _start_periodic_update_check(self, check: Callable[[], Awaitable[None]]) -> None:
task_coro = _periodic_update_check(check)
# Retain the concrete check name in task diagnostics and existing observers.
task_coro.__name__ = check.__name__
self._start_background_task(task_coro)

def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
task = asyncio.create_task(coro)
Expand Down
9 changes: 9 additions & 0 deletions src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@
# that is a wide margin. A throttle miss fails safe: a transient error returns
# FAILED, which skips the mark and retries next launch.
AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 30 * 60
# Backstop watchdog for a single periodic check attempt. The check's own
# per-socket timeouts already abort network stalls (and the streamed installer
# download is deliberately not capped by a total timeout so a slow link still
# completes — see `_maybe_run_native_update`), so this only prevents a
# non-network hang (stuck subprocess, trickle-forever stream) from killing the
# session-lifetime loop. Set to 2× the interval so a genuinely slow silent
# download still finishes before the watchdog fires; worst-case dead-loop
# recovery is one timeout plus one interval.
AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS = 2 * AUTO_UPDATE_CHECK_INTERVAL_SECONDS
PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0
WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
UPGRADE_COMMAND_TIMEOUT_SECONDS = 30 * 60
Expand Down
233 changes: 233 additions & 0 deletions tests/ui_and_conv/test_mid_session_update_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""Mid-session update discovery: one-time toast + periodic re-check loop."""

from __future__ import annotations

import asyncio
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


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


def _mock_update_check(monkeypatch, targets: list[str | None], notice: str | None) -> None:
async def refresh() -> None:
return None

target_values = iter(targets)
monkeypatch.setattr(shell_module, "refresh_update_cache_if_due", refresh)
monkeypatch.setattr(shell_module, "welcome_update_target", lambda: next(target_values))
monkeypatch.setattr(shell_module, "pending_update_notice", lambda: notice)


@pytest.mark.asyncio
async def test_newly_discovered_update_toasts_once(
runtime: Runtime, tmp_path: Path, monkeypatch, _toasts
):
shell = _make_shell(runtime, tmp_path)
shell._prompt_session = SimpleNamespace(invalidate=lambda: None) # type: ignore[assignment]
_mock_update_check(monkeypatch, [None, "9.9.9"], "Update available: 9.9.9")

await shell._auto_update()

assert len(_toasts) == 1
assert "9.9.9" in _toasts[0][0]
assert shell._update_toast_shown_version == "9.9.9"


@pytest.mark.asyncio
async def test_update_cached_before_refresh_does_not_toast(
runtime: Runtime, tmp_path: Path, monkeypatch, _toasts
):
shell = _make_shell(runtime, tmp_path)
_mock_update_check(monkeypatch, ["9.9.9", "9.9.9"], "Update available: 9.9.9")

await shell._auto_update()

assert _toasts == []


@pytest.mark.asyncio
async def test_repeat_discovery_of_same_version_toasts_only_once(
runtime: Runtime, tmp_path: Path, monkeypatch, _toasts
):
shell = _make_shell(runtime, tmp_path)
_mock_update_check(
monkeypatch,
[None, "9.9.9", None, "9.9.9"],
"Update available: 9.9.9",
)

await shell._auto_update()
await shell._auto_update()

assert len(_toasts) == 1


@pytest.mark.asyncio
async def test_suppressed_notice_does_not_toast(
runtime: Runtime, tmp_path: Path, monkeypatch, _toasts
):
shell = _make_shell(runtime, tmp_path)
_mock_update_check(monkeypatch, [None, "9.9.9"], None)

await shell._auto_update()

assert _toasts == []
assert shell._update_toast_shown_version is None


def test_update_toast_dedupes_repeated_notices(runtime: Runtime, tmp_path: Path, _toasts):
"""The periodic loop re-surfaces outcomes; each notice text toasts once."""
shell = _make_shell(runtime, tmp_path)

shell._update_toast("Update available: 9.9.9", style="bold")
shell._update_toast("Update available: 9.9.9", style="bold")
shell._update_toast("Update available: 10.0.0", style="bold")

assert [msg for msg, _ in _toasts] == [
"Update available: 9.9.9",
"Update available: 10.0.0",
]


@pytest.mark.asyncio
async def test_periodic_update_check_runs_immediately_then_at_interval(monkeypatch):
checks: list[str] = []
sleeps: list[float] = []

async def check() -> None:
checks.append("check")

async def sleep(delay: float) -> None:
sleeps.append(delay)
if len(sleeps) == 2:
raise asyncio.CancelledError

monkeypatch.setattr(shell_module.asyncio, "sleep", sleep)

with pytest.raises(asyncio.CancelledError):
await shell_module._periodic_update_check(check)

assert checks == ["check", "check"]
assert sleeps == [
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
]


@pytest.mark.asyncio
async def test_periodic_update_check_recovers_after_failing_check(monkeypatch):
checks: list[str] = []
sleeps: list[float] = []

async def check() -> None:
checks.append("check")
if len(checks) == 1:
raise RuntimeError("transient check failure")

async def sleep(delay: float) -> None:
sleeps.append(delay)
if len(sleeps) == 2:
raise asyncio.CancelledError

monkeypatch.setattr(shell_module.asyncio, "sleep", sleep)

with pytest.raises(asyncio.CancelledError):
await shell_module._periodic_update_check(check)

# The first failure is swallowed (logged) and the loop keeps re-checking.
assert checks == ["check", "check"]
assert sleeps == [
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
]


@pytest.mark.asyncio
async def test_periodic_update_check_recovers_after_timed_out_check(monkeypatch):
checks: list[str] = []
sleeps: list[float] = []

async def check() -> None:
checks.append("check")
if len(checks) == 1:
await asyncio.Event().wait() # hang until the watchdog fires

async def sleep(delay: float) -> None:
sleeps.append(delay)
if len(sleeps) == 2:
raise asyncio.CancelledError

# asyncio.timeout drives off the event loop clock, not asyncio.sleep, so a
# tiny real timeout fires while the patched sleep only records intervals.
monkeypatch.setattr(shell_module.asyncio, "sleep", sleep)
monkeypatch.setattr(shell_module, "AUTO_UPDATE_CHECK_ATTEMPT_TIMEOUT_SECONDS", 0.01)

with pytest.raises(asyncio.CancelledError):
await shell_module._periodic_update_check(check)

# The hung first attempt is bounded by the watchdog; the loop logs the
# timeout and continues to the next interval instead of dying.
assert checks == ["check", "check"]
assert sleeps == [
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
shell_module.AUTO_UPDATE_CHECK_INTERVAL_SECONDS,
]


def test_startup_scheduler_uses_periodic_loop_in_all_scheduling_branches(
runtime: Runtime, tmp_path: Path, monkeypatch
):
from pythinker_code.config import AutoUpdateMode

shell = _make_shell(runtime, tmp_path)
scheduled_names: list[str] = []

def capture(coro):
# The scheduler wraps each check in the periodic loop but deliberately
# retains the concrete check's __name__ for task diagnostics/observers
# (see `_start_periodic_update_check`). Assert on that documented name
# at the scheduling seam instead of mocking the private loop wiring;
# fully removing the seam would require exercising real update I/O.
scheduled_names.append(coro.__name__)
coro.close()
return None

monkeypatch.delenv("PYTHINKER_CLI_NO_AUTO_UPDATE", raising=False)
monkeypatch.setattr(shell, "_start_background_task", capture)

with monkeypatch.context() as context:
context.setattr(shell_module, "PythinkerSoul", type("OtherSoul", (), {}))
shell._schedule_startup_update_task()
monkeypatch.setattr(shell_module, "resolve_auto_update_mode", lambda cfg: AutoUpdateMode.NOTIFY)
shell._schedule_startup_update_task()
monkeypatch.setattr(
shell_module, "resolve_auto_update_mode", lambda cfg: AutoUpdateMode.DOWNLOAD
)
shell._schedule_startup_update_task()

assert scheduled_names == ["_auto_update", "_auto_update", "_silent_auto_update"]
Loading