diff --git a/Agent.md b/Agent.md index 9b958689..d597bd6e 100644 --- a/Agent.md +++ b/Agent.md @@ -54,8 +54,8 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (157: 96 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (684: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 20 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 50 daemonBridge + 9 DaemonBridgeProvider + 44 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (158: 97 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (690: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 28 transcript + 26 TranscriptView + 22 history + 23 historyReplay + 38 composer + 65 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 52 daemonBridge + 9 DaemonBridgeProvider + 45 Shell + 22 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/client/app.py b/emrg/client/app.py index 89a8759a..b44afbe1 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -310,12 +310,19 @@ def _replay_rows(messages) -> list[tuple[str, object, str]]: The rows are `(kind, payload, timestamp)` triples; `assistant` is deliberately its own kind rather than pre-wrapped, because the caller renders it through `StreamingMarkdown` for colour. What the daemon's records mode emits is its own - contract and it is narrow: `kind: "message"` for the user and assistant roles - and `kind: "tool_result"` for tool output. A record outside that pair is - skipped rather than rendered as a blank row — including a role the file on disk - may hold and the records mode does not project, because inventing it here would - put the TUI and the GUI back on two different content paths, which is the - defect that requirement removes. + contract: `kind: "message"` for the user, assistant and system roles, + `kind: "tool_result"` for tool output, and `kind: "summary"` for a compaction + boundary. A record outside that set is skipped rather than rendered as a blank + row — a role the file on disk may hold and the records mode does not project is + invented nowhere here, because inventing it would put the TUI and the GUI back on + two different content paths, which is the defect that requirement removes. + + The system role and the summary record are the two #2351 restored, and each is + rendered with the wording its own **live** path used, so a reopen matches the + screen the host closed: a system note is the record's text (the live note for + `/clear` was written from the same record), and a compaction row is the string + `compact_result` produced, rebuilt from the count and the first 200 characters of + the summary body the record carries. """ rows: list[tuple[str, object, str]] = [] calls: dict[str, ToolCard] = {} @@ -330,13 +337,26 @@ def _replay_rows(messages) -> list[tuple[str, object, str]]: # (rant 2026-10-09T09:25:00). Absent on records written before the # field was carried, which renders as no clock at all. stamp = str(record.get("timestamp") or "") - if role in ("user", "assistant"): + if role in ("user", "assistant", "system"): rows.append((str(role), str(record.get("content") or ""), stamp)) if role == "assistant": for card in _cards_from_tool_calls(record.get("tool_calls")): rows.append(("tool_card", card, "")) if card.tool_call_id: calls[card.tool_call_id] = card + elif kind == "summary": + # The live note the daemon's `compact_result` produced, rebuilt from the + # record (#2351). Same wording, same 200-character cut: a replay that + # said it differently would be a second transcript again. + try: + compacted = int(record.get("compacted_message_count") or 0) + except (TypeError, ValueError): + compacted = 0 + body = str(record.get("content") or "") + rows.append(("system", + f"Compact complete — {compacted} messages compressed into summary.\n" + f"Summary: {body[:200]}...", + str(record.get("timestamp") or ""))) elif kind == "tool_result": card = calls.get(str(record.get("tool_call_id") or "")) if card is not None: diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 888d452b..2ec8325e 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -1110,6 +1110,17 @@ class DaemonClient { // 实测(`~/.emrg/emrg-gui.log`,176404 行):turn_start 丢 703、turn_end 丢 704, // 首丢 2026-09-02T07:26:10Z、末丢 2026-09-30T02:02:14Z——bug 一直活着。 // 与 done/cancelled 幂等(渲染层清计时也是幂等的),故直接按原名转发。 + if (frame.type === "rewind_result") { + // A rewind of this session (#2351). The daemon broadcasts the result to every + // connection subscribed to the session, because the rewind rewrote the history + // they are all displaying. The requesting client's own reply never arrives + // here — `_resolvePending` above claims it — so the main process forwards that + // one on the same channel (`main.js`, `emrg:rewindSession`). Both then reach + // the renderer as this event, which rebuilds the transcript from the file + // instead of deleting the rows it remembers. + this._emit(frame.type, frame); + return; + } if (frame.type === "turn_start" || frame.type === "turn_end") { this._emit(frame.type, frame); return; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 755218a2..df551825 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -701,6 +701,14 @@ vision = false { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD, record_index: recordIndex }, 5000 ); + // The requester's own answer never reaches the renderer as an event: + // `daemon_client._classify` resolves the pending command with it and stops + // (#2351). Forward it here so the client that did the rewind takes the same + // path as every other client watching the session — its transcript is rebuilt + // from the truncated history rather than patched by deleting rows. + if (win && !win.isDestroyed()) { + win.webContents.send("emrg:event", { type: "rewind_result", data: frame, sid: sessionId }); + } return { ok: true, removedCount: frame.removed_count ?? 0 }; }); diff --git a/emrg/gui/renderer/src/components/DialogHost.test.tsx b/emrg/gui/renderer/src/components/DialogHost.test.tsx index bade2145..ba31c685 100644 --- a/emrg/gui/renderer/src/components/DialogHost.test.tsx +++ b/emrg/gui/renderer/src/components/DialogHost.test.tsx @@ -32,6 +32,9 @@ function appState(over: Partial = {}): DaemonAppState { openSessions: [], busyBySid: {}, turnStartBySid: {}, + // No rewind has been announced to this fixture (#2351) — the counter is only + // ever compared for change, and "absent" is the state before any `rewind_result`. + rewoundBySid: {}, disconnectedBySid: {}, // No frame has reported a session's sandbox tier to this fixture (rant // 2026-09-30T09:30:16, GUI half) — absent is "the daemon has not said". diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index a9e51a52..17a09cbd 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -215,6 +215,54 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect((screen.getAllByTestId("open-session-item").find((el) => el.dataset.sid === "s2"))?.className).toContain("active"); }); + it("#2351:rewind_result 让 Shell 从落盘历史重建该会话,而不是删掉手里最后 N 行", async () => { + // The defect this pins: `rewind_result` used to be the requesting client's own + // business, and a client that answered it by deleting its last N rows stayed right + // only while it still held them. The daemon now broadcasts the frame to every + // connection subscribed to the session — so the rule is "whoever is told the history + // was rewritten rebuilds from the file", and the rebuild is this effect. Without it + // the transcript is cleared and never refilled: measured, deleting the effect left + // all 686 renderer tests green. + const m = mockEmrg(); + let rewritten = false; + m.listHistory.mockImplementation(() => + Promise.resolve( + rewritten + ? { messages: [{ record_index: 0, kind: "message", role: "user", content: "kept row" }], hasMore: false } + : { messages: [], hasMore: false }, + ), + ); + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(m.listHistory).toHaveBeenCalledTimes(1)); + // A row that arrived live — the rewind removes it, and the file after the rewrite + // no longer holds it. + m.emit(deltaFrame("s1", "the row the rewind removed")); + await waitFor(() => expect(screen.getByText("the row the rewind removed")).toBeInTheDocument()); + + rewritten = true; + m.emit({ + type: "rewind_result", + sid: "s1", + data: { + type: "rewind_result", + session_id: "s1", + ok: true, + record_index: 1, + kept_count: 1, + removed_count: 1, + removed: [{ record_index: 1, kind: "message", role: "assistant" }], + }, + }); + // ① the file is read again — this is the assertion the effect alone satisfies + await waitFor(() => expect(m.listHistory).toHaveBeenCalledTimes(2)); + // ② what the rebuilt transcript shows comes from the file, not from the rows the + // client happened to hold + await waitFor(() => expect(screen.getByText("kept row")).toBeInTheDocument()); + expect(screen.queryByText("the row the rewind removed")).not.toBeInTheDocument(); + }); + it("loads a session's history as records and replays them like the live stream (rant 2026-09-20T18:58:44)", async () => { const m = mockEmrg(); // 一条远超 80 字的助手消息 + 一轮工具 + 收尾文本:旧路径取 preview 只会显示 81 字, diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 6b2853f2..d82a9062 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -170,6 +170,29 @@ export function Shell() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [appState.openSessions, activeSid]); + /** 重新装配一个会话的 transcript:丢掉分页状态与「已加载」标记,从头回放一次。 */ + async function reloadHistory(sid: string) { + historyLoadedRef.current.delete(sid); + historyPagesRef.current.delete(sid); + transcript.clear(sid); + await loadHistory(sid); + } + + /** + * 一次 rewind 之后重建这个会话的 transcript(#2351)。 + * + * daemon 把 `rewind_result` 广播给订阅该会话的每个连接,改写的是同一份落盘历史; + * 本客户端自己做的那次也经主进程转发成同一个事件(`main.js:emrg:rewindSession`), + * 于是这里只有一条规则:**谁被告知历史被改写了,谁的屏幕就从那份历史重新长出来**, + * 而不是各自猜着删掉最后 N 行。计数器只比较变化,值本身没有含义。 + */ + const rewoundCount = activeSid ? appState.rewoundBySid[activeSid] : undefined; + useEffect(() => { + if (!activeSid || rewoundCount === undefined) return; + void reloadHistory(activeSid); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rewoundCount, activeSid]); + // ── workspace 面板数据加载(vanilla loadTaskMeta/renderRantList 语义) ── async function loadProjects() { const b = wsBridge(); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts index c6dd2c2f..dab5b476 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.test.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -60,6 +60,35 @@ describe("createDaemonBridge", () => { expect(txt.some((x) => x === "a:Hello")).toBe(true); }); + it("#2351:rewind_result → 丢掉该会话的行并发出重载信号(自己那次与别人的都算)", () => { + // The defect this pins: a rewind rewrites `history.jsonl`, so a transcript that + // keeps its rows describes a history that no longer exists. The bridge must act + // on the *frame* — the daemon broadcasts it to every connection subscribed to + // the session — which is what makes a second client, and a client that did not + // perform the rewind, agree with the one that did. + const { emit, bridge, transcript } = setup(); + emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "gone soon" }] }, sid: "s1" }); + expect(entriesText(transcript, "s1").length).toBeGreaterThan(0); + + emit({ type: "rewind_result", data: { session_id: "s1", ok: true, record_index: 1, kept_count: 1, removed_count: 1, removed: [{ record_index: 1, kind: "message", role: "assistant" }] }, sid: "s1" }); + + // ① rows the file no longer holds are gone + expect(entriesText(transcript, "s1")).toEqual([]); + // ② the counter Shell reloads on has moved — the value itself is not a claim + expect(bridge.store.get().rewoundBySid["s1"]).toBe(1); + expect(bridge.store.get().rewoundBySid["s2"]).toBeUndefined(); + }); + + it("#2351:失败的 rewind_result 不动 transcript(历史没被改写)", () => { + // A refusal (`error`, no `ok`) leaves the file alone, so dropping the rows would + // erase a transcript that is still exactly what is on disk. + const { emit, bridge, transcript } = setup(); + emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "still here" }] }, sid: "s1" }); + emit({ type: "rewind_result", data: { error: "record_index 9 out of range (0-2)" }, sid: "s1" }); + expect(entriesText(transcript, "s1").some((x) => x.includes("still here"))).toBe(true); + expect(bridge.store.get().rewoundBySid["s1"]).toBeUndefined(); + }); + it("done → transcript.handleDone + 该会话的 busy 收敛(daemon 的回合终局为准)", () => { const { emit, bridge } = setup(); bridge.store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, s1: true } })); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts index 86ab3b69..847c3aa6 100644 --- a/emrg/gui/renderer/src/lib/daemonBridge.ts +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -119,6 +119,18 @@ export interface DaemonAppState { busyBySid: Record; /** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 daemon turn_start 权威广播) */ turnStartBySid: Record; + /** + * How many rewinds of each session this client has been told about (#2351). + * + * A rewind rewrites the session's persisted history, so every client showing that + * session has to rebuild its transcript from the file rather than keep the rows it + * happens to hold. The daemon broadcasts `rewind_result` to every connection + * subscribed to the session — the requester's own reply is forwarded by the main + * process as the same event — so this counter is the one signal both cases share. + * Shell watches it and reloads that session; the value itself is only ever + * compared for change. + */ + rewoundBySid: Record; /** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */ disconnectedBySid: Record; /** @@ -288,6 +300,7 @@ export function createDaemonAppStore(): SnapshotStore { openSessions: [], busyBySid: {}, turnStartBySid: {}, + rewoundBySid: {}, disconnectedBySid: {}, sandboxBySid: {}, sandboxRootsBySid: {}, @@ -566,6 +579,26 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { })); break; } + case "rewind_result": { + // A rewind — this client's own, or another client's on the same session + // (#2351). The daemon rewrote `history.jsonl`, so every row this transcript + // holds about that session describes a history that no longer exists: drop + // them and let Shell replay the file. Acting on the *frame* rather than on + // the click is what makes a second client and a reconnecting one agree with + // the one that did the rewind. + const rw = data as { session_id?: string; ok?: boolean }; + const rwSid = rw?.session_id ?? sid; + if (!rwSid || rw?.ok !== true) break; + transcript.clear(rwSid); + store.update((s) => ({ + ...s, + rewoundBySid: { + ...s.rewoundBySid, + [rwSid]: (s.rewoundBySid[rwSid] ?? 0) + 1, + }, + })); + break; + } case "sandbox_set": { // The session's tier, from whichever of the two frames carried it: the // requester's own reply and every other connection's broadcast are the same diff --git a/emrg/gui/renderer/src/lib/historyReplay.test.ts b/emrg/gui/renderer/src/lib/historyReplay.test.ts index 2545d3b8..24180827 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.test.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { createTranscriptStore, formatToolArguments, type AssistantEntry, type TranscriptEntry, type TranscriptStore } from "./transcript"; import { replayHistoryRecords, replayHistoryRecordsPrepend, toolCallIntent, type HistoryRecord } from "./historyReplay"; +import { t } from "./i18n"; /** * historyReplay.test.ts — 回放等价性测试(rant 2026-09-20T18:58:44 的验收)。 @@ -299,6 +300,7 @@ describe("the record shape the daemon actually sends", () => { handleDone: vi.fn(), handleToolStart: vi.fn(), handleToolEnd: vi.fn(), + addSystemMessage: vi.fn(), }; replayHistoryRecords( [ @@ -316,6 +318,67 @@ describe("the record shape the daemon actually sends", () => { }); }); +/** + * #2351:回放里的系统提示与压缩边界。 + * + * daemon 的 records 模式现在也投影两条既不是消息(显示路径投影的两种角色)也不是工具结果的 + * 记录:`Session.clear` 写下的 `role: "system"` 消息,和每次压缩写下的 `type: "summary"`。 + * 实时那一路两条都有对应的行(`/clear` 之后的系统提示、`compact_result` 之后的已压缩提示), + * 所以回放必须调同一个入口写同一句话 —— 否则重开一个会话看到的就不是自己关掉的那一屏。 + * + * 这三条是回放那一半唯一的钉:删掉 `replayHistoryRecords` 里那两个分支,此前整套渲染层 + * 测试仍然全绿(实测:686 条不变),于是两个客户端的一致性可以静默退回去。 + */ +describe("系统提示与压缩边界的回放(#2351)", () => { + const records: HistoryRecord[] = [ + { record_index: 0, kind: "message", role: "system", content: "[Session cleared]" }, + { record_index: 1, kind: "summary", content: "the earlier turns, summarised", compacted_message_count: 7 }, + { record_index: 2, kind: "message", role: "user", content: "after the compact" }, + ]; + + function sinkOf() { + return { + addUserMessage: vi.fn(), + handleDelta: vi.fn(), + handleDone: vi.fn(), + handleToolStart: vi.fn(), + handleToolEnd: vi.fn(), + addSystemMessage: vi.fn(), + }; + } + + it("写一行系统提示,用的就是记录自己的文本(不翻译、不改写)", () => { + const s = sinkOf(); + replayHistoryRecords(records, SID, s); + // 第一条系统行:`Session.clear` 的那句话。实时那一路显示的也是它 —— 回放换一个说法, + // 两条路就又分叉了,而这正是 #2351 要消掉的东西。 + expect(s.addSystemMessage).toHaveBeenNthCalledWith(1, "[Session cleared]", SID); + }); + + it("压缩边界写的是实时那一行的同一句话(同一个键,不是第二套文案)", () => { + const s = sinkOf(); + replayHistoryRecords(records, SID, s); + // `DialogHost` 在 `compact_result` 上写的是 `t("app.compacted")`;回放调同一个入口、 + // 同一个键,于是两路是同一行。断言精确相等:换成「已压缩 N 条」这种自造文案会变红。 + expect(s.addSystemMessage).toHaveBeenNthCalledWith(2, t("app.compacted"), SID); + expect(s.addSystemMessage).toHaveBeenCalledTimes(2); + }); + + it("边界两侧的顺序与落盘一致:系统行在前,压缩行在中间,用户消息在最后", () => { + const s = sinkOf(); + replayHistoryRecords(records, SID, s); + const order = [ + ...s.addSystemMessage.mock.calls.map((c) => `sys:${c[0]}`), + ...s.addUserMessage.mock.calls.map((c) => `user:${c[0]}`), + ]; + expect(order).toEqual([ + "sys:[Session cleared]", + `sys:${t("app.compacted")}`, + "user:after the compact", + ]); + }); +}); + /** * rant 2026-09-30T09:17:54 → #1787(Part 2/2):回放此前只从 `function.arguments` 里抽走 * intent,把剩下的丢掉 —— 于是「打开旧会话」看到的工具行与实时不一致。这一组钉住 @@ -342,6 +405,7 @@ describe("工具入参的回放一致性(#1787)", () => { handleDone: vi.fn(), handleToolStart: vi.fn(), handleToolEnd: vi.fn(), + addSystemMessage: vi.fn(), }; replayHistoryRecords(RECORDS, SID, sink); expect(sink.handleToolStart).toHaveBeenCalledWith( diff --git a/emrg/gui/renderer/src/lib/historyReplay.ts b/emrg/gui/renderer/src/lib/historyReplay.ts index a7aa8d6a..c70e5588 100644 --- a/emrg/gui/renderer/src/lib/historyReplay.ts +++ b/emrg/gui/renderer/src/lib/historyReplay.ts @@ -22,6 +22,7 @@ */ import { createTranscriptStore, type TranscriptStore } from "./transcript"; +import { t } from "./i18n"; /** 落盘记录里的一条助手/用户消息(daemon `include_records` 的 `kind: "message"`)。 */ export interface HistoryMessageRecord { @@ -44,7 +45,21 @@ export interface HistoryToolResultRecord { error?: boolean; } -export type HistoryRecord = HistoryMessageRecord | HistoryToolResultRecord; +export type HistoryRecord = HistoryMessageRecord | HistoryToolResultRecord | HistorySummaryRecord; + +/** + * 落盘记录里的一次压缩(daemon `include_records` 的 `kind: "summary"`,#2351)。 + * + * 实时那一路是 `compact_result` → 一条系统提示;落盘记录里压缩边界是**一条记录**, + * 若回放跳过它,重开一个会话就看不到自己亲眼见过的「已压缩」这一行。 + */ +export interface HistorySummaryRecord { + record_index?: number; + kind: "summary"; + content?: string; + timestamp?: string; + compacted_message_count?: number; +} /** 回放写入端:实时那一组 handler(TranscriptStore 直接满足)。 */ export interface ReplaySink { @@ -67,6 +82,12 @@ export interface ReplaySink { data: { tool_call_id: string; tool_name?: string; content?: string; error?: unknown }, sid?: string | null, ): void; + /** + * 一条系统提示行(#2351)。实时那一路由 `DialogHost` 在 `compact_result` 上调用, + * 回放那一路由 `replayHistoryRecords` 调用——两条路写的是同一个入口,所以重开一个 + * 会话看到的压缩提示与一直开着时那一行是同一行。 + */ + addSystemMessage(text: string, sid?: string | null): void; } /** 助手记录的 `tool_calls[].function.arguments` 里的 intent(daemon 记日志用的就是它)。 */ @@ -136,6 +157,23 @@ export function replayHistoryRecords( }; for (const r of records) { + if (r.kind === "message" && r.role === "system") { + // 落盘写下的系统提示(`Session.clear` 的 `[Session cleared]`,#2351)。它的正文 + // 就是记录自己的文本:既不翻译、也不改写——实时那一路 `/clear` 之后屏幕上也 + // 只有这一句话,而回放如果换一个说法,两条路就又分叉了。 + // 不在这里 closeTurn:压缩可以在工具轮次之间发生(auto-compact),实时那一路 + // 那时**没有** done 帧,凭空补一个会把这一段助手文本提前封口。 + sink.addSystemMessage(r.content ?? "", sid); + continue; + } + + if (r.kind === "summary") { + // 压缩边界(#2351)。实时那一行由 `DialogHost` 的 `compact_result` 分支写, + // 文案是 `app.compacted`;这里调同一个入口、用同一个键,于是同一行。 + sink.addSystemMessage(t("app.compacted"), sid); + continue; + } + if (r.kind === "message" && r.role === "user") { // 实时顺序:上一轮的 done 先到,用户才接着发下一条。 closeTurn(); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 5bc88273..1906e612 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -764,6 +764,39 @@ test("帧分类(G21+G58):各帧事件分发正确", async () => { assert.strictEqual(cmd.length, 3); }); +test("#2351:rewind_result 帧转发(另一个客户端改写本会话历史时的重载信号)", async () => { + // 同一个缝(`_classify` 兜底只记日志、不发事件),但两头都要看:daemon 现在把 + // `rewind_result` 广播给订阅该会话的每个连接,改写的是同一份 `history.jsonl`—— + // 中间层不转发,别的客户端做的那次 rewind 在 GUI 里就是「什么都没发生」,屏幕继续 + // 显示一份已经不存在于磁盘的历史。自己那次由主进程转发(`main.js`),不走这里。 + const warned = []; + const client = new DaemonClient({ logger: { info: () => {}, warn: (m) => warned.push(m) } }); + await connectClient(client); + const seen = []; + client.onEvent((type, data) => seen.push([type, data])); + + const send = (obj) => currentMockWs.emit("message", Buffer.from(JSON.stringify(obj))); + const frame = { + type: "rewind_result", + session_id: "s1", + ok: true, + record_index: 2, + kept_count: 2, + removed_count: 1, + removed: [{ record_index: 2, kind: "message", role: "assistant" }], + }; + send(frame); + + // ① 按原名转发 —— 渲染层 switch 的就是这个名字 + assert.deepStrictEqual(seen.map(([t]) => t), ["rewind_result"]); + // ② 负载原样带过去:删哪几行由渲染层决定,中间层不做二次解释 + assert.strictEqual(seen[0][1].session_id, "s1"); + assert.deepStrictEqual(seen[0][1].removed, frame.removed); + assert.strictEqual(seen[0][1].kept_count, 2); + // ③ 不再落入兜底 + assert.deepStrictEqual(warned.filter((m) => m.includes("unknown frame")), []); +}); + test("rant 2026-09-30T09:47:11:turn_start/turn_end 广播转发(GUI 会话计时基准)", async () => { // 这个 bug 的形状是「daemon 发了、渲染层要了、中间层丢了」:`turn_start`/`turn_end` // 落进 `_classify` 的兜底分支,只记日志、不发事件 —— 于是 `daemonBridge.ts` 的 diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 8d3606d0..abe3b208 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -3347,8 +3347,20 @@ async def _process_message( # A client replaying a session must show what the live stream # showed, so the answer has to be the same material: every message # record (including the tool-call-only assistant records the - # display path drops) and every tool_result record, in record - # order, each carrying its absolute `record_index`. + # display path drops), every tool_result record and the two record + # kinds the file holds that are neither, in record order, each + # carrying its absolute `record_index`. + # + # The last part is #2351. `history.jsonl` also holds a persisted + # `role: "system"` message (`Session.clear` writes `[Session + # cleared]`) and one `type: "summary"` record per compaction, and + # both were dropped here: the live stream announced each of them + # (a system note, a `compact_result`) and a reopened session showed + # neither, so the replay was quietly a different transcript. They + # are projected as their own material, with the fields each client + # needs to reproduce its live wording — the daemon does not invent + # the text: a summary record carries the summary body because the + # TUI's live note shows its first 200 characters. # # `preview` is deliberately NOT attached here. It is a /rewind # affordance — a one-line rewind point — and a replay that prefers @@ -3389,7 +3401,7 @@ async def _process_message( "content": r.get("content", "") or "", "error": bool(r.get("error")), }) - elif rtype == "message" and r.get("role") in ("user", "assistant"): + elif rtype == "message" and r.get("role") in ("user", "assistant", "system"): item: dict = { "record_index": i, "kind": "message", @@ -3401,6 +3413,18 @@ async def _process_message( if tool_calls: item["tool_calls"] = tool_calls history_messages.append(item) + elif rtype == "summary": + # One per compaction (#2351): the live stream announced it + # with `compact_result`, so a replay that omits it loses a + # boundary the host watched appear. + history_messages.append({ + "record_index": i, + "kind": "summary", + "content": r.get("content", "") or "", + "timestamp": r.get("timestamp", ""), + "compacted_message_count": + r.get("compacted_message_count", 0), + }) await self._send(ws, { "type": "history_list", "session_id": session_id, @@ -3484,15 +3508,40 @@ async def _process_message( ) session._updated_at = datetime.now().isoformat() session._save_meta() - await self._send(ws, { + # What the cut removed, not only how much (#2351, item 2). A count alone + # let the requesting client drop its last N rows and nothing else: a + # client that reconnects, or a second client watching this session, had + # no way to apply the same cut and so drifted from the live view. The + # descriptors name each removed record by its absolute position and its + # kind, which is enough for a receiver holding the same history to + # identify the rows; `kept_count` says where the surviving transcript + # now ends. + removed = [ + { + "record_index": i, + "kind": str(r.get("type", "message") or "message"), + "role": r.get("role"), + } + for i, r in enumerate(records) if i >= record_index + ] + frame = { "type": "rewind_result", "session_id": session_id, "ok": True, "record_index": record_index, - "removed_count": len(records) - len(truncated), - }) - logger.info("session rewound: %s at index %d (removed %d records)", - session_id, record_index, len(records) - len(truncated)) + "kept_count": len(truncated), + "removed_count": len(removed), + "removed": removed, + } + # Every connection subscribed to this session, not just the caller: the + # rewind rewrote the history the other clients on it are displaying. + # `_broadcast` reaches the caller too when it is a subscriber; when it + # is not, the frame above is its own answer. + await self._broadcast(session_id, frame) + if ws not in self._session_subscribers.get(session_id, {}): + await self._send(ws, frame) + logger.info("session rewound: %s at index %d (removed %d records, %d kept)", + session_id, record_index, len(removed), len(truncated)) elif msg_type == "task_vibe_check": # Rant 2026-08-17T11:39:19: scheduler asks the agent, after a diff --git a/tests/test_app_replay_rows.py b/tests/test_app_replay_rows.py index 9c017bbe..77c5a4dd 100644 --- a/tests/test_app_replay_rows.py +++ b/tests/test_app_replay_rows.py @@ -46,22 +46,51 @@ def test_a_message_record_becomes_its_own_role_row(): assert rows == [("user", "hello", ""), ("assistant", long_body, "")] -def test_a_role_the_records_mode_does_not_project_is_skipped(): - """A `system` record is not material this client may invent. - - The daemon's records mode answers `role: "user" | "assistant"` explicitly. The file - on disk holds more than that (system rows, compaction summaries), so a client that - rendered them would again be reading a different session than the GUI — the drift - requirement 4 removes. If the mode is widened, this client learns it there. +def test_a_system_note_and_a_compaction_boundary_are_rows(): + """Issue #2351: the two record kinds the live stream announced, replayed as asked. + + This client renders exactly what the daemon's records mode projects, and nothing it + does not — that is the drift requirement 4 removes. The mode now projects a + `role: "system"` message and a `kind: "summary"` record, so both are rows here: + + * the system note takes the **record's own text** (that is all the live note for + `/clear` ever said), with the record's own moment; + * the compaction row is rebuilt with the wording `compact_result` produced live — + the count it named and the first 200 characters of the summary body — because a + different sentence would be a second transcript again. """ rows = _replay_rows([ - {"record_index": 0, "kind": "message", "role": "system", "content": "Interrupted"}, + {"record_index": 0, "kind": "message", "role": "system", + "content": "[Session cleared]", "timestamp": "2026-09-20T00:00:10"}, {"record_index": 1, "kind": "message", "role": "tool", "content": "legacy role"}, - {"record_index": 2, "kind": "summary", "content": "[Session summary from compact #3]"}, + {"record_index": 2, "kind": "summary", "content": "the earlier turns", + "compacted_message_count": 7, "timestamp": "2026-09-20T00:00:20"}, {"record_index": 3, "kind": "message", "role": "user", "content": "kept"}, ]) - assert rows == [("user", "kept", "")] + assert rows == [ + ("system", "[Session cleared]", "2026-09-20T00:00:10"), + ("system", + "Compact complete — 7 messages compressed into summary.\n" + "Summary: the earlier turns...", + "2026-09-20T00:00:20"), + ("user", "kept", ""), + ], "a role the mode does not project must still be skipped, and the two it now " + "projects must arrive with their own text and moment" + + +def test_a_summary_without_a_count_still_renders_as_a_boundary(): + """A record written before the count was carried, or a truncated one. + + Rendering it as `0 messages` would state a fact the record does not hold; skipping + it would lose the boundary the host watched appear. The row is produced with the + count it was given and no invention beyond that. + """ + rows = _replay_rows([{"record_index": 0, "kind": "summary", "content": ""}]) + + assert rows == [("system", + "Compact complete — 0 messages compressed into summary.\n" + "Summary: ...", "")] def test_a_tool_result_names_the_tool_and_whether_it_failed(): diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 0251116f..573b32eb 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -2994,6 +2994,140 @@ async def _test(): await cleanup() asyncio.run(_test()) + def test_the_two_record_kinds_that_are_neither_reach_a_client(self): + """Issue #2351: a system note and a compaction boundary are rows too. + + `history.jsonl` holds two record kinds that are neither a message from a role + the display path projects nor a tool result: the `role: "system"` message + `Session.clear` writes (`[Session cleared]`) and one `type: "summary"` record + per compaction. The live stream announced both — a system note after `/clear`, + a `compact_result` on compaction — so a records mode that dropped them made a + reopened session a different transcript from the one the host closed. + + The assertion is about what the projection *says*, not only that it says + something: the system note carries the record's own text, and the summary + carries the count the live note named plus the body the record holds. A row + without those would render as a boundary with no account of itself. + """ + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_records_note" + self._seed(cwd, sid, [ + {"type": "message", "role": "system", "content": "[Session cleared]", + "timestamp": "2026-09-20T00:00:10"}, + {"type": "summary", "content": "the earlier turns, summarised", + "timestamp": "2026-09-20T00:00:20", + "compact_id": "c_001", "compacted_message_count": 7}, + {"type": "message", "role": "user", "content": "after the compact", + "timestamp": "2026-09-20T00:01:00"}, + ]) + _, _, cleanup = await _boot_server(cwd) + try: + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "list_history", "session_id": sid, + "cwd": str(cwd), "include_records": True, + }) + items = resp["messages"] + assert [(m["record_index"], m["kind"], m.get("role")) + for m in items] == [ + (0, "message", "system"), + (1, "summary", None), + (2, "message", "user"), + ], f"records mode did not project them: {items}" + assert items[0]["content"] == "[Session cleared]" + assert items[0]["timestamp"] == "2026-09-20T00:00:10" + assert items[1]["compacted_message_count"] == 7 + assert items[1]["content"] == "the earlier turns, summarised" + assert items[1]["timestamp"] == "2026-09-20T00:00:20" + + # The default mode is the /rewind list and stays user-only: a + # system note is not a rewind point. + default = await self._cmd(ws, { + "type": "list_history", "session_id": sid, "cwd": str(cwd), + }) + assert [m["content"] for m in default["messages"]] == [ + "after the compact", + ], f"the default mode listed a non-user row: {default['messages']}" + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_a_rewind_says_what_it_removed_and_tells_every_subscriber(self): + """Issue #2351, item 2: the frame must name the cut, and reach every client. + + `rewind_result` used to carry `removed_count` alone, sent to the caller only. + A client could delete its last N rows — which reproduces the view only while + that client still holds them — and a second client watching the same session + was told nothing at all, so the two drifted exactly where the issue says they + must not. The rewind rewrote `history.jsonl`; that file is shared. + + So the frame names each removed record by its absolute position and its kind + (`kept_count` says where the surviving history now ends), and it goes to every + connection subscribed to the session. The second connection here never asked + for the rewind — it only subscribed — and must still be told. + """ + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + sid = "s_rewind_says" + self._seed(cwd, sid, [ + {"type": "message", "role": "user", "content": "one", + "timestamp": "2026-09-20T00:00:01"}, + {"type": "message", "role": "assistant", "content": "two", + "timestamp": "2026-09-20T00:00:02"}, + {"type": "message", "role": "user", "content": "three", + "timestamp": "2026-09-20T00:00:03"}, + ]) + _, _, cleanup = await _boot_server(cwd) + + async def _drain(ws, want): + """Read until the frame of this type, so a pong cannot be mistaken + for the answer (the two connections each get their own).""" + while True: + frame = json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + if frame.get("type") == want: + return frame + + try: + asker = await connect_to_server() + watcher = await connect_to_server() + try: + # Subscribing is what the read loop records: any frame naming + # the session does it, and this one is answered by a pong. + for ws in (asker, watcher): + await ws.send(json.dumps({ + "type": "ping", "session_id": sid, "cwd": str(cwd), + })) + await _drain(ws, "pong") + + await asker.send(json.dumps({ + "type": "rewind_session", "session_id": sid, + "cwd": str(cwd), "record_index": 1, + })) + here = await _drain(asker, "rewind_result") + there = await _drain(watcher, "rewind_result") + finally: + await asker.close() + await watcher.close() + + for frame in (here, there): + assert frame.get("ok") is True, frame + assert frame["record_index"] == 1 + assert frame["kept_count"] == 1, frame + assert frame["removed_count"] == 2, frame + assert frame["removed"] == [ + {"record_index": 1, "kind": "message", "role": "assistant"}, + {"record_index": 2, "kind": "message", "role": "user"}, + ], frame + finally: + await cleanup() + asyncio.run(_test()) + class TestWSTaskWire: """GUI task CRUD wire frames — task type field contract (rant 2026-08-14T21:48:00).