Skip to content
Closed
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: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
20 changes: 20 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
50 changes: 50 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) ──


Expand Down
Loading