Repository navigation
fix(native-chat): enforce each pending send's own boundary in glue matching (STA-4477) - #14935
Conversation
…tching Glue matching filtered candidate rows against the OLDEST still-open send and then matched the entire open queue against them. A prompt queued after a glued row landed could therefore be judged "already delivered" by that older row and pruned — the queued prompt disappeared with no bubble and no transcript turn. Each send now carries its own transcript boundary into the match: `gluedCandidateRows` tags every candidate row with the set of pending indices it actually landed after, and the matcher stops a run at the first send the row predates rather than skipping over it (adjacency is what makes a row glue). Exact single matches still belong to the occurrence path, unchanged. `native-chat-pending.ts` sat at 299 of its 300 effective-line budget, so the slash-command marker cache — a separate rule that never took part in pending pruning — moves verbatim to `native-chat-command-marker.ts`. Pure move: no behavior change, imports only. (max-lines is never bumped or disabled.) Refs STA-4477. Original PR #14663.
📝 WalkthroughWalkthroughThe change moves command-marker storage and processing into a dedicated module. Transcript matching now returns message IDs with normalized text. Pending-send glue matching tracks representable pending indices and respects send boundaries. Pending pruning and optimistic rendering use the new row-based APIs. Tests cover boundary ordering, adjacency, partial retirement, and visibility. The benchmark now exercises and asserts the row-based matching flow. Merge Risk: 🟡 Moderate · up to The change fixes per-send transcript boundaries, but an image-only pending message can still be skipped when matching a merged row, potentially causing surrounding text prompts to be incorrectly retired and lost. Merge should wait for this bounded correctness issue to be fixed. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Review: CLEAN — no blocking defectsRe-verified against head Read this first: #14935 and #14980 are complementary, not alternativesThis PR reconciles an already-merged transcript row downstream. The reason a merged row exists at all is that Orca manufactures it. Verified in the code at this head:
Two overlapping sends sit in their gap concurrently and interleave, landing as one agent input line. #14980 (draft) adds the per-PTY submit queue that closes that. This PR is still worth landing on its own. Not just "defense in depth" — there is a concrete hole. A per-PTY send queue already exists for the desktop composer ( Scope note, which explains why the desktop and mobile halves are separate PRs: this change is desktop-renderer-only. Mobile does not share Two things I turned up that belong to #14980, not here, but should not get lost:
Prior-art conformance: (a) CONFORMSHead-of-queue-only run consumption is the established discipline for this problem, and this fix restores it. Before, the matcher filtered candidate rows against the oldest still-open send and then matched the whole open queue against them, letting a run skip over a send it should have stopped at. Now it Non-vacuity — what is actually test-provenThree surgical mutations against
The regression test is a real regression test in both directions, and the fix is proven at both consumers. Lint gate proven live, not inferred. The commit moves the slash-command marker cache out to Move is verbatim. I diffed the moved block against the new file body: byte-identical. All six moved symbols have their consumers updated ( What is NOT covered — please weigh these
Everything in the mutation table and the lint/line-count section is executed and test-proven. The root-cause citations are read directly from the files at this head. Items 1–3 are the honest gaps. |
…er path The `break` on a send the row cannot represent is the fix's central semantic choice, and swapping it for `continue` was passing the whole suite: nothing exercised a queue whose middle send is unrepresentable. Add that case. The mixed-age case also asserted both call sites in one `it`, so a prune-path failure masked the render-path assertion — and the render path is the one that makes a queued bubble visually vanish. Split it. Skip the per-send boundary scans when fewer than two sends are open: the glue matcher already returns nothing there, so a lone queued echo was walking the transcript twice per render for a discarded result.
Electron QA (desktop native-chat glue send boundary)Isolated PR-head instance ( What was stagedOpened a native-chat Claude pane on a throwaway local project (experimental native chat on). Sent two real composer prompts so optimistic queued bubbles appeared:
Then wrote one concatenated user row (no assistant turn) into the pane’s watched transcript:
Those two pending bubbles retired and the glued row rendered. After that row was already on screen, sent a third composer prompt:
Then, on the same instance, edited What was observed
AFTER (fix): glued row present, BEFORE (oldest-only matcher via HMR): same glued row, third bubble gone AFTER restored + resent C: third bubble back Queued bubbles before the glued row: What was NOT reproducedLive agent glue was not produced. A running agent never concatenated two fast TUI sends onto one input line. The glued user row was written into the watched transcript after two real composer sends. HMR of the pending module cleared the in-memory queue, so the restored-fix confirmation is a resend of C, not the same cache entry coming back. Not exercised: mobile, Windows/WSL, SSH/Linux, or a real assistant reply after glue. |
Review session: CLEAN to merge — one review round found a real test gap, fixed in
|
| Mutation | Result |
|---|---|
Off-by-one on the boundary index (index + 1 → index) |
10 RED |
| Disable the per-send guard (pre-fix behavior) | 2 RED — was 1 RED before the split |
| Over-apply the guard (nothing representable) | 10 RED |
Break only the pendingSendsAsMessages call site |
1 RED |
Adjacency break → continue |
1 RED (was 0 RED pre-ee94f1bc) |
The guard is fenced in both directions — under-applying and over-applying both go red — and the off-by-one class that shipped three times in this file is now pinned.
Desktop QA — before/after on one instance
Isolated PR-head Electron instance driven over Playwright CDP, fresh profile (window.api.repos.list() returned [] on first attach). Two real composer sends produced queued bubbles; a concatenated user row was written into the watched transcript; a third prompt was then queued after that glued row was already on screen.
- With the fix: the third bubble survived.
- After HMR to the oldest-only matcher: the third bubble vanished — the exact STA-4477 symptom.
- After restoring the fix: it survived again.
- 0 console errors (4 pre-existing warnings, none new).
Screenshots are in the QA comment below. Not reproduced: a live agent actually concatenating two fast TUI sends — the glued row was written into the watched transcript after two real sends. Mobile, Windows/WSL and SSH/Linux legs are out of scope for this change and were not exercised.
Precedent
Checked against mature implementations of the same optimistic-echo reconciliation problem. Three things line up:
- Applying one queue-wide anchor boundary to un-positioned optimistic rows is genuinely wrong, and none of them do it. Carrying the boundary per optimistic send is the correct formulation — so fix(native-chat): stop glued rapid sends from pinning queued bubbles #14663's candidate-set filtering was the defect and this PR's direction is right.
- Head-only, stop-on-mismatch traversal is the established idiom for walking a queue against a coalesced row. The
breakis precedent-aligned, not a judgment call. - The low-pending early-out is the load-bearing optimization in every one of them, precisely because the steady state is an empty queue. That is what
ee94f1bcrestores. Memoizing the per-send eligible-row set would be the wrong shape — the transcript changes on nearly every token, so the hit rate would be ~0.
Perf (measured, 2000-message transcript, mean over 200 calls, 3 runs)
pre-ee94f1bc |
at head | |
|---|---|---|
| 1 pending (common case) | 0.050–0.066 ms | 0.020–0.021 ms |
8 pending (PENDING_SEND_LIMIT) |
0.29–0.71 ms | 0.25–0.31 ms |
~2.6× faster on the common path, unchanged at max queue depth. Both call sites are effect/useMemo-gated on [pending, messages] (NativeChatView.tsx:219-223, 291-294), so this recomputes when the transcript or pending set changes, not per render. Trivially bounded.
Readiness
- Wire compatibility: no wire change.
NativeChatPendingSend/NativeChatUserRow/NativeChatGluedUserRowappear in exactly three renderer files and never cross an IPC/RPC/preload surface. - Security / supply chain: no dependency, lockfile, binary, or script changes — all six changed files are under
components/native-chat/. - Backcompat / data loss: no persisted schema, settings, or migration touched; this is client-local React state. No mobile-facing surface (the mobile equivalent lives in its own tree).
- Unbounded growth: caches stay bounded (
PENDING_SEND_LIMIT/COMMAND_MARKER_LIMIT= 8, plus LRU key bounding). New allocations are per-call locals bounded by open sends. - Cross-platform: pure TypeScript logic — no paths, shells, PTYs, or native modules.
- Module split: forced, not scope creep.
native-chat-pending.tssat at 299 effective lines against a 300 cap, so any added line broke it. The moved block is byte-identical to the removed one and the remaining import is still legitimately used.max-lineswas neither bumped nor disabled — I proved the gate is live by planting 80 code lines:error eslint(max-lines): File has too many lines (319). Maximum allowed is 300.
Gates
- 73 files / 770 tests pass at head.
- Merged tree materialized and run against current
main— not just a cleanmerge-tree: 770 pass, 0 conflicts. - CI green against exactly
ee94f1bc39: 44 pass, 1 skipping (e2e), 0 failures.MERGEABLE / CLEAN. oxfmt: all changed files correctly formatted.
Residual risk / follow-ups (not blocking, not addressed here)
- Pre-existing, out of scope: an image-only pending send (empty text) is
continued rather than treated as a run-breaker, so it does not break glue adjacency between the text sends around it. This predates the PR and I did not adjudicate it fully — worth a follow-up ticket rather than a change here. - Architectural, well beyond STA-4477: the cleanest long-term shape is to mint a stable correlation id per optimistic send at submit time and have the transcript row carry it, or to coalesce queued sends into one optimistic entry before dispatch so the N→1 reconcile case never arises. Either would delete the matching, boundary, and ordering problems outright. Deliberately not attempted here.
- Two review-agent rounds did not return a written verdict (they were starved under heavy machine load). Round 2 ran to completion and changed nothing, but I am recording that as "no written sign-off" rather than claiming a clean round I did not receive — the mutation battery, perf numbers, merged-tree run and lint-gate proof above are all my own executed evidence.
Verdict: no P0, no P1. Ready to merge.
…glue exports Renaming the glue matcher's exports left this caller behind, and it crashed at runtime after printing six result rows: TypeError: matchingNativeChatUserTexts is not a function No gate caught it. config/scripts/** is in no tsconfig include and the file is not a *.test.ts, so neither typecheck nor vitest ever loads it. The empty-pending arm passes no pending sends, so the matcher takes its empty-queue exit without ever reading the rows — which is also why the renderer skips candidate-row construction entirely in that case. Escaping the row scan directly keeps what this arm actually measures identical to before, rather than fabricating per-row boundary sets that no production path builds.
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ea9d5998-1bec-44fc-aea4-3c987719b80a
📒 Files selected for processing (7)
config/scripts/native-chat-live-session-benchmark.tssrc/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsxsrc/renderer/src/components/native-chat/NativeChatView.tsxsrc/renderer/src/components/native-chat/native-chat-command-marker.tssrc/renderer/src/components/native-chat/native-chat-pending-occurrence.tssrc/renderer/src/components/native-chat/native-chat-pending.test.tssrc/renderer/src/components/native-chat/native-chat-pending.ts
Included review availability: Your plan includes up to 10 reviews per rolling hour; 0 remain after this review.
| // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, | ||
| // sessionId changes on every /clear) is ephemeral and was never evicted, so it | ||
| // grew one entry per (pane, session) for the renderer's whole life. LRU-bound | ||
| // the key count (mirrors the #7566 draft/attachment caches in this folder). |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Shorten the cache-history comment.
Lines 52-55 include historical detail that the code does not need. Keep one brief comment that states why scope keys need bounding.
Proposed change
- // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId,
- // sessionId changes on every /clear) is ephemeral and was never evicted, so it
- // grew one entry per (pane, session) for the renderer's whole life. LRU-bound
- // the key count (mirrors the `#7566` draft/attachment caches in this folder).
+ // Bound scope keys because session IDs rotate after `/clear`.As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId, | |
| // sessionId changes on every /clear) is ephemeral and was never evicted, so it | |
| // grew one entry per (pane, session) for the renderer's whole life. LRU-bound | |
| // the key count (mirrors the #7566 draft/attachment caches in this folder). | |
| // Bound scope keys because session IDs rotate after `/clear`. |
Source: Coding guidelines
| if (represented.has(entry.index) || entry.text.length === 0) { | ||
| continue | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Stop the glue run at an unresolved empty-text send.
continue skips an image-only pending send. A queue of first, image-only, and second can then match one firstsecond row and retire first and second as adjacent glue. The image send is an unmatched boundary.
Keep skipping already represented entries. Break when an unresolved entry has empty normalized text.
Proposed fix
- if (represented.has(entry.index) || entry.text.length === 0) {
+ if (represented.has(entry.index)) {
continue
}
+ if (entry.text.length === 0) {
+ break
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (represented.has(entry.index) || entry.text.length === 0) { | |
| continue | |
| } | |
| if (represented.has(entry.index)) { | |
| continue | |
| } | |
| if (entry.text.length === 0) { | |
| break | |
| } |
Follow-up: round 2 CLEAN, plus one real defect this PR had introduced (
|
Final review-loop status (post-merge,
|
| before the round-1 guard | merged | |
|---|---|---|
| 1 pending (common case) | 0.050–0.066 ms | 0.020–0.021 ms |
| 8 pending (the cap) | 0.29–0.71 ms | 0.25–0.31 ms |
~2.6× faster on the common path, unchanged at the cap. Both consumers are effect/memo-gated on the transcript and pending set, so this recomputes per transcript change, not per render.
One measurement caveat worth recording: a single 8-pending sample read 0.41 ms and looked like a regression. Three runs showed it was JIT/GC noise. A separate 3000-message reading of 13.5 ms/call was cold-JIT and collapsed to ~1 ms warmed. Neither was real — reporting either would have been a false finding.
Electron QA — what was validated, and what was blocked
Driven over Playwright CDP on an isolated PR-head instance with its own profile, CDP port and renderer port; ownership verified before capturing anything. Screenshots are in the QA comment above.
Validated: two real composer sends produced queued bubbles; after a glued transcript row landed, a third prompt queued after that row survived with the fix, vanished after hot-reloading to the oldest-only matcher, and returned when the fix was restored. That is the STA-4477 symptom reproduced and closed on a live renderer. 0 console errors (4 pre-existing warnings, none new).
Blocked, stated plainly — I do not claim this coverage:
- Live agent glue was never produced. No running agent concatenated two fast sends onto one input line. The glued row was written into the watched transcript after two genuine composer sends. The reconciliation path is real; the mechanism that produces a glued row in the field is not what was exercised.
- The hot reload cleared the in-memory pending queue, so the restored-fix confirmation is a resend, not the same cache entry surviving a round trip.
- Not exercised at all: mobile, Windows/WSL, SSH/Linux, and a real assistant reply arriving after glue. Those are out of scope for a desktop renderer change.
Deliberately not done
- No clock-skew slack constant. A slack value for this hazard already exists on adjacent paths, but applying it here means changing a predicate shared with the exact-match path. Loosening exact matching risks re-opening the over-retirement class this file has already shipped twice. Separate ticket with its own regression surface.
- No last-rendered duplicate suppressor. The precedent pairs one with stop-on-mismatch traversal, but the exact-match path already fills that role here and runs first, so adding one speculatively would have been unjustified scope.
- No architectural change (correlation id at submit, or coalesce-before-dispatch), per above.
- No fix for the empty-text
continue. The two rounds initially disagreed on this; I adjudicated it and withdrew it as a defect. An attachment-only pending entry is skipped without its own boundary check, but boundaries are minted from the last transcript message at send time and are therefore monotonically non-decreasing — so any send queued after it also post-dates the row and does break the run. Unreachable in production, and the skipped entry is never retired by glue regardless. No ticket needed. - No unrelated cleanups. Every changed file is in this fix's own blast radius.
Residual risk this merged with
config/scripts/**is invisible to every gate — this will recur. It is in no tsconfig include and its files are not tests, so neither typecheck nor the test runner ever loads them. That is why a rename shipped a runtimeTypeErrorwith fully green CI. The specific caller is fixed, but the class is not, and the next rename of any renderer export will reproduce it silently. This is the one item I would open a follow-up ticket for.- No test drives the paged-out / timestamp-fallback boundary arm, which is the arm most exposed to renderer-vs-host clock skew under SSH. Failure direction there is conservative — a duplicate bubble lingers, never a vanished prompt.
- A real behavior trade under clock skew: a glued pair that previously retired now does not, when the remote clock trails far enough. That lost leniency was the bug — it came from applying the oldest send's boundary queue-wide — and glue now fails exactly where exact matching already fails, which is the fix's own stated invariant. The failure direction moved from data loss to visual noise. That is the right trade, but it is a trade, not a free win.
- The new
breaktest's fixture is deliberately non-monotone, which production cannot produce. It is a synthetic stress of that one line — correct for the purpose, but should not be read as a reproduction of a field scenario.
Honest summary
The production fix was already correct when I picked it up. Neither round found a behavioral defect in it, and I want to be clear about that rather than imply the loop rescued it. What the loop actually produced was test integrity: the fix's defining semantic was completely unpinned, and its render-path assertion was unreachable under failure. Plus one broken caller that no gate in this repository could see.
…tching (STA-4477) (stablyai#14935) * fix(native-chat): enforce each pending send's own boundary in glue matching Glue matching filtered candidate rows against the OLDEST still-open send and then matched the entire open queue against them. A prompt queued after a glued row landed could therefore be judged "already delivered" by that older row and pruned — the queued prompt disappeared with no bubble and no transcript turn. Each send now carries its own transcript boundary into the match: `gluedCandidateRows` tags every candidate row with the set of pending indices it actually landed after, and the matcher stops a run at the first send the row predates rather than skipping over it (adjacency is what makes a row glue). Exact single matches still belong to the occurrence path, unchanged. `native-chat-pending.ts` sat at 299 of its 300 effective-line budget, so the slash-command marker cache — a separate rule that never took part in pending pruning — moves verbatim to `native-chat-command-marker.ts`. Pure move: no behavior change, imports only. (max-lines is never bumped or disabled.) Refs STA-4477. Original PR stablyai#14663. * test(native-chat): cover the glue adjacency break and unmask the render path The `break` on a send the row cannot represent is the fix's central semantic choice, and swapping it for `continue` was passing the whole suite: nothing exercised a queue whose middle send is unrepresentable. Add that case. The mixed-age case also asserted both call sites in one `it`, so a prune-path failure masked the render-path assertion — and the render path is the one that makes a queued bubble visually vanish. Split it. Skip the per-send boundary scans when fewer than two sends are open: the glue matcher already returns nothing there, so a lone queued echo was walking the transcript twice per render for a discarded result. * fix(native-chat): migrate the live-session benchmark off the renamed glue exports Renaming the glue matcher's exports left this caller behind, and it crashed at runtime after printing six result rows: TypeError: matchingNativeChatUserTexts is not a function No gate caught it. config/scripts/** is in no tsconfig include and the file is not a *.test.ts, so neither typecheck nor vitest ever loads it. The empty-pending arm passes no pending sends, so the matcher takes its empty-queue exit without ever reading the rows — which is also why the renderer skips candidate-row construction entirely in that case. Escaping the row scan directly keeps what this arm actually measures identical to before, rather than fabricating per-row boundary sets that no production path builds.
…tching (STA-4477) (stablyai#14935) * fix(native-chat): enforce each pending send's own boundary in glue matching Glue matching filtered candidate rows against the OLDEST still-open send and then matched the entire open queue against them. A prompt queued after a glued row landed could therefore be judged "already delivered" by that older row and pruned — the queued prompt disappeared with no bubble and no transcript turn. Each send now carries its own transcript boundary into the match: `gluedCandidateRows` tags every candidate row with the set of pending indices it actually landed after, and the matcher stops a run at the first send the row predates rather than skipping over it (adjacency is what makes a row glue). Exact single matches still belong to the occurrence path, unchanged. `native-chat-pending.ts` sat at 299 of its 300 effective-line budget, so the slash-command marker cache — a separate rule that never took part in pending pruning — moves verbatim to `native-chat-command-marker.ts`. Pure move: no behavior change, imports only. (max-lines is never bumped or disabled.) Refs STA-4477. Original PR stablyai#14663. * test(native-chat): cover the glue adjacency break and unmask the render path The `break` on a send the row cannot represent is the fix's central semantic choice, and swapping it for `continue` was passing the whole suite: nothing exercised a queue whose middle send is unrepresentable. Add that case. The mixed-age case also asserted both call sites in one `it`, so a prune-path failure masked the render-path assertion — and the render path is the one that makes a queued bubble visually vanish. Split it. Skip the per-send boundary scans when fewer than two sends are open: the glue matcher already returns nothing there, so a lone queued echo was walking the transcript twice per render for a discarded result. * fix(native-chat): migrate the live-session benchmark off the renamed glue exports Renaming the glue matcher's exports left this caller behind, and it crashed at runtime after printing six result rows: TypeError: matchingNativeChatUserTexts is not a function No gate caught it. config/scripts/** is in no tsconfig include and the file is not a *.test.ts, so neither typecheck nor vitest ever loads it. The empty-pending arm passes no pending sends, so the matcher takes its empty-queue exit without ever reading the rows — which is also why the renderer skips candidate-row construction entirely in that case. Escaping the row scan directly keeps what this arm actually measures identical to before, rather than fabricating per-row boundary sets that no production path builds.





ELI5
When you fire off two chat prompts in a row, the agent's terminal sometimes swallows them onto one line, so both show up in the transcript as a single user turn. Native chat knows how to recognize that and clear the two "queued" bubbles. But it was checking that merged row against only the oldest waiting prompt — so a prompt you typed after that row already landed could also be marked "already delivered" and quietly thrown away. This makes every waiting prompt prove the row came after it, individually.
What Changed
Desktop / renderer only.
mobile/is untouched.Every pending send carries its own transcript boundary into glue matching.
gluedCandidateRowsnow tags each candidate row with the set of pending indices that row actually landed after, instead of filtering the whole candidate list byopen[0]'s boundary and then matching the entire open queue against it.A send the row predates ends the run instead of being skipped. In
selectPendingIndicesRepresentedByUserRows, a candidate that the row cannot representbreaks out of the open run rather than being filtered out of it. Glue consumes a leading, adjacent run — adjacency is the whole reason a merged row is attributable at all, so skipping over a non-representable send would let a row claim two prompts that were never adjacent.Exact matches are untouched.
gluedCount < 2still returns to the occurrence/content-key path, so repeated prompts and ordinary single landings keep their existing semantics.Forced file split, pure move.
native-chat-pending.tswas at 299 of its 300 effective-line budget (max-lines,skipBlankLines+skipComments) onmain. One added line breaks the build, and this repo never bumps or disablesmax-lines. The slash-command marker cache — a genuinely separate rule that never participated in pending pruning — moves verbatim intonative-chat-command-marker.ts. Verified byte-identical to the code it replaced; only import sites changed.Why
prunePendingSendsandpendingSendsAsMessagesboth derived their glue candidates from a single anchor:That is safe only while the queue is a single burst. As soon as one prompt is queued after a glued row has landed, that older row is still in the candidate list for the newer prompt, and if the row's text happens to spell the concatenation of the queue (
"fix the bug"vs"fix the"+"bug"), the newer prompt is pruned as already-delivered. The bubble vanishes and no transcript turn ever appears for it — the prompt is simply lost.A fuzzy match must never reach further back than an exact one. The exact paths already call
messagesAfterPendingBoundary(messages, entry)per entry; the glue path was the only match path without a per-send boundary.Linked Issue
Refs STA-4477. Original PR #14663.
Visual Proof
Not applicable — pure non-visual matching logic behind existing UI, with no rendered change in any passing case. The user-visible effect of the bug is the absence of a bubble, which the deterministic tests below assert directly on both the pruning and the rendering entry point.
Testing
Results at this HEAD, rebased onto
bf6dc6fcba:tsc --noEmit(web project): clean.tsc -bdeliberately not used — its emit poisons vitest.Red-first / non-vacuity. Reverting the production files wholesale is not a clean measurement here, because the fix renames the matcher's API and 2 of 3 test files then fail to load rather than fail an assertion. So the behavioural revert was done surgically instead —
representablePendingIndicesset to every index, which is exactly the pre-fix "one anchor for the whole queue" semantics with the new signature:That is the P1 verbatim: both pending sends pruned (
[]) where only the older one had actually landed. The companion teststill retires the leading run the glued row did land afterstays green under the same mutation, so the fix is pinned from both sides and is not simply disabling glue.STA-4477 was re-confirmed present on current
origin/mainbefore fixing —gluedCandidateMessagesthere still filters byopen[0]alone.Review
NativeChatPendingSendis renderer-local state; nothing here is an RPC param, a stream frame, or content a host publishes. Checked againstdocs/reference/remote-wire-compatibility.mdrules 1–3.Setper open pending entry per pass, bounded byPENDING_SEND_LIMIT = 8; all allocations invocation-local.src/renderer/src/components/native-chat/. The file split is forced by the 300-line cap, not elective.Checklist
max-lines(never bumped, never disabled)AI Disclosure
Authored and reviewed with AI assistance.
Author