Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand Down
34 changes: 27 additions & 7 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand All @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});

Expand Down
3 changes: 3 additions & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ function appState(over: Partial<DaemonAppState> = {}): 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".
Expand Down
48 changes: 48 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Shell />));
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 字,
Expand Down
23 changes: 23 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }));
Expand Down
33 changes: 33 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ export interface DaemonAppState {
busyBySid: Record<string, boolean>;
/** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 daemon turn_start 权威广播) */
turnStartBySid: Record<string, number>;
/**
* 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<string, number>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
disconnectedBySid: Record<string, boolean>;
/**
Expand Down Expand Up @@ -288,6 +300,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
openSessions: [],
busyBySid: {},
turnStartBySid: {},
rewoundBySid: {},
disconnectedBySid: {},
sandboxBySid: {},
sandboxRootsBySid: {},
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading