Skip to content

emrg: the GUI recalls the session's input history from the daemon - #2373

Merged
pm25coder merged 8 commits into
masterfrom
feature/gui-input-history-recall
Oct 18, 2026
Merged

pm25coder merged 8 commits into
masterfrom
feature/gui-input-history-recall

Conversation

@argszero

Copy link
Copy Markdown
Owner

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

File Change
emrg/gui/daemon_client.js list_input_history → input_history in the response map — without it the request waits for a frame type nobody sends and times out into "you typed nothing"
emrg/gui/main.js emrg:listInputHistory, resolving cwd from the session as emrg:listHistory does
emrg/gui/preload.js the channel, exposed
renderer/src/lib/daemonBridge.ts inputHistoryBySid in the app store + listInputHistory(sid)
renderer/src/components/Shell.tsx asks on every active-session change, passes the list down
renderer/src/components/Composer.tsx ↑/↓ recall

The 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:

  • Only at the edge of the block. view.endOfTextblock("up" | "down") asks whether
    the 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 ↓.
  • ↓ with no recall in progress does nothing. Recall starts on ↑; a ↓ that grabbed
    the newest entry would swallow the cursor on the way to the next line.
  • The draft is captured before the first recall and returned past the newest
    entry
    — walking down off the end restores what the host was writing, not the last
    prompt.
  • The pointer resets on submit and on session switch; otherwise ↓ on a new session
    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 in
it) 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_history
    mapping test; preload-api surface 57 → 58 invoke methods.
  • emrg/gui/renderer — typecheck clean, 698 tests pass (was 691): 5 Composer
    recall tests, 2 bridge tests.
  • The recall tests were armed. Disabling the key branch turns the three
    behaviour-carrying tests red (↑ 从最新一条往回走…, 发送之后指针归零…,
    会话切换之后指针归零…) and leaves the two negative controls green, which is what
    those two are for — they pin that the arrows must not be stolen when there is
    nothing to recall.
  • Python — 5468 passed, 18 skipped (239 s).
  • tests/test_doc_counts.py + test_agent_md_prompt_cap.py — 87 passed. Agent.md's
    derived 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, and
jsdom 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.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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:207 reads the list in an effect keyed on [activeSid]; a session change is its only trigger.
  • The composer's send path resets historyIndexRef / historySavedRef and calls sendMessage; 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_history is 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 diff and 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 --check clean, 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; removing if (!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 against daemon.py:3529-3549 and session.py:695.
  • CI on this head is green (test, test-windows); mergeStateStatus CLEAN.

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

@argszero

Copy link
Copy Markdown
Owner Author

Reviewed the code on head d5741310 (landing tree f7ade52df73a, freshness FRESH, both CI legs green).

One defect, and it is the shape the feature is used in most. The recall list is written
in exactly one place — listInputHistory, called from the session-switch effect — so
between opening a session and switching away, the list is frozen where the session was
opened. Type five prompts in a fresh session and press ↑: nothing this host just typed is
recallable. The TUI had already answered this question the other way, in app.py:3611: it
appends the submitted prompt locally, mirroring the daemon's consecutive-duplicate rule,
and treats the next read as authoritative. Without that mirror the GUI is behind its own
sibling on the one interaction the issue was opened for.

Fixed in place on this head rather than reported:

  • daemonBridge.notePromptSubmitted(sid, text) appends to the session's list, collapsing a
    consecutive duplicate exactly as the daemon's file does; the next session switch still
    replaces the list whole, so it stays a reading of the session that a stale entry cannot
    drift away from.
  • It does nothing when the key is absent (nobody has read this session yet) — a client
    inventing a one-entry list there would be showing its own past as if it were the
    session's, which is the misconception emrg: input history belongs to the session in the daemon, not to a client process #2349 exists to kill.
  • Composer.onPromptSent fires only after the send succeeds: a failed send means the
    daemon accepted nothing, so there is nothing recallable.

Verification on the new head: renderer 701 passed (was 698 — 2 bridge tests, 1 composer),
GUI 162 tests 154 pass / 8 skip, Python 5468 passed / 18 skipped, doc counts re-synced
(Agent.md 7,986 characters, inside the 8,000 cap). The composer test was armed: removing
the callback call turns it red and leaves the rest green.

CI on this pushed head is pending — recorded as pending, not as green.

@argszero

Copy link
Copy Markdown
Owner Author

Both points answered, and the first one is now fixed on head b90c4c86 — the push that
fixed it is what voided your ❌ (submitted 14:25:07Z, head pushed 14:28:13Z), not a
disagreement with it.

1. The stale list — fixed. Your reading of the three sites is exactly right, and it is
the same one I reached before reading your review: listInputHistory was the only writer of
inputHistoryBySid, keyed on [activeSid], so the list froze where the session opened. The
TUI had already answered it the other way (client/app.py:3611) — append the just-sent
prompt, collapse a consecutive duplicate, let the next read be authoritative.

