diff --git a/Agent.md b/Agent.md
index 9b5e01c7..f9439f0f 100644
--- a/Agent.md
+++ b/Agent.md
@@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```
-Python: `uv run pytest tests/ -v` (652) — import check: `uv run python -c "from emrg.client.app import run_client"`
+Python: `uv run pytest tests/ -v` (670) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (107: 29 daemon_client + 22 app-commands + 31 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
diff --git a/README.cn.md b/README.cn.md
index a22b5d77..c2c8ede7 100644
--- a/README.cn.md
+++ b/README.cn.md
@@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
-uv run pytest tests/ -v # 跑测试(当前 652 项)
+uv run pytest tests/ -v # 跑测试(当前 670 项)
uv run python -m emrg # 启动 TUI
# CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败
diff --git a/README.md b/README.md
index e376e78f..7ddfc00e 100644
--- a/README.md
+++ b/README.md
@@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own.
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # install deps
-uv run pytest tests/ -v # run tests (currently 652 items)
+uv run pytest tests/ -v # run tests (currently 670 items)
uv run python -m emrg # launch TUI
# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI
diff --git a/emrg/client/app.py b/emrg/client/app.py
index f2aeea4d..d83189ad 100644
--- a/emrg/client/app.py
+++ b/emrg/client/app.py
@@ -356,6 +356,13 @@ async def _reconnect():
import emrg
ver = getattr(emrg, "__version__", "dev")
chat.add("system", f"EMRG {ver} | {server_id}\nType /help for shortcuts, or just start chatting.")
+ # Auto update-check prompt (rant 2026-08-10T07:12:12):
+ # one-time, non-blocking — query daemon's cached latest
+ # release, show a status line, mark prompted (idempotent).
+ try:
+ await conn.send_command("update_check")
+ except Exception:
+ pass # never block chat on update check
status.update(left=_status_left(session_title, session_id), center=server_id)
term.set_title(f"{session_title or session_id} @ {project_name}")
term.render(); continue
@@ -906,6 +913,29 @@ async def _reconnect():
continue
# Memory content (read)
+ if data.get("type") == "update_check":
+ # Auto update-check prompt (rant 2026-08-10T07:12:12):
+ # one-time, non-blocking status line; idempotent per version.
+ if data.get("has_update") and data.get("enabled"):
+ latest = data.get("latest_version", "")
+ prompted = data.get("prompted_version", "")
+ if latest and latest != prompted:
+ import emrg
+ ver = getattr(emrg, "__version__", "dev")
+ chat.add("system",
+ f"New version v{latest} available (current v{ver}) — "
+ f"https://github.com/argszero/emrg/releases")
+ status.update(center=server_id or "emrg")
+ try:
+ await conn.send_command(
+ "update_check_prompted",
+ {"version": latest},
+ )
+ except Exception:
+ pass
+ term.render()
+ continue
+
if data.get("type") == "memory_content":
err = data.get("error", "")
if err:
diff --git a/emrg/config.py b/emrg/config.py
index 2ed6efb0..9e57396a 100644
--- a/emrg/config.py
+++ b/emrg/config.py
@@ -37,9 +37,23 @@ class LlmConfig:
stream_options: Optional[dict] = field(default_factory=lambda: {"include_usage": False})
+@dataclass
+class UpdateConfig:
+ """Auto update-check settings (rant 2026-08-10T07:12:12).
+
+ check: master switch — when false, the daemon never queries GitHub.
+ ttl_hours: how often to re-check the latest release (default 24h).
+ Prompting is always display-only (no auto download/install).
+ """
+
+ check: bool = True
+ ttl_hours: int = 24
+
+
@dataclass
class EmrgConfig:
llm: LlmConfig = field(default_factory=LlmConfig)
+ update: UpdateConfig = field(default_factory=UpdateConfig)
def config_dir() -> Path:
@@ -92,7 +106,34 @@ def load_config() -> EmrgConfig:
var_name = llm.api_key[2:-1]
llm.api_key = os.environ.get(var_name, llm.api_key)
- return EmrgConfig(llm=llm)
+ update_data = data.get("update", {})
+ update = UpdateConfig(
+ check=update_data.get("check", True),
+ ttl_hours=update_data.get("ttl_hours", 24),
+ )
+
+ return EmrgConfig(llm=llm, update=update)
+
+
+def load_update_config() -> UpdateConfig:
+ """Load only the [update] section (rant 2026-08-10T07:12:12).
+
+ The daemon is constructed with just LlmConfig; this helper lets it read
+ the update-check switch without parsing the full config. Missing config
+ file or missing section → defaults (check=True, ttl=24h).
+ """
+ cfg_path = config_path()
+ if not cfg_path.exists():
+ return UpdateConfig()
+ try:
+ data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
+ except (OSError, tomllib.TOMLDecodeError):
+ return UpdateConfig()
+ update_data = data.get("update", {})
+ return UpdateConfig(
+ check=update_data.get("check", True),
+ ttl_hours=update_data.get("ttl_hours", 24),
+ )
def ensure_config() -> None:
diff --git a/emrg/gui/main.js b/emrg/gui/main.js
index 7b211ab7..4735f26d 100644
--- a/emrg/gui/main.js
+++ b/emrg/gui/main.js
@@ -459,6 +459,32 @@ vision = false
return { authenticated: Boolean(frame.authenticated), user: frame.user || null };
});
+ ipcMain.handle("emrg:updateCheck", async () => {
+ // Auto update-check prompt (rant 2026-08-10T07:12:12): query daemon's
+ // cached latest release; display-only, no auto download/install.
+ try {
+ const frame = await client.sendCommandAndWait("update_check", {}, 10000);
+ return {
+ current_version: frame.current_version || "",
+ latest_version: frame.latest_version || "",
+ has_update: Boolean(frame.has_update),
+ prompted_version: frame.prompted_version || "",
+ enabled: Boolean(frame.enabled),
+ };
+ } catch {
+ return { has_update: false, enabled: false, latest_version: "", current_version: "" };
+ }
+ });
+
+ ipcMain.handle("emrg:updateCheckPrompted", async (_e, { version }) => {
+ // Idempotency (rant 07:12:12 §4): record that the GUI showed the prompt
+ // for this version — same version never re-prompted.
+ try {
+ await client.sendCommandAndWait("update_check_prompted", { version: String(version || "") }, 5000);
+ } catch { /* best-effort */ }
+ return { ok: true };
+ });
+
ipcMain.handle("emrg:githubConnect", async (_e, { token }) => {
// Windows GCM rant Stage 2:PAT 授权 + setup-git(daemon github_connect)
const frame = await client.sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000);
diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js
index 1ccc6678..3a076d97 100644
--- a/emrg/gui/preload.js
+++ b/emrg/gui/preload.js
@@ -28,6 +28,8 @@ const api = {
sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload),
evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload),
githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"),
+ updateCheck: () => ipcRenderer.invoke("emrg:updateCheck"),
+ updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload),
githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload),
githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"),
githubConnectWeb: () => ipcRenderer.invoke("emrg:githubConnectWeb"),
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index e69ff749..d6341d0a 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -164,6 +164,7 @@
设置
关于
EMRG v0.2.8 · 🌱 已自我进化 0 次
+
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js
index f9c759b1..91f07dbc 100644
--- a/emrg/gui/renderer/js/dialogs.js
+++ b/emrg/gui/renderer/js/dialogs.js
@@ -232,6 +232,9 @@ const Dialogs = (() => {
} catch { /* 元素缺失(测试桩)时忽略 */ }
// Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新
await refreshGithubStatus();
+ // Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows
+ // a one-time non-intrusive line when a newer release exists.
+ await refreshUpdateCheck();
} catch (e) {
Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message }));
}
@@ -485,6 +488,39 @@ const Dialogs = (() => {
}
}
+ // Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one
+ // line in the about area, never a modal — no auto download/install.
+ async function refreshUpdateCheck() {
+ const el = $("about-update");
+ if (!el) return; // 元素缺失(测试桩)时忽略
+ try {
+ const u = await window.emrg.updateCheck();
+ if (!u || !u.enabled || !u.has_update || !u.latest_version) {
+ el.classList.add("hidden");
+ el.textContent = "";
+ return;
+ }
+ if (u.latest_version === u.prompted_version) {
+ el.classList.add("hidden");
+ el.textContent = "";
+ return;
+ }
+ const link = el("a", {
+ href: "https://github.com/argszero/emrg/releases",
+ target: "_blank",
+ rel: "noopener",
+ }, _t("settings.updateAvailable", { latest: u.latest_version }));
+ el.textContent = "";
+ el.appendChild(link);
+ el.classList.remove("hidden");
+ // 幂等:同版本只提示一次
+ try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ }
+ } catch {
+ el.classList.add("hidden");
+ el.textContent = "";
+ }
+ }
+
// ── 确认对话框(替代 confirm/alert) ────
let confirmCb = null;
function showConfirm(title, message, opts = {}) {
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index 9039cbec..093037dc 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -89,6 +89,7 @@ const I18N = (() => {
"settings.githubConnectedStatus": "已连接 @{user}",
"settings.githubNotConnected": "未连接",
"settings.githubStatusFailed": "状态获取失败",
+ "settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)",
"settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token",
"settings.githubConnecting": "连接中…",
"settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)",
@@ -380,6 +381,7 @@ const I18N = (() => {
"settings.githubConnectedStatus": "Connected as @{user}",
"settings.githubNotConnected": "Not connected",
"settings.githubStatusFailed": "Failed to load status",
+ "settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)",
"settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first",
"settings.githubConnecting": "Connecting…",
"settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)",
diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py
index 82453d02..2f84ca84 100644
--- a/emrg/server/daemon.py
+++ b/emrg/server/daemon.py
@@ -292,12 +292,19 @@ async def serve(self) -> None:
# host-modified skill copies.
self._skills_ttl_task = asyncio.create_task(self._skills_ttl_loop())
+ # Auto update-check prompt (rant 2026-08-10T07:12:12): runs at startup
+ # + every [update] ttl_hours (default 24h). ONLY checks the latest
+ # release and persists state — clients (TUI/GUI) decide how to show
+ # the one-time prompt. No auto download/install, silent on failure.
+ self._update_check_task = asyncio.create_task(self._update_check_loop())
+
try:
await self._server.serve_forever()
except asyncio.CancelledError:
pass
finally:
self._skills_ttl_task.cancel()
+ self._update_check_task.cancel()
try:
await self._skills_ttl_task
except (asyncio.CancelledError, Exception):
@@ -369,6 +376,38 @@ async def _skills_ttl_loop(self) -> None:
logger.warning("skills update errors: %s", result["errors"])
await asyncio.sleep(_UPDATE_TTL_SECONDS)
+ async def _update_check_loop(self) -> None:
+ """Background auto update-check prompt (rant 2026-08-10T07:12:12).
+
+ Runs at startup + every [update] ttl_hours (default 24h). ONLY checks
+ the latest release via api.github.com and persists state to
+ ~/.emrg/.last_update_check.json — no auto download/install. Failures
+ are silent (never crash, never log noise); the next TTL retries.
+ Disabled entirely when [update] check = false in config.toml.
+ """
+ from emrg.config import load_update_config
+ from emrg.update_check import (
+ load_state,
+ run_update_check_once,
+ should_check,
+ )
+
+ update_cfg = load_update_config()
+ if not update_cfg.check:
+ logger.debug("auto update-check disabled by config ([update] check=false)")
+ return
+
+ ttl = max(3600, int(update_cfg.ttl_hours or 24) * 3600)
+ while True:
+ state = load_state()
+ if should_check(state, ttl):
+ result = await run_update_check_once()
+ if result.get("checked"):
+ logger.debug(
+ "update check: latest=%s", result.get("latest_version")
+ )
+ await asyncio.sleep(ttl)
+
def _evolution_count(self) -> int:
"""Total completed evolution cycles across scheduler handlers + disk.
@@ -1354,6 +1393,41 @@ async def _process_message(
auth = await self._check_github_auth()
await self._send(ws, {"type": "github_status", **auth})
+ elif msg_type == "update_check":
+ # Auto update-check prompt (rant 2026-08-10T07:12:12): TUI/GUI
+ # query the daemon's latest known release. Returns the cached
+ # latest_version (populated at startup + every TTL) plus a
+ # has_update flag computed against the running version. No auto
+ # download/install; the client shows a one-time prompt.
+ import emrg
+ from emrg.config import load_update_config
+ from emrg.update_check import is_newer, load_state, parse_version
+
+ current = getattr(emrg, "__version__", "0")
+ state = load_state()
+ latest = state.get("latest_version") or ""
+ has_update = bool(
+ latest and is_newer(parse_version(latest), parse_version(current))
+ )
+ await self._send(ws, {
+ "type": "update_check",
+ "current_version": current,
+ "latest_version": latest,
+ "has_update": has_update,
+ "prompted_version": state.get("prompted_version") or "",
+ "enabled": load_update_config().check,
+ })
+
+ elif msg_type == "update_check_prompted":
+ # Idempotency (rant 07:12:12 §4): a client records that it showed
+ # the prompt for this version — same version never re-prompted.
+ from emrg.update_check import load_state, mark_prompted
+
+ version = msg.get("version", "")
+ if version:
+ mark_prompted(load_state(), version)
+ await self._send(ws, {"type": "update_check_prompted", "ok": True})
+
elif msg_type == "github_connect":
# Windows GCM rant Stage 2: GUI PAT auth — gh auth login
# --with-token + gh auth setup-git (git no longer touches GCM).
diff --git a/emrg/update_check.py b/emrg/update_check.py
new file mode 100644
index 00000000..6abf12eb
--- /dev/null
+++ b/emrg/update_check.py
@@ -0,0 +1,150 @@
+"""Automatic update-check + notify (rant 2026-08-10T07:12:12).
+
+Design (host-specified boundary): ONLY check for new versions and PROMPT —
+never auto-download, never auto-install, never start an installer flow.
+The prompt is lightweight and non-intrusive (TUI status line / GUI settings
+about area), one prompt per version (idempotent via state file), silent on
+network failure (retry at next TTL).
+
+Check source: api.github.com (github.com:443 / raw.githubusercontent.com are
+blocked on the host network — see git_utils / skills installer patterns).
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Optional
+
+import httpx
+
+from emrg.config import config_dir
+
+# Default TTL between checks (seconds). Host-configurable via [update] ttl_hours.
+DEFAULT_TTL_SECONDS = 24 * 3600
+# API endpoint — releases/latest never includes prereleases (semver-satisfying).
+RELEASES_LATEST_URL = "https://api.github.com/repos/argszero/emrg/releases/latest"
+CHECK_TIMEOUT_SECONDS = 10.0
+
+STATE_FILE_NAME = ".last_update_check.json"
+
+
+def parse_version(tag: str) -> tuple:
+ """Parse a version tag like 'v0.2.18' into a numeric tuple for comparison.
+
+ Only dot-separated pieces that are entirely digits are kept; the first
+ piece with any non-digit (prerelease/build suffix like '-beta1' or
+ '18-rc.2') terminates parsing — prerelease tags can never compare as
+ newer than a released version. Unparseable input → () (never newer).
+ """
+ if not tag:
+ return ()
+ s = tag.strip()
+ if s.startswith("v"):
+ s = s[1:]
+ parts = []
+ for piece in s.split("."):
+ if piece.isdigit():
+ parts.append(int(piece))
+ else:
+ break # prerelease/build suffix — stop, drop the rest
+ return tuple(parts)
+
+
+def is_newer(latest: tuple, current: tuple) -> bool:
+ """True iff latest > current (pure tuple comparison)."""
+ return bool(latest) and latest > current
+
+
+# ── State file (~/.emrg/.last_update_check.json) ──────────────────────────
+# {checked_at: float epoch, latest_version: "0.2.18"|None,
+# prompted_version: "0.2.18"|None}
+# - checked_at: last successful check timestamp (TTL gate)
+# - latest_version: last known latest from GitHub
+# - prompted_version: the version for which a prompt was already shown
+# (idempotency: same version is only prompted once)
+
+
+def state_path() -> Path:
+ return config_dir() / STATE_FILE_NAME
+
+
+def load_state() -> dict:
+ try:
+ data = json.loads(state_path().read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ return data
+ except (OSError, json.JSONDecodeError):
+ pass
+ return {}
+
+
+def save_state(state: dict) -> None:
+ try:
+ state_path().write_text(
+ json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ except OSError:
+ pass # state file is best-effort — never crash on write failure
+
+
+def should_check(state: dict, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> bool:
+ """True iff a check is due (no record, or last check older than TTL)."""
+ checked_at = state.get("checked_at")
+ if not isinstance(checked_at, (int, float)):
+ return True
+ return (time.time() - checked_at) >= ttl_seconds
+
+
+def should_prompt(state: dict, latest_version: str, current_version: str) -> bool:
+ """True iff a prompt should be shown for this version (idempotent).
+
+ Conditions: latest is parseable and newer than current, AND this exact
+ version was not already prompted before.
+ """
+ if not latest_version:
+ return False
+ if not is_newer(parse_version(latest_version), parse_version(current_version)):
+ return False
+ return state.get("prompted_version") != latest_version
+
+
+def mark_prompted(state: dict, version: str) -> dict:
+ """Record that a prompt was shown for `version` (mutates + persists)."""
+ state["prompted_version"] = version
+ save_state(state)
+ return state
+
+
+async def check_latest_version(timeout: float = CHECK_TIMEOUT_SECONDS) -> Optional[str]:
+ """Fetch the latest release tag from api.github.com.
+
+ Returns the tag_name (e.g. '0.2.18') or None on ANY failure — silent,
+ never raises, never logs noise. The caller retries at the next TTL.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
+ resp = await client.get(RELEASES_LATEST_URL)
+ if resp.status_code != 200:
+ return None
+ data = resp.json()
+ tag = data.get("tag_name") or ""
+ return tag.lstrip("v") if tag else None
+ except Exception:
+ return None
+
+
+async def run_update_check_once(state: Optional[dict] = None) -> dict:
+ """One deterministic check cycle: fetch latest, persist state, return result.
+
+ Never raises. Used by the daemon background loop and directly testable.
+ """
+ state = state if state is not None else load_state()
+ latest = await check_latest_version()
+ if latest is None:
+ return {"checked": False, "latest_version": None, "state": state}
+ state["checked_at"] = time.time()
+ state["latest_version"] = latest
+ save_state(state)
+ return {"checked": True, "latest_version": latest, "state": state}
diff --git a/tests/test_update_check.py b/tests/test_update_check.py
new file mode 100644
index 00000000..d0d69fd0
--- /dev/null
+++ b/tests/test_update_check.py
@@ -0,0 +1,230 @@
+"""Auto update-check prompt tests (rant 2026-08-10T07:12:12).
+
+Covers the host-specified acceptance items:
+- version comparison (0.2.17 < 0.2.18)
+- TTL logic (not expired → no check)
+- prompt idempotency (same version not repeated)
+- silent failure on network/API errors (no raise, next TTL retries)
+- [update] check=false disables the daemon loop
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from emrg.update_check import (
+ DEFAULT_TTL_SECONDS,
+ check_latest_version,
+ is_newer,
+ load_state,
+ mark_prompted,
+ parse_version,
+ run_update_check_once,
+ save_state,
+ should_check,
+ should_prompt,
+ state_path,
+)
+
+
+# ── version comparison ────────────────────────────────────────────────────
+
+def test_parse_version_basic():
+ assert parse_version("v0.2.18") == (0, 2, 18)
+ assert parse_version("0.2.17") == (0, 2, 17)
+ assert parse_version("v1.0") == (1, 0)
+ assert parse_version("") == ()
+
+
+def test_parse_version_drops_prerelease_suffix():
+ # releases/latest never returns prereleases, but the parser is defensive:
+ # a prerelease tag terminates at the non-numeric piece → truncated tuple
+ # can never compare as NEWER than a released version (safe semantic).
+ assert parse_version("v0.2.17-beta1") == (0, 2)
+ assert parse_version("0.2.18-rc.2") == (0, 2)
+ # prerelease must never trigger a "new version" prompt
+ assert is_newer(parse_version("0.2.18-rc.2"), parse_version("0.2.17")) is False
+
+
+def test_is_newer():
+ assert is_newer((0, 2, 18), (0, 2, 17)) is True
+ assert is_newer((0, 2, 17), (0, 2, 18)) is False
+ assert is_newer((0, 2, 17), (0, 2, 17)) is False
+ assert is_newer((), (0, 2, 17)) is False # unparseable never newer
+
+
+def test_should_prompt_acceptance():
+ # 0.2.17 current, latest 0.2.18, not yet prompted → prompt
+ state = {}
+ assert should_prompt(state, "0.2.18", "0.2.17") is True
+ # same version already prompted → no repeat (idempotency)
+ state = {"prompted_version": "0.2.18"}
+ assert should_prompt(state, "0.2.18", "0.2.17") is False
+ # up to date → no prompt
+ assert should_prompt({}, "0.2.17", "0.2.17") is False
+ # no latest → no prompt
+ assert should_prompt({}, "", "0.2.17") is False
+
+
+# ── TTL logic ─────────────────────────────────────────────────────────────
+
+def test_should_check_missing_state_returns_true():
+ assert should_check({}) is True
+
+
+def test_should_check_fresh_state_returns_false():
+ state = {"checked_at": time.time()}
+ assert should_check(state, DEFAULT_TTL_SECONDS) is False
+
+
+def test_should_check_stale_state_returns_true():
+ state = {"checked_at": time.time() - DEFAULT_TTL_SECONDS - 1}
+ assert should_check(state, DEFAULT_TTL_SECONDS) is True
+
+
+def test_should_check_custom_ttl():
+ state = {"checked_at": time.time() - 5000}
+ assert should_check(state, 3600) is True
+ assert should_check(state, 10000) is False
+
+
+# ── state file ────────────────────────────────────────────────────────────
+
+def test_state_roundtrip(tmp_path, monkeypatch):
+ from emrg import config as config_mod
+
+ monkeypatch.setattr(config_mod, "config_dir", lambda: tmp_path)
+ # re-read the module-level state_path binding
+ from emrg import update_check as uc
+
+ monkeypatch.setattr(uc, "state_path", lambda: tmp_path / ".last_update_check.json")
+ save_state({"checked_at": 123.0, "latest_version": "0.2.18"})
+ assert load_state() == {"checked_at": 123.0, "latest_version": "0.2.18"}
+
+
+def test_load_state_missing_file(tmp_path, monkeypatch):
+ from emrg import update_check as uc
+
+ monkeypatch.setattr(uc, "state_path", lambda: tmp_path / "nope.json")
+ assert load_state() == {}
+
+
+def test_load_state_corrupt_file(tmp_path, monkeypatch):
+ from emrg import update_check as uc
+
+ p = tmp_path / "bad.json"
+ p.write_text("{not json", encoding="utf-8")
+ monkeypatch.setattr(uc, "state_path", lambda: p)
+ assert load_state() == {}
+
+
+def test_mark_prompted_persists(tmp_path, monkeypatch):
+ from emrg import update_check as uc
+
+ p = tmp_path / "state.json"
+ monkeypatch.setattr(uc, "state_path", lambda: p)
+ state = {"latest_version": "0.2.18"}
+ out = mark_prompted(state, "0.2.18")
+ assert out["prompted_version"] == "0.2.18"
+ assert json.loads(p.read_text(encoding="utf-8"))["prompted_version"] == "0.2.18"
+
+
+# ── network check ─────────────────────────────────────────────────────────
+
+def test_check_latest_version_success():
+ async def run():
+ class _Resp:
+ status_code = 200
+
+ def json(self):
+ return {"tag_name": "v0.2.18"}
+
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=_Resp())
+ client.__aenter__ = AsyncMock(return_value=client)
+ with patch(
+ "emrg.update_check.httpx.AsyncClient",
+ return_value=client,
+ ):
+ return await check_latest_version()
+
+ assert asyncio.run(run()) == "0.2.18"
+
+
+def test_check_latest_version_http_error_returns_none():
+ async def run():
+ class _Resp:
+ status_code = 500
+
+ def json(self):
+ return {}
+
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=_Resp())
+ client.__aenter__ = AsyncMock(return_value=client)
+ with patch(
+ "emrg.update_check.httpx.AsyncClient",
+ return_value=client,
+ ):
+ return await check_latest_version()
+
+ assert asyncio.run(run()) is None # silent, no raise
+
+
+def test_check_latest_version_network_error_returns_none():
+ async def run():
+ with patch(
+ "emrg.update_check.httpx.AsyncClient",
+ side_effect=OSError("network unreachable"),
+ ):
+ return await check_latest_version()
+
+ assert asyncio.run(run()) is None # silent, no raise
+
+
+def test_run_update_check_once_failure_preserves_state(tmp_path, monkeypatch):
+ from emrg import update_check as uc
+
+ p = tmp_path / "state.json"
+ monkeypatch.setattr(uc, "state_path", lambda: p)
+ with patch.object(uc, "check_latest_version", AsyncMock(return_value=None)):
+ result = asyncio.run(uc.run_update_check_once({}))
+ assert result["checked"] is False
+ assert result["latest_version"] is None
+ # failure must NOT persist a checked_at (next TTL retries immediately)
+ assert not p.exists()
+
+
+def test_run_update_check_once_success_persists(tmp_path, monkeypatch):
+ from emrg import update_check as uc
+
+ p = tmp_path / "state.json"
+ monkeypatch.setattr(uc, "state_path", lambda: p)
+ with patch.object(uc, "check_latest_version", AsyncMock(return_value="0.2.18")):
+ result = asyncio.run(uc.run_update_check_once({}))
+ assert result["checked"] is True
+ assert result["latest_version"] == "0.2.18"
+ saved = json.loads(p.read_text(encoding="utf-8"))
+ assert saved["latest_version"] == "0.2.18"
+ assert "checked_at" in saved
+
+
+# ── daemon loop disable (config [update] check=false) ─────────────────────
+
+def test_update_check_loop_returns_when_disabled():
+ """config [update] check=false → loop exits immediately (no network)."""
+ import emrg.server.daemon as daemon_mod
+
+ async def run():
+ server = daemon_mod.EmrgServer.__new__(daemon_mod.EmrgServer)
+ with patch("emrg.config.load_update_config") as mock_cfg:
+ mock_cfg.return_value.check = False
+ await server._update_check_loop()
+ return True
+
+ assert asyncio.run(run()) is True