diff --git a/Agent.md b/Agent.md index 402e09c4..3bda420c 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` (1103) — 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..314624b0 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2983,9 +2983,10 @@ def _warn_missing_usage_anchor( # First round: no assistant turn yet — nothing anchored, normal. if not any(m.get("role") == "assistant" for m in messages): return - # Post-compact re-anchor round: anchor deliberately dropped; consume - # the marker so a SECOND consecutive anchor-less round (provider - # still silent) warns on the next round. + # Deliberate drop (post-compact, manual compact, or mid-session model + # switch — issue #1000): anchor intentionally invalidated; consume the + # marker so a SECOND consecutive anchor-less round (provider still + # silent) warns on the next round. if session.session_id in self._usage_anchor_dropped_by_compact: self._usage_anchor_dropped_by_compact.discard(session.session_id) return @@ -3521,6 +3522,29 @@ async def _handle_set_model( if new_vision is not None: self.llm.config.vision = new_vision + # Issue #1000 (Dev.to community finding): a usage anchor's base is the + # OLD model's real prompt_tokens for the historical context. Different + # models tokenize the same context differently (the 148K-est-vs-222K- + # real tokenizer variance is model-specific), so after a mid-session + # model/provider switch a stale base drifts the projection and can + # mis-fire the auto-compact gate (undercount → overflow risk, or + # overcount → premature compact). Drop ALL anchors (model is global + # daemon state) and mark the drops as deliberate — same pattern as the + # post-compact drop — so the next round re-anchors from the NEW + # model's real usage and the fail-LOUD missing-anchor warning does not + # false-fire. + if api_model != old_model: + dropped = len(self._usage_anchors) + for sid in list(self._usage_anchors.keys()): + self._usage_anchors.pop(sid, None) + self._usage_anchor_dropped_by_compact.add(sid) + if dropped: + logger.info( + "model switch %s → %s: dropped %d stale usage anchor(s) " + "(issue #1000 — tokenizer base is model-specific)", + old_model, api_model, dropped, + ) + logger.info( "model switched: %s → %s (api=%s, context_window: %d → %d)", old_model, model_name, api_model, old_ctx, self.llm.config.context_window, diff --git a/tests/test_daemon.py b/tests/test_daemon.py index d18cc325..a4785945 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -602,6 +602,46 @@ def test_manual_compact_drop_marks_anchor(caplog): assert sid not in server._usage_anchor_dropped_by_compact # consumed +def test_model_switch_drops_stale_usage_anchors(caplog): + """Issue #1000 (Dev.to community finding): a usage anchor's base is the + OLD model's real prompt_tokens — the tokenizer base is model-specific + (148K est vs 222K real variance differs per model). A mid-session model + switch must drop ALL anchors + mark the deliberate drop so the next round + re-anchors from the NEW model's real usage: no stale-base projection + drift, no false-positive fail-LOUD missing-anchor warning.""" + import asyncio + + server = _make_server() + sid = "model-switch-test" + # Stale anchor from the OLD model's real prompt_tokens + server._usage_anchors[sid] = (222_000, 148_000) + server.llm.config.model = "gpt-4o" + server.llm.config.models = [ + {"name": "deepseek", "model": "deepseek-chat", "context_window": 128000} + ] + writer = _FakeWriter() + + # Actual model switch → anchors dropped + deliberate-drop marked + asyncio.run(server._handle_set_model("deepseek", writer)) + assert sid not in server._usage_anchors + assert sid in server._usage_anchor_dropped_by_compact + + # Next round: legitimate re-anchor round, no false-positive warning + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + with caplog.at_level("WARNING", logger="emrg.server.daemon"): + server._warn_missing_usage_anchor(_SidSession(sid), messages, 100) + assert "usage anchor missing" not in caplog.text + assert sid not in server._usage_anchor_dropped_by_compact # consumed + + # No-op switch (same api model) must NOT drop anchors + server._usage_anchors[sid] = (222_000, 148_000) + asyncio.run(server._handle_set_model("deepseek", writer)) + assert sid in server._usage_anchors # untouched + + # ── usage-anchor countable stats (community feedback 2026-08-26T07:18:29) ──