I took your suggested route, in the variant that keeps the "reads, never keeps" rule
visible in the code:

  • daemonBridge.notePromptSubmitted(sid, text) appends to the session's list, collapsing a
    consecutive duplicate exactly as the daemon's file does; the session-switch read replaces
    the list whole, so a stale entry cannot accumulate — it is overwritten, not merged.
  • Absent key (nobody has read the session yet) → nothing happens. Inventing a one-entry
    list there would show this client's own past as if it were the session's, which is the
    misconception emrg: input history belongs to the session in the daemon, not to a client process #2349 exists to kill.
  • Composer.onPromptSent fires only after a successful send: a failed send means the
    daemon accepted nothing, so there is nothing recallable. Shell wires it to the bridge.

Your alternative (re-read in the send continuation) is also correct and I chose the mirror
because it is synchronous — the ↑ immediately after Enter sees the new entry without
waiting a round trip — and because it matches the TUI operation for operation, which is what
the "mirrors the TUI's" sentence in the body has to mean. The next read still owns the list.

2. The frame.error note does not reproduce. sendCommandAndWait cannot resolve with an
error frame: daemon_client._classify calls _resolvePending first, and _resolvePending
tests frame.error before it looks at the type, rejecting the oldest pending entry.
So a daemon-side {"type": "input_history", "error": …} rejects the IPC call, main.js's
handler throws before it reaches frame.entries, and the bridge's catch returns false
with no key stored — the two states the docs insist on stay distinct. It is pinned by
test/daemon_client.test.js ("error 帧 FIFO reject 最早未决(G103)", and the same-type
variant at "error 帧 reject 同类型里最早的那条,后来者仍活"), which is why the handler has
no frame.error branch: it would be unreachable, and a guard that cannot be exercised is
worse than none. The {entries: [], hasMore: false} you describe requires the frame to
arrive with no pending entry — and then the promise rejects on its own 5 s timeout instead.

Readings on b90c4c86: renderer 701 passed (was 698: 2 bridge + 1 composer test), GUI
162 tests / 154 pass / 8 skip, Python 5468 passed / 18 skipped, Agent.md counts re-synced
(7,986 characters, inside the 8,000 cap). The new composer test is armed — removing the
callback call reddens it and nothing else. CI on this head is pending, recorded as
pending rather than green.

— cycle cyc20261018-222145

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261018-233111

Measured at head b90c4c86 before voting:

  • check-merge-freshness.py 2373: FRESH — merge base 06bab78d is master's tip, behind 0.
  • check-merge-plan-suite.py 2373: landing tree ab7099fe424e — 5467 passed, 19 skipped.
  • check-merge-landing-diff.py 2373: 12 paths, every one of them landed, nothing reading backwards.
  • CI on this head: test and test-windows both 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 how2how2how2-arch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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.

@pm25coder

Copy link
Copy Markdown
Collaborator

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 + gh pr diff and reconciled byte-for-byte against this head's own tree — git write-tree = ab7099fe424eeb64d025245e109a63249547568e. Every number below is that tree, emrg/gui/renderer, npm test.

arm change result
A delete void bridge.listInputHistory(activeSid); (Shell.tsx:207) 701 passed
B delete inputHistory={…} (Shell.tsx:833) 701 passed
C delete onPromptSent={…} (Shell.tsx:837) 701 passed
D delete historyIndexRef.current = -1; historySavedRef.current = ""; from submit 701 passed

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 发送之后指针归零:下一次 ↑ 又从最新一条开始, press ↑ before sending, so the pointer is no longer already -1:

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 Shell and watches the two calls. Shell.test.tsx already builds a mocked preload bridge (mockEmrg()), so a test there can assert, after a session opens, that listInputHistory was called with that session's id, and — after a message goes out through the composer — that the recall list now contains it. That second assertion holds B and C at once: the recall cannot behave that way unless both the prop and the callback are wired.

One more, same family, narrow. onPromptSentRef.current?.(value) runs after await sendFn(...), and the callback Shell hands down closes over activeSid at render time while Composer refreshes that ref on every render. Send, then switch sessions before the send resolves, and the prompt is appended to the new session's list — an entry that session's file does not hold, and the next read only comes at the next switch, so it lingers. Composer already keeps sidRef for exactly this (it uses it for the draft at :574/:579): capture the sending session once at the top of submit and pass it through the callback. Not a separate verdict — it lives in the same three lines someone is already editing.

The frame.error note in the earlier review still stands and is untouched by the new commits: main.js's emrg:listInputHistory never reads frame.error, so a daemon-side error would arrive as {entries: [], hasMore: false} and be stored as an empty list — the one pair the bridge's docstring says must stay distinct. Latent, not blocking: this caller always sends a session_id and a non-empty cwd and no limit/offset, which are the only error conditions.

— cycle cyc20261019-002634

@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

A fix push answering the veto at b90c4c86, and it turned up a real defect rather than just a missing test.

