From 88284c53dd16007f3ed46218936a9d33d2667a42 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 18:39:39 +0800 Subject: [PATCH] emrg: invalidate usage anchor on mid-session model/provider switch Fix #1000: _handle_set_model switched llm.config.model but left the per-session usage anchors holding the OLD model's real prompt_tokens. The auto-compact projection (real_anchor + est_delta) then carried a stale tokenizer baseline into the new model's rounds, re-introducing the #946 underestimation (148K est vs 222K real) on the first post-switch round. Now: when the API model actually changes, drop all anchors and mark the drops deliberate (mirror of the post-compact drop) so the fail-LOUD anchor-loss warning does not false-positive; the next round re-anchors from the new provider. +2 tests, Agent.md count 1102->1104. --- Agent.md | 2 +- emrg/server/daemon.py | 20 +++++++++++++++++ tests/test_daemon.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index 402e09c4..872626c6 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1102) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1104) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (151 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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/emrg/server/daemon.py b/emrg/server/daemon.py index 41b924e6..995aaa91 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -3526,6 +3526,26 @@ async def _handle_set_model( old_model, model_name, api_model, old_ctx, self.llm.config.context_window, ) + # Community feedback 2026-08-26T10:21:46 (#1000): a mid-session model + # switch invalidates every usage anchor — each anchored real + # prompt_tokens came from the OLD model's tokenizer, so carrying it + # into the new model's rounds (projected = real_old + est_delta) + # re-introduces the #946 underestimation (148K est vs 222K real) on + # the first post-switch round. Drop all anchors and mark the drops + # deliberate so the fail-LOUD anchor-loss warning does not + # false-positive (mirror of the post-compact drop, PR #948); the + # next round re-anchors from the new provider. + if api_model != old_model and self._usage_anchors: + anchor_count = len(self._usage_anchors) + for sid in self._usage_anchors: + self._usage_anchor_dropped_by_compact.add(sid) + self._usage_anchors.clear() + logger.info( + "model switch: invalidated %d usage anchor(s) (stale %s " + "baseline; next round re-anchors from %s)", + anchor_count, old_model, api_model, + ) + await self._send(ws, { "type": "model_set", "model": model_name, diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d18cc325..5b8b6555 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -602,6 +602,56 @@ def test_manual_compact_drop_marks_anchor(caplog): assert sid not in server._usage_anchor_dropped_by_compact # consumed +class _FakeWs: + """Minimal websocket stand-in with an async send() that records payloads.""" + + def __init__(self): + self.sent: list[str] = [] + + async def send(self, text: str) -> None: + self.sent.append(text) + + +def test_model_switch_invalidates_usage_anchor(): + """A mid-session model switch must drop every stale usage anchor and mark + the drops deliberate (#1000 — the anchored real prompt_tokens came from + the OLD model's tokenizer; carrying it into the new model's rounds + re-introduces the #946 148K-vs-222K underestimation on the first + post-switch round).""" + server = _make_server() + server.llm.config.model = "api-old" + server.llm.config.models = [{"name": "model-x", "model": "api-x"}] + server._usage_anchors["s1"] = (222_000, 148_000) + server._usage_anchors["s2"] = (100_000, 70_000) + + asyncio.run(server._handle_set_model("model-x", _FakeWs())) + + # All anchors dropped; deliberate-drop markers set so the fail-LOUD + # anchor-loss warning does not false-positive on the next round + assert server._usage_anchors == {} + assert "s1" in server._usage_anchor_dropped_by_compact + assert "s2" in server._usage_anchor_dropped_by_compact + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + server._warn_missing_usage_anchor(_SidSession("s1"), messages, 50_000) + assert "s1" not in server._usage_anchor_dropped_by_compact # consumed + assert server.llm.config.model == "api-x" + + +def test_model_switch_same_model_keeps_anchor(): + """Re-selecting the current model is a no-op — anchors must survive.""" + server = _make_server() + server.llm.config.model = "api-x" + server.llm.config.models = [{"name": "model-x", "model": "api-x"}] + server._usage_anchors["s1"] = (222_000, 148_000) + + asyncio.run(server._handle_set_model("model-x", _FakeWs())) + + assert server._usage_anchors == {"s1": (222_000, 148_000)} + + # ── usage-anchor countable stats (community feedback 2026-08-26T07:18:29) ──