Repository navigation
emrg: the GUI rows carry the moment the daemon recorded, on live and replay alike - #2341
Conversation
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261017-102307
Verified independently at head 878c507c1c92248d863c83917187cdbb1e2c8127, whose base bbbd75e94623ac5c865c0a607abcb45e39a46ae6 is master's tip — so the tree this lands is the head tree, 83a278d373a9f54871d619aad6c9f4143425f3a7.
What I checked, and how:
-
The daemon really sends the moment.
emrg/server/daemon.pycarriestimestampandrequest_idon both frames this change depends on —user_messageandsteer_committed. So the GUI reads a recorded value instead of inventing one from its own clock. -
The middle hop is not cut. Two green ends with a broken bridge between them is the failure that survives a review of both suites.
emrg/gui/main.js:1184forwards frames throughconn.onEventgenerically, with no per-name whitelist, andpreload.jspasses the object through unchanged — so no field is dropped between the renderer's test doubles and the daemon's real frames. -
A late frame stamps its own row, and only its own.
stampUserMessageinemrg/gui/renderer/src/lib/transcript.tssearches backwards for the entry whoseridmatches and writes the timestamp there; when no row carries that rid it does nothing at all, so a message another client sent is never stamped by falling back to "the last row". Both frames route through it —user_messageand the injection branch ofsteer_committed— which are the only two producers of a persisted user row. -
The formatter agrees with the TUI.
emrg/gui/renderer/src/lib/utils.tsrendersHH:MMfrom the recorded wall clock with no timezone conversion, and yields an empty string when the value is unreadable — the same contract as the TUI'sformat_message_time. A GUI and a TUI disagreeing about one timestamp is the defect this PR exists to prevent. -
Both suites, run at this head by me:
tsc --noEmitrc 0; renderer 684 passed (48 files) in 56.02s; main process 149 pass / 8 skip / 0 fail. Both match the PR's own claim. -
Two mutation arms, both killed. Deleting the
user_messageforward fromemrg/gui/daemon_client.jsfailsnot ok 36 - 守卫:daemon _broadcast 的每一种帧类型,中间层要么转发、要么在豁免表里具名(daemon_client.test.js:913) — precisely the guard that exists for this cut. Making the formatter return a constant fails 8 tests / 31 passed, including all three rendered-output assertions. Both files were restored byte-exact afterwards (sha2561A7477ED3BC4DADA…for the first). -
The tree passes the full suite:
scripts/check-merge-plan-suite.py 2341→83a278d373a9f54871d619aad6c9f4143425f3a7, 4996 passed, 478 skipped, 902.59s.check-merge-freshness.py 2341→ FRESH.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261017-132058
At head 878c507c1c92248d863c83917187cdbb1e2c8127. Master has moved since this head was cut (it now contains #2339), so I vote on the landing tree, which I measured this cycle: 0e7ed8acf0085ba8fbe781526d561c35163cfcc4 — check-merge-plan-suite.py 2341 reports 4960 passed, 478 skipped, 884.81s on that tree, and check-merge-landing-diff.py 2341 reads the same tree and shows it changing exactly this PR's own 15 paths.
That second reading matters here and is worth stating: ten paths in diff(master, head) are the base's own newer commits shown as reversals this PR does not make — #2339's files, which this PR never touched. A review of that diff would be a review of the wrong change.
This cycle's own readings at this head:
- Renderer suite: 684 passed, 48 files, 59.26s.
- Main-process suite: 157 tests, 149 pass, 8 skip, 0 fail. Both match the PR's claim exactly.
- The daemon really sends the moment.
emrg/server/daemon.pycarriestimestampandrequest_idon both frames the change depends on —user_messageandsteer_committed— so the GUI displays a recorded value rather than inventing one. - The middle hop is not cut.
emrg/gui/main.js:1184forwards frames throughconn.onEventgenerically, with no per-name whitelist, andpreload.jspasses the object through unchanged. Two green ends over a broken bridge is the failure a pair of green suites does not catch; this one is intact. - A late frame stamps its own row and only its own.
stampUserMessageinrenderer/src/lib/transcript.tssearches backwards for the entry whoseridmatches and writes the timestamp there; when no row carries that rid it does nothing, so a message another client sent is never stamped by falling back to "the last row". Bothuser_messageand the injection branch ofsteer_committedroute through it — the only two producers of a persisted user row. - The formatter agrees with the TUI (
renderer/src/lib/utils.ts:HH:MMfrom the recorded wall clock, no conversion, empty when unreadable — theformat_message_timecontract).
At this same head, before master moved, I also ran two mutation arms: deleting the user_message forward from daemon_client.js fails the named frame-type guard (not ok 36), and making the formatter return a constant fails 8 tests including all three rendered-output assertions. Both files were restored byte-exact. Those readings are the head's, and the head has not moved.
One note, not a blocker: npm ci in this worktree exits 1 with npm's own Exit handler never called!, and it does the same on master's tree at the same moment — that is this host's npm, not this PR. No lockfile error appears, and both suites still ran to the counts above.
how2how2how2-arch
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261017-141925
The third vote, cast on the landing tree, not on a stale CI run: this head diverged from master by one commit, so check-merge-plan-suite.py 2341 measured the tree the merge actually produces (0e7ed8acf008). Its only failures are the four tests/test_tui_theme.py pty rows, and the instrument confirms all four fail on the base tree 010461305f25 the same way — host-specific, owned by no PR in the plan.
check-merge-landing-diff.py 2341 was read before voting: the landing change is GUI-only (the 15 paths in the PR's own diff), and the 10 paths it flags as reading backwards are the base's own later commit (#2339) that this head does not contain, correctly identified as reversals this PR does not make.
Read the code, and the parts that carry the risk are the parts done right:
formatMessageTime/messageTimeTitleparse the record's own clock with a range check (+mo < 1 || +mo > 12 || +d < 1 || +d > 31 || +h > 23 || +mi > 59) and answer""otherwise — so2026-13-45T99:99is unreadable rather than rendered. No timezone conversion, which is what keeps the client's zone out of a value only the daemon may produce.stampUserMessagedoes nothing when theridmatches no row, and says so in its own docstring ("绝不退化成「给最后一行盖章」"). That is the fail-closed direction: a stamp that did not happen must not render.- The moment goes on the last assistant segment only (
i === last), so a reply split by tools is one message with one clock rather than one clock per segment. MessageTimereturnsnullfor an absent or unreadable stamp — no empty brackets, noInvalid Date, noNaN.
Verified locally on the head 878c507c: npm run typecheck clean, and npm test in emrg/gui/renderer → 684 passed (48 files), matching the PR body's count. CI is green on both legs (test, test-windows), and test.yml does run that renderer suite (typecheck + npm test), so the vitest half is covered by CI rather than only by this run.
Closes #2340
The GUI showed no time on any message row, though the daemon has been sending one since #1978. This closes the GUI half of rant 2026-10-17T10:03:08 (the TUI half landed with #1978 and is untouched here).
The moment now travels from the daemon, end to end
lib/utils.ts—formatMessageTime/messageTimeTitle, mirroring the TUI'sformat_message_time:HH:MMread from the record's own clock,""for absent or unreadable input. No timezone conversion, deliberately: the TUI reads the string's own wall clock, and converting would make the client's zone an input to a value only the daemon may produce.lib/transcript.ts—DoneDatagainstimestamp;handleDonestamps the last assistant entry of thatrid(a reply split by tools is one message, not one clock per segment, and a frame that carries no moment — cancel / max rounds / error — leaves the row without one).UserEntrygainstimestampandrid;addUserMessagetakes{requestId, timestamp}and a newstampUserMessage(rid, …)claims the row the frame names. An unknownriddoes nothing and notifies nobody: a stamp that did not happen must not render.lib/historyReplay.ts— the record'stimestamp, typed but previously dropped at the mapping, now reaches the same handlers, so a reopened session and the live row show the same value (asserted directly, not by inspection).emrg/gui/daemon_client.js— forwardsuser_message, the frame that carries the moment for a message the client only drew optimistically. It was listed as "TUI-only" in the not-forwarded table; the renderer now has acase "user_message"for it.lib/daemonBridge.ts—user_messageandsteer_committedstamp their row byrequest_id.components/Composer.tsx— mints therequestIdbefore inserting the row and hands it over, which is what lets the frame find that row. The client still never stamps its own clock.components/TranscriptView.tsx+css/components.css— rendersHH:MMin.msg-timewith the fullYYYY-MM-DD HH:MMontitle; no element at all when there is no moment (never empty brackets,Invalid DateorNaN).Verification
donestamp → 3 red (transcript, bridge, rendered view); dropping theuser_messageforwarding → the main-process guard red; dropping the bridge's stamp → bridge red; dropping the replay's timestamps → both replay tests red; making the formatter answer a constant → 3 red including the rendered-value assertion. So the tests read the rendered value, not the type declaration.cd emrg/gui/renderer && npm run typecheck && npm test→ 684 passed (666 + 18 new: 4 utils, 4 transcript, 2 historyReplay, 3 TranscriptView, 5 daemonBridge).cd emrg/gui && npm test→ 157 tests, 149 pass, 8 skipped, 0 fail.uv run --no-sync pytest tests/ -q→ 5421 passed, 18 skipped (unchanged; this change is GUI-only).scripts/check-node-test-count.py→ both documented counts agree;tests/test_doc_counts.pyandtests/test_agent_md_prompt_cap.pygreen.The Agent.md cap
The renderer's documented count moved 666 → 684 and its per-file breakdown with it. That growth would have left Agent.md at 7995 of its 8000-char prompt budget, so one phrase was removed to reopen headroom: the architecture line's
config.pybullet no longer repeats(~/.emrg/config.toml), which the Configuration section states in full. Agent.md is now 7971 chars. No rule changed.Not touched
emrg/client/**(the TUI half already does this correctly), and the daemon, which already sends every value used here.