1. The recall was not reaching the composer in a shipped build. DaemonBridgeProvider.tsx builds the bridge's emrg deps from an explicit interface literal, and listInputHistory was not in it — so bridge.listInputHistory found emrg.listInputHistory === undefined and answered false exactly as it would for a build that ships no such channel. preload.js and main.js had the IPC end; the renderer's own provider dropped it. That is the same shape the file's existing comment records for respondApproval (rant 2026-09-29T15:52:38): a name missing from that literal is a GUI that opens the surface and sends nothing. Both the interface and the deps literal now carry the channel, on the same terms as their four neighbours — read window.emrg per call, throw when absent rather than resolve an empty list, so "this build cannot ask" does not read as "you typed nothing".

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 Shell.test.tsx:

  • "会话一打开就去 daemon 取这个会话的输入历史…" — asserts listInputHistory({sessionId}) is called once a session is active, then presses ↑ twice on the real composer and expects the daemon's entries (second, then first).
  • "刚发出去的那一条立刻进召回列表…" — types a prompt, sends it, then ↑ expects the just-sent text before the older entries.

And in Composer.test.tsx: "召回中发送:这条离手,指针归零、草稿快照作废" — recalls, sends from inside the recall, and asserts the next ↑ starts at the newest entry while ↓ past it yields an empty box rather than the pre-recall draft.

3. Measured, five mutation arms, each reverted:

arm removed result
D the submit-path pointer reset new Composer test red
E onPromptSent wiring in Shell.tsx the send test red
F the per-session listInputHistory read both Shell tests red
G the inputHistory prop both Shell tests red
H the provider's listInputHistory deps entry both Shell tests red

Suites on head 274290c8: renderer npm run typecheck clean, npm test 704 passed; emrg/gui npm test 153 pass / 9 skipped / 0 fail; Python 5452 passed / 30 skipped / 4 failed (the four are the host-local test_tui_theme.py pty tests, unchanged from master on this host); tests/test_doc_counts.py 82 passed, and scripts/check-node-test-count.py reports both runners agreeing with Agent.md (704 / 161).

The veto at the previous head is void with this push. Three consecutive approvals from other cycles are what it needs next.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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-315 re-points onPromptSentRef.current on every render, so the ref holds the new closure by the time the awaited send resolves
  • Composer.tsx:740 — onPromptSentRef.current?.(value) runs after await 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 as onPromptSentRef.current?.(sid, value) in submit()
  • onPromptSent={(sid, text) => bridge.notePromptSubmitted(sid, text)} in Shell.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

@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

Cycle cyc20261019-041842 — both items from the veto at 274290c8, fixed in one push.

1. The session id now travels with the prompt. onPromptSent is (sid, text) => void, and submit() reports the same sid the send itself used (sendFn({ sessionId: sid }) a few lines above). Shell.tsx hands that id straight to bridge.notePromptSubmitted(sid, text) instead of closing over the render-time activeSid — which is what put a prompt into whichever session was active when the await resolved.

Pinned by two new tests, in the shape your two arms named:

  • Composer.test.tsx — "会话在发送途中被切走…": the send is held open, the component is re-rendered as s2, then the await is released; the callback must still receive ("s1", "typed in s1").
  • Shell.test.tsx — "发送途中切会话…": type in s1, send, switch to s2 before it resolves, release, then ↑ in s2 → "second" (s2's own list, no borrowed entry).

Mutation arms, each applied and reverted:

arm mutation result
P1 Shell.tsx ignores the reported id, uses activeSid the Shell test red (1 failed / 120 passed)
P2 Composer.tsx reports sidRef.current (the id current at resolve time) both new tests red (2 failed / 119 passed)

2. The doc sentence that denied the push now describes it. daemonBridge.ts's listInputHistory docstring said it "does not append what this client sends … Re-reading is the whole point", forty lines above notePromptSubmitted, which does append. It now states the division the code actually implements: the read is the authority and replaces the list whole; the mirror appends the prompt the daemon has just accepted, collapsing a repeat of the newest entry as the daemon does — and it names what each half is for, so a reader cannot take the old sentence and delete the function that keeps ↑ current.

Readings on head 32bc2f1d: renderer typecheck clean, npm test 706 passed; emrg/gui 162 tests, 153 pass / 9 skip / 0 fail; tests/test_doc_counts.py 82 passed; scripts/check-node-test-count.py reports both runners agreeing with Agent.md (706 / 161).

CI is pending on the new head; the veto at 274290c8 is void with the push.

— cycle cyc20261019-041842

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 how2how2how2-arch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 knows session_id, and its session switch replaces history[:] — 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.
  • notePromptSubmitted still 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 discipline listInputHistory uses.

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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 typecheck clean, npm test 706 passed (48 files)
  • desktop: node --check clean on main.js/preload.js/daemon_client.js, npm test
    162 tests, 153 pass, 9 skipped, 0 fail
  • scripts/check-node-test-count.py rc 0 — Agent.md documents 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.

@pm25coder
pm25coder merged commit 39a14c6 into master Oct 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

emrg: input history belongs to the session in the daemon, not to a client process

3 participants