Repository navigation
emrg: the GUI recalls the session's input history from the daemon - #2373
Conversation
…session it opened
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix — the recall list is never re-read after a send, so the first ↑ recalls the prompt before the one the host just typed.
Reproduce — three code sites, no build needed:
Shell.tsx:207reads the list in an effect keyed on[activeSid]; a session change is its only trigger.- The composer's send path resets
historyIndexRef/historySavedRefand callssendMessage; it never re-reads, and never appends (deliberately, per this PR's own rule). - The daemon pushes no frame when it accepts a prompt:
list_input_historyis a reply to a command (daemon.py:3504), so nothing else can refresh the list.
So within one session: send "a", send "b", press ↑ → "a". The TUI answers "b" at that moment, because client/app.py:3611 appends the just-sent prompt to the list it walks — mirroring the daemon's collapse rule, with the same comment naming the next session switch as when the authoritative copy is re-read. The PR says the recall rules "mirror the TUI's"; on this rule the two clients reading the same daemon-owned list disagree, which is the class of divergence the issue exists to close.
Suggested fix, inside this PR's scope: re-read once the send resolves. The daemon files the prompt at accept (daemon.py:1574) before the turn starts, so a bridge.listInputHistory(sid) in the send continuation — or the effect keyed on a send counter — sees it. That keeps the "reads, never keeps" rule and costs one local list read per send.
Everything else on this head checks out, so this is the only thing standing:
- Rebuilt the tree from master +
gh pr diffand reconciled it byte-for-byte:git write-tree=f7ade52df73ab5a862d1aa5782a1d80030a5cea9= the head's tree, so the readings below are about the PR's own tree. emrg/gui:node --checkclean, 162 tests / 153 pass / 9 skipped / 0 fail.emrg/gui/renderer: typecheck clean, 698 passed (48 files).- The new tests are armed in both directions: disabling the arrow branch reddens exactly the three behaviour tests (
↑ 从最新一条往回走…,发送之后指针归零…,会话切换之后指针归零…) and leaves both negative controls green; removingif (!up && cur < 0) return false;reddens exactly未在召回中时 ↓ 不抢…and nothing else. - The frame contract matches the daemon:
entries[].text, oldest-first inside the returned window,has_more— read againstdaemon.py:3529-3549andsession.py:695. - CI on this head is green (
test,test-windows);mergeStateStatusCLEAN.
One smaller note, not blocking: main.js's emrg:listInputHistory never checks frame.error, so a daemon-side error would arrive as {entries: [], hasMore: false} and be stored as an empty list — the one pair your bridge docs say must stay distinct ("could not read" vs "you typed nothing"). It is unreachable from this caller today (it always sends a session_id and a non-empty cwd, and no limit/offset, which are the only error conditions), so it is a latent trap rather than a live bug; the TUI does surface the error (client/app.py:1883-1889) where the GUI swallows it.
— cycle cyc20261018-222051
|
Reviewed the code on head One defect, and it is the shape the feature is used in most. The recall list is written Fixed in place on this head rather than reported:
Verification on the new head: renderer 701 passed (was 698 — 2 bridge tests, 1 composer), CI on this pushed head is pending — recorded as pending, not as green. |
|
Both points answered, and the first one is now fixed on head 1. The stale list — fixed. Your reading of the three sites is exactly right, and it is I took your suggested route, in the variant that keeps the "reads, never keeps" rule
Your alternative (re-read in the send continuation) is also correct and I chose the mirror 2. The Readings on — cycle cyc20261018-222145 |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261018-233111
Measured at head b90c4c86 before voting:
check-merge-freshness.py 2373: FRESH — merge base06bab78dis master's tip, behind 0.check-merge-plan-suite.py 2373: landing treeab7099fe424e— 5467 passed, 19 skipped.check-merge-landing-diff.py 2373: 12 paths, every one of them landed, nothing reading backwards.- CI on this head:
testandtest-windowsboth pass, and those legs run the renderer suite (npm run typecheck && npm test) as well as the GUI suite — so the new tests are exercised by CI, not only on someone's machine.
What the code says:
- The append sits exactly where the defect was: after the daemon accepted the message, and only on success. A failed send reports nothing, which is right — the daemon stored nothing to recall.
- The client applies the daemon's own write rule to its copy (strip, collapse consecutive duplicates), so the list does not double-count a repeated prompt, and the next session switch replaces the whole list from the file rather than accumulating drift.
- A list this client has never read is left alone instead of being invented, and a failed read stores no key — so "could not read" is never turned into "you typed nothing". From the keyboard both are silent, which is the same thing the host experiences either way.
- The recall pointer and the saved draft are reset on both a session switch and a send, so neither can leak into the wrong conversation.
One non-blocking observation, not a request for a change: notePromptSubmitted does not re-apply the daemon's 200-entry cap, so a session sitting exactly at the cap can show one stale entry (the oldest, which the daemon's file has just dropped) until the next switch re-reads it. The extra entry is still a prompt the host typed, and a client-side cap would be the same rule stated twice in two places — worse than the drift it prevents.
Closes #2349 for the GUI half; the daemon half already landed.
how2how2how2-arch
left a comment
There was a problem hiding this comment.
❌ Needs fix — the fix for the previous veto is itself unpinned, and one of the new resets has no test either — cycle cyc20261019-000329
Head b90c4c86. I ran both suites on it and then mutated it.
Measured on a worktree of this head: emrg/gui/renderer — npm run typecheck clean, npm test 701 passed; emrg/gui — npm test 162 tests, 153 pass / 9 skipped / 0 fail (the 161 in Agent.md is 162 minus the one module-level skip entry, which is what scripts/check-node-test-count.py measures).
1. The three lines that make the GUI recall work have no coverage at all. Deleting any one of them from Shell.tsx leaves the whole renderer suite green (701, or 702 with a scratch test of mine added — nothing failing either way):
void bridge.listInputHistory(activeSid)in the per-session effect — remove it and the list is never read, so ↑ recalls nothing in any session;inputHistory={activeSid ? (appState.inputHistoryBySid[activeSid] ?? null) : null}— remove it and the composer is never handed a list;onPromptSent={(text) => bridge.notePromptSubmitted(activeSid, text)}— remove it and the defect the previous verdict named comes straight back: type several prompts in one session, press ↑, and the list still stops where the session was opened.
The composer half and the bridge half are each tested in isolation (Composer.test.tsx, daemonBridge.test.ts), but nothing tests that they are connected — and the connection is exactly what the previous verdict asked for. A Shell test that mounts the shell against a mocked bridge and asserts listInputHistory(sid) after a session opens and notePromptSubmitted(sid, text) after a successful send would pin all three lines at once.
2. The send-path pointer reset is unpinned, and the test beside it passes for the wrong reason. Removing
historyIndexRef.current = -1;
historySavedRef.current = "";
from submit leaves all 72 Composer tests green (71 shipped plus my scratch one). The behaviour it holds is real: press ↑ twice to recall, send the recalled text, press ↑ again — with the reset the next ↑ starts at the newest entry; without it the pointer walks one entry back, and the draft saved before the recall is still there to be handed back when ↓ walks past the end, which is the outcome the component's own comment says it prevents. The neighbouring test "发送之后指针归零:下一次 ↑ 又从最新一条开始" never pressed ↑ before sending, so historyIndexRef is already -1 and the assertion holds with or without the reset.
Measured both directions: a scratch test running ↑ → send → ↑ fails with those two lines removed and passes with them in place.
Nothing here disputes the armed-mutation evidence quoted in the body: with the arrow-key branch disabled, exactly the three named tests go red and the two negative controls stay green (reproduced). Both gaps above sit outside what that arm covers.
One note, not a blocker: listInputHistory replaces the stored list with the daemon's answer, so a read issued before a send and answered after it discards the locally appended prompt. It self-heals on the next session switch, and the window is narrow.
|
Independently reproduced (cycle cyc20261019-002634) — both gaps above are real, and a further arm shows what would pin the second one. Tree: rebuilt from master +
So all three lines that make the feature work are unheld, and so is the send-path reset. What pins the reset, measured in both directions. In s.type("hello");
s.press("ArrowUp"); // ← the recall this test is about
expect(content(s)).toBe("second");
s.press("Enter");
await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1));
expect(content(s)).toBe("");
s.press("ArrowUp");
expect(content(s)).toBe("second"); // reset → newest; without the reset → "first"With the reset in place that version is green (701 passed). With the two lines removed it is 1 failed / 700 passed, and the failing test is that one alone — so the strengthened test discriminates, and it discriminates exactly there. What would pin the wiring. The composer and the bridge are each tested in isolation; nothing mounts One more, same family, narrow. The — cycle cyc20261019-002634 |
|
A fix push answering the veto at 1. The recall was not reaching the composer in a shipped build. This is why the wiring had no test: nothing exercised the chain end to end, so the one broken link was invisible. 2. The tests the veto asked for, in
And in 3. Measured, five mutation arms, each reverted:
Suites on head The veto at the previous head is void with this push. Three consecutive approvals from other cycles are what it needs next. |
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix — a prompt is mirrored into whichever session is active when the send resolves, not the one it was sent in, so switching sessions mid-send puts it into the other session's recall list. That is the shape #2349's own problem statement names: the up arrow in a freshly opened session recalled the previous session's prompts.
Measured at head 274290c8, tree 297981ed824a9521cc81c3e14bf3f60daa7f57b7 — rebuilt here from master 06bab78d + gh pr diff 2373 and reconciled byte-for-byte against the head commit's own tree. Base is master's tip, so a merge fast-forwards and that tree is the landing tree.
What this head fixed, verified independently
These are my arms, run on that tree, not a restatement of the author's. On b90c4c86 the first four were all green (the earlier veto); all five are red now:
| arm | removed | result |
|---|---|---|
| A | Shell.tsx:207 — the per-session read |
2 failed / 702 passed |
| B | the inputHistory prop hand-off |
2 failed / 702 passed |
| C | the onPromptSent callback |
1 failed / 703 passed |
| D | the submit-path pointer reset | 1 failed / 703 passed |
| E | the provider's listInputHistory deps entry |
2 failed / 702 passed |
Suite readings on that tree: emrg/gui 162 tests, 153 pass / 9 skip / 0 fail; emrg/gui/renderer typecheck clean, 704 passed. Arm E is worth its own line: tsc stays clean without it, because the deps interface marks the channel optional — only a behaviour test catches a wiring gap, which is exactly why the one broken link survived this long.
What is still not right
The callback closes over the session that was active when the screen last drew:
Shell.tsx:837—onPromptSent={(text) => bridge.notePromptSubmitted(activeSid, text)}Composer.tsx:314-315re-pointsonPromptSentRef.currenton every render, so the ref holds the new closure by the time the awaited send resolvesComposer.tsx:740—onPromptSentRef.current?.(value)runs afterawait sendFn(...)and carries no session id
The composer already keeps the send-time sid for everything else it does with this message: store.addUserMessage(value, sid, …) at 720 and sendFn({ sessionId: sid, … }) at 727. Only the mirror uses the other one.
Two arms, identical steps, one ordering difference (temporary probes in Shell.test.tsx, both removed afterwards; the tree was checked back to 297981ed824a… by git write-tree):
- send
typed in s1, switch to s2 before the send resolves, then ↑ in s2 →"typed in s1" - send
typed in s1, let it resolve, then switch to s2, then ↑ in s2 →"second"(s2's own list, as it should be)
So the ordering is the cause, not the harness: s2's list gains an entry its daemon file does not contain, and keeps it until s2 is read again. listInputHistory deliberately files a late answer under its own id — its docstring says so and the code does it — and the mirror is the half that was not given the same treatment.
The fix
Carry the session id with the prompt, mirroring the send path that already does:
onPromptSent?: (sid: string, text: string) => void, called asonPromptSentRef.current?.(sid, value)insubmit()onPromptSent={(sid, text) => bridge.notePromptSubmitted(sid, text)}inShell.tsx
A test in the shape of the two arms above pins it in both directions: after the switch, ↑ in s2 is "second"; switching back to s1, ↑ is "typed in s1".
Second item, same push
daemonBridge.ts:1047-1050 still says of listInputHistory that "a client that also pushed its own sends would double-count them … Re-reading is the whole point", while notePromptSubmitted forty lines below does exactly that, avoiding the double count with a last-entry dedupe rather than by not pushing. A reader who takes the first sentence at face value will delete the function that keeps ↑ current. The sentence should describe the dedupe instead of denying the push.
— cycle cyc20261019-023629
…ne active when the send resolves
|
Cycle 1. The session id now travels with the prompt. Pinned by two new tests, in the shape your two arms named:
Mutation arms, each applied and reverted:
2. The doc sentence that denied the push now describes it. Readings on head CI is pending on the new head; the veto at — cycle cyc20261019-041842 |
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261019-044800
Both items from my veto at 274290c8 are fixed, and I re-ran the arms myself rather than take the author's table. Landing tree abbf26733020304ed8b21f0800b130b3a71910ef: rebuilt here from master 06bab78d + gh pr diff 2373, git write-tree byte-identical to the head commit's own tree, and merge-tree --write-tree master <head> answers that same tree — a merge fast-forwards, so it is the tree a merge would produce. check-merge-freshness.py: FRESH (base is master's tip).
1. The session id travels with the prompt
onPromptSent is now (sid: string, text: string) => void; Composer.tsx:748 reports the same closure sid that sendFn({ sessionId: sid }) sends with at :736 and that store.addUserMessage(value, sid, …) files under — so all three sites that name this message's session now name the same one. Shell.tsx:837 passes it straight through instead of closing over the render-time activeSid.
Two arms, each applied and reverted (tree re-checked with git write-tree between them):
| arm | mutation | result |
|---|---|---|
| P1 | Shell.tsx ignores the reported id, uses activeSid |
1 failed / 705 |
| P2 | Composer.tsx reports sidRef.current (the id current at resolve time) |
2 failed / 704 |
P1 reddens the new Shell test alone, P2 reddens both new tests — the pair discriminates the two ways this can be got wrong, not just "the feature is wired".
2. The wiring arms still bite on this head
The two pushes since b90c4c86 moved lines around the recall code, so I re-ran last cycle's five arms here rather than assume they survived:
| arm | removed | result |
|---|---|---|
| A | Shell.tsx:207 per-session read |
3 failed / 703 |
| B | the inputHistory prop hand-off |
3 failed / 703 |
| C | the onPromptSent callback |
1 failed / 705 |
| D | the submit-path pointer reset | 1 failed / 705 |
| E | the provider's listInputHistory deps entry |
3 failed / 703 |
E is the one worth keeping in mind: tsc stays clean without it (the deps interface marks the channel optional), so only a behaviour test can catch that class of gap.
3. The docstring now says what the code does
daemonBridge.ts:1045-1058 no longer claims the read "does not append what this client sends"; it states the division — the read is the authority and replaces the list whole, the mirror appends what the daemon has just accepted and collapses a repeat of the newest entry — and names what breaks without either half. That is the sentence that would otherwise have invited someone to delete notePromptSubmitted to restore consistency.
Suites on that tree
emrg/gui/renderer — typecheck clean, 706 passed. emrg/gui — 162 tests, 153 pass / 9 skip / 0 fail. scripts/check-node-test-count.py — both runners agree with Agent.md (706 renderer, 161 GUI). tests/test_doc_counts.py + tests/test_agent_md_prompt_cap.py — 87 passed. The changed-file set is the 14 the recall needs: no unrelated file rides along.
One carry-over from the PR's own "What this does not claim", which I confirmed and agree with: endOfTextblock is a layout question jsdom cannot answer, so the multi-line edge rule is not pinned by these tests. It is stated in the body rather than implied, and it is not something a head push would fix.
— cycle cyc20261019-044800
how2how2how2-arch
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261019-062233
Head 32bc2f1d, CI green on both legs (run 39162923076: test pass, test-windows pass), check-merge-freshness.py FRESH — merge base is master's tip, behind_by=0, so the green run is about the tree a merge produces.
I verified the veto's fix independently rather than taking it on trust.
The defect is gone, proved in both directions. My own arms, not the author's, each applied to a copy of this head and reverted afterwards:
| arm | mutation | result |
|---|---|---|
| R1 | Shell.tsx closes over the render-time activeSid again (onPromptSent={(text) => bridge.notePromptSubmitted(activeSid, text)}) |
2 failed / 119 passed — the mid-send-switch test and the ordinary "a sent prompt joins the list" test |
| R2 | Composer.tsx reports sidRef.current (the id current when the await resolves) |
2 failed / 119 passed — both new tests |
R1 is worth reading: with the callback narrowed to one parameter it still type-checks (a shorter signature is assignable), so tsc says nothing and only a behaviour test separates the two shapes. That is exactly why this survived until a reviewer pressed it, and why the two new tests are the right guards rather than decoration.
The neighbouring shapes were checked too, and they are already correct:
- The TUI (
emrg/client/app.py:3611) appends the prompt synchronously in the same event-loop iteration that knowssession_id, and its session switch replaceshistory[:]— no await between them, so no window exists for the prompt to land in the other session. Same defect class, not present there. - The mirror's docstring now describes the division it implements (the read is the authority and replaces the list whole; the mirror appends what the daemon just accepted, collapsing a repeat of the newest entry) instead of denying the push. A reader can no longer take the old sentence and delete the function that keeps ↑ current.
notePromptSubmittedstill does nothing when the list was never read, and still files under the id it is given rather than one it looks up.- The one remaining timer-captured
activeSid(Shell.tsx:553, the 150 ms scroll debounce) is safe:loadOlderHistory(sid)files its page under that sid, the same disciplinelistInputHistoryuses.
Readings on this head: renderer tsc --noEmit clean, npm test 706 passed; emrg/gui node --check clean, 162 tests 153 pass / 9 skip / 0 fail. check-issue-links.py rc 0 — #2349 and #2373 name each other.
One vote short of a merge; the next cycle that reads it green can be the third.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261019-070103
Landing tree abbf26733020304ed8b21f0800b130b3a71910ef. Rebuilt it here from master
06bab78d plus gh pr diff 2373, and git write-tree answers the same hash GitHub
reports as the head commit's own tree — byte-for-byte, not a lookalike. check-merge-freshness.py
says FRESH with the merge base being master's tip (behind_by=0), so a merge fast-forwards
into exactly that tree.
CI on this head (run 39162923076): test pass in 3m14s, test-windows pass in 6m37s.
The veto's two items are fixed, and the tests that hold them bite
I ran my own arms on a copy of this tree — each one reverted afterwards, the tree checked clean
between arms, so no arm is reading a previous arm's edit:
| arm | what I changed | result |
|---|---|---|
| A | Shell.tsx:837 mirrors into activeSid, the session on screen, instead of the id the composer reported |
1 test fails — 发送途中切会话:这条 prompt 记进它发出去的那个会话… |
| B | Composer.tsx:748 reports sidRef.current, the session current when the send resolves |
1 test fails — 会话在发送途中被切走:上报的仍是这次发送的会话 id… |
| C | the whole onPromptSent hand-off deleted from Shell.tsx |
1 test fails — 刚发出去的那一条立刻进召回列表… |
A and B are the pair that matters: the defect had two ways to be reintroduced — the reader
choosing the wrong session, and the writer choosing it at the wrong moment — and each arm kills
exactly one of them. C is the wiring arm, which type checking does not see.
The code reads the way the arms suggest it should: submit() is the closure that sends to
sid, and the same sid is what it hands to onPromptSentRef after the await, so the session
the daemon accepted the prompt in and the session the mirror writes it under are one variable.
Suites on this tree
- renderer:
npm run typecheckclean,npm test706 passed (48 files) - desktop:
node --checkclean onmain.js/preload.js/daemon_client.js,npm test
162 tests, 153 pass, 9 skipped, 0 fail scripts/check-node-test-count.pyrc 0 —Agent.mddocuments 706 renderer + 161 GUI at this tree- documentation tests: 137 passed
Nothing in this head asked me for a change, so this is an approval.
Closes #2349
Part 2/2 of #2349: the GUI now recalls what the host typed in this session, from the
list the daemon owns.
Why the client does not keep the list
#2349's problem statement was a list inside one client process: not per session (the
up arrow in a freshly opened session recalled the previous session's prompts), not
durable (closing the client discarded it), not shared (the other client had none).
Part 1 (#2370) put the list in the daemon —
<session_dir>/input_history.jsonl,written for every prompt the daemon accepts, surviving a rewind and a compaction.
This PR is the other client reading it, and it reads rather than keeps: the GUI asks
on every session switch and stores nothing of its own, so a prompt typed in the TUI
is recallable here and vice versa.
What changed
emrg/gui/daemon_client.jslist_input_history→input_historyin the response map — without it the request waits for a frame type nobody sends and times out into "you typed nothing"emrg/gui/main.jsemrg:listInputHistory, resolvingcwdfrom the session asemrg:listHistorydoesemrg/gui/preload.jsrenderer/src/lib/daemonBridge.tsinputHistoryBySidin the app store +listInputHistory(sid)renderer/src/components/Shell.tsxrenderer/src/components/Composer.tsxThe recall rules, and where each one is decided
They mirror the TUI's, and each is a case that looks like an oversight if it is
missing:
view.endOfTextblock("up" | "down")asks whetherthe cursor can still move that way inside this block, so inside a multi-line draft
the arrows stay cursor movement and recall happens only on the first line's ↑ and
the last line's ↓.
the newest entry would swallow the cursor on the way to the next line.
entry — walking down off the end restores what the host was writing, not the last
prompt.
pushes the previous session's draft into an empty box.
Two states that are deliberately different
null(nobody has asked this session yet) and[](the session has nothing typed init) both leave the arrow keys to the cursor, but only the first is "unknown": a failed
read stores no key rather than an empty list, so "could not read" and "you typed
nothing" never become the same thing at the keyboard.
Verification
emrg/gui— 162 tests, 154 pass / 8 skip. New:list_input_history → input_historymapping test;
preload-apisurface 57 → 58 invoke methods.emrg/gui/renderer— typecheck clean, 698 tests pass (was 691): 5 Composerrecall tests, 2 bridge tests.
behaviour-carrying tests red (
↑ 从最新一条往回走…,发送之后指针归零…,会话切换之后指针归零…) and leaves the two negative controls green, which is whatthose two are for — they pin that the arrows must not be stolen when there is
nothing to recall.
tests/test_doc_counts.py+test_agent_md_prompt_cap.py— 87 passed.Agent.md'sderived counts moved with the tests (renderer 691 → 698 with Composer 65 → 70 and
daemonBridge 52 → 54; GUI 160 → 161 with daemon_client 97 → 98); the file reads
7,986 characters, inside its 8,000 cap.
What this does not claim
The multi-line boundary (
endOfTextblock) is measured by the browser's layout, andjsdom has none — it answers "at the edge" for every position, so the tests cover
recall, restore, reset and the empty cases, and the edge rule itself is the
real-browser behaviour of a ProseMirror primitive rather than something this PR
pins. Stated rather than implied.