diff --git a/Agent.md b/Agent.md index bfd50a53..22334d2e 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` (1106) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1107) — 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: `cd emrg/gui/renderer && npm run typecheck && npm test` (262: 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 + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog) + `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 上下文) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 288331ae..87c491b5 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -28,6 +28,7 @@ from datetime import datetime from pathlib import Path from typing import Optional +from urllib.parse import urlparse import yaml from websockets.asyncio.server import serve @@ -259,7 +260,7 @@ def __init__(self, llm_config: LlmConfig) -> None: # (estimated_tokens, iso_ts) at anchor-loss warning time, so the # estimator drift of the anchor-less window can be measured when # the provider re-anchors (countable metric, not just a log line). - self._missing_anchor_est: dict[str, tuple[int, str]] = {} + self._missing_anchor_est: dict[str, tuple[int, str, str, str]] = {} # Rant 2026-08-23T13:54:14: per-session dynamic-context snapshot # (session_id → (text, injected_ms)) so unchanged context is not # re-sent (context_refresh_interval_ms gating). @@ -3023,13 +3024,23 @@ def _warn_missing_usage_anchor( return # already warned once for this session self._warned_missing_usage_anchor.add(session.session_id) loss_ts = datetime.now().astimezone().isoformat() - self._missing_anchor_est[session.session_id] = (estimated, loss_ts) + loss_model = self.llm.config.model + loss_provider = _provider_slug(self.llm.config.base_url) + # Issue #1011 (heinrichneb): a loss window without provider identity is + # half a counter — after a mid-session model switch the drift file must + # say WHICH provider went silent. Store the loss-time identity so a + # later anchor_drift can attribute the window. + self._missing_anchor_est[session.session_id] = ( + estimated, loss_ts, loss_model, loss_provider, + ) try: _append_usage_anchor_event({ "type": "anchor_loss", "timestamp": loss_ts, "session": session.session_id, "est": estimated, + "model": loss_model, + "provider": loss_provider, }) except OSError as exc: logger.warning("usage-anchor stats append failed: %s", exc) @@ -3055,7 +3066,7 @@ def _record_anchor_drift(self, session, real_pt: int) -> None: stored = self._missing_anchor_est.pop(session.session_id, None) if not stored: return - est_before, loss_ts = stored + est_before, loss_ts, loss_model, loss_provider = stored try: _append_usage_anchor_event({ "type": "anchor_drift", @@ -3064,6 +3075,12 @@ def _record_anchor_drift(self, session, real_pt: int) -> None: "real_after": real_pt, "delta": real_pt - est_before, "loss_ts": loss_ts, + # Issue #1011: attribute the window — who went silent + # (loss identity) vs who re-anchored (current identity). + "loss_model": loss_model, + "loss_provider": loss_provider, + "model": self.llm.config.model, + "provider": _provider_slug(self.llm.config.base_url), }) except OSError as exc: logger.warning("usage-anchor stats append failed: %s", exc) @@ -4268,6 +4285,21 @@ def _write_exit_record(reason: str, exit_code: int, traceback_text: str | None) _USAGE_ANCHOR_STATS_PATH = Path.home() / ".emrg" / "logs" / "usage-anchor.jsonl" +def _provider_slug(base_url: str) -> str: + """Derive a provider identity from the LLM base_url (issue #1011). + + Deterministic hostname extraction — 'https://api.openai.com/v1' → + 'api.openai.com'; a local endpoint ('http://localhost:11434/v1') → + 'localhost'. Empty/unparsable input falls back to the raw string so the + event still carries something attributable. No heuristics, no DNS. + """ + try: + host = urlparse(base_url).hostname + return host or base_url + except Exception: + return base_url + + def _append_usage_anchor_event(record: dict, path: Path | None = None) -> int: """Append a countable usage-anchor event (one JSON line) and return the cumulative total (community feedback 2026-08-26T07:18:29 — heinrichneb: diff --git a/tests/test_daemon.py b/tests/test_daemon.py index b7087c6c..fd613a8a 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -710,6 +710,9 @@ def test_usage_anchor_loss_event_is_countable(tmp_path, monkeypatch): e1, e2 = json.loads(lines[0]), json.loads(lines[1]) assert e1["type"] == "anchor_loss" and e1["session"] == "s1" assert e1["total"] == 1 and "timestamp" in e1 + # Issue #1011: the loss event must say WHICH provider went silent + assert e1["model"] == "gpt-4o-mini" # default LlmConfig model in _make_server + assert e1["provider"] == "localhost" # base_url host of _make_server assert e2["session"] == "s2" and e2["est"] == 60_000 and e2["total"] == 2 @@ -759,12 +762,66 @@ def test_usage_anchor_drift_measured_on_reanchor(tmp_path, monkeypatch): assert drift["real_after"] == 180_000 assert drift["delta"] == 80_000 assert drift["loss_ts"] == loss["timestamp"] + # Issue #1011: drift window is attributable — loss identity (who went + # silent) + re-anchor identity (who finally reported real tokens) + assert drift["loss_model"] == loss["model"] == "gpt-4o-mini" + assert drift["loss_provider"] == loss["provider"] == "localhost" + assert drift["model"] == "gpt-4o-mini" + assert drift["provider"] == "localhost" # Re-anchor measured once — a second call (no pending loss) is a no-op server._record_anchor_drift(_SidSession(sid), 200_000) assert len(stats.read_text(encoding="utf-8").strip().splitlines()) == 2 +def test_usage_anchor_cross_provider_window_attributable(tmp_path, monkeypatch): + """Issue #1011 — heinrichneb: 'a counter that can't say WHICH provider + went silent is half a counter'. The loss window's identity must survive + from anchor_loss to anchor_drift: a mid-session model switch produces a + drift window whose loss side names the silent provider and whose re-anchor + side names the new provider.""" + stats = tmp_path / "usage-anchor.jsonl" + monkeypatch.setattr(daemon_mod, "_USAGE_ANCHOR_STATS_PATH", stats) + server = _make_server() + sid = "x-provider" + messages = [ + {"role": "assistant", "content": "x"}, + {"role": "user", "content": "y"}, + ] + # Loss detected under provider A (deepseek) + server.llm.config.model = "deepseek-chat" + server.llm.config.base_url = "https://api.deepseek.com/v1" + server._warn_missing_usage_anchor(_SidSession(sid), messages, 50_000) + + # Model switch to provider B (openai) — #1003 invalidates anchors; the + # pending drift window is dropped, so no cross-provider drift is measured. + # But the anchor_loss event itself must still name provider A. + server.llm.config.model = "gpt-4o" + server.llm.config.base_url = "https://api.openai.com/v1" + server._invalidate_usage_anchors_on_switch() + + lines = stats.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + loss = json.loads(lines[0]) + assert loss["type"] == "anchor_loss" + assert loss["model"] == "deepseek-chat" + assert loss["provider"] == "api.deepseek.com" + + # Same-provider window: loss + re-anchor both under provider B (fresh + # session — warn-once is per session lifetime, so a new window needs a + # new session id to emit a second anchor_loss) + server._warn_missing_usage_anchor(_SidSession("x-provider-2"), messages, 60_000) + server._record_anchor_drift(_SidSession("x-provider-2"), 150_000) + lines = stats.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 3 + loss_b, drift_b = json.loads(lines[1]), json.loads(lines[2]) + assert loss_b["provider"] == "api.openai.com" + assert drift_b["loss_model"] == "gpt-4o" + assert drift_b["loss_provider"] == "api.openai.com" + assert drift_b["model"] == "gpt-4o" + assert drift_b["provider"] == "api.openai.com" + + def test_context_message_injection_format(tmp_path): """Context message carries tz-aware time and lands before the user prompt.