v0.20.1 patch: chat text reconcile + memory explorer spacing#82
v0.20.1 patch: chat text reconcile + memory explorer spacing#82
Conversation
…design When the SDK is configured with includePartialMessages, each content block ships via stream_event deltas and then reappears in the final SDKAssistantMessage. Before this change, handleAssistant re-emitted text_start + text_delta(fullText) for blocks the stream had already covered. Since the client reducer was refactored to keep each text block as its own entry in message.content (commit 59112d2), the redundant text_start appended a second block with the same blockId and the subsequent text_delta ran its map-update across both of them, doubling the rendered text. The wire protocol already defines message.text_reconcile for exactly this handoff, and the client reducer already handles it with replace semantics. This change wires the server to emit it: when ctx.blockTypes already tags the index as text or thinking, emit a single text_reconcile frame with the canonical full_text instead of text_start + text_delta. Non-streaming callers keep the original diff path. Thinking blocks skip redundant emission entirely because the client's Map.set reducer is idempotent. Uses the existing TextReconcileFrame type; no wire protocol changes, no persisted shape changes, no client production code changes.
Seven new tests in sdk-to-wire.test.ts cover the combined stream_event plus final assistant sequence that v0.20.0 accidentally broke and previous tests never exercised in isolation. Coverage: - final assistant emits a single text_reconcile frame, no duplicated text_start or text_delta - divergent canonical text reconciles to the final SDK value - assistant-only path (no prior stream) preserves diff emit - thinking block emits no redundant frames after stream - tool_use block stays guarded by startedToolIds - interleaved [text, tool_use, text] reconciles each text block independently with correct text_block_id - empty text block gracefully emits text_start without a delta Existing 33 translator tests continue to pass.
Adds vitest 2.x as a dev dependency, wires a test script, and drops a minimal config that picks up src/**/__tests__/**/*.test.ts under the node environment. The SPA had no test harness before this, which is how the v0.20.0 chat duplication regression shipped silently. The harness is intentionally minimal. The reducer is pure TypeScript with no React dependency, so node is enough.
Four new tests lock in the reducer's intended behavior for the text block lifecycle: - text_delta accumulates into one content block per text_start - text_reconcile replaces the accumulated delta text rather than appending to it - divergent canonical text from reconcile snaps the block to the final value - text_reconcile for a block that was never started is a no-op The reducer already handled these cases correctly; these tests guard against future regressions of the same shape that v0.20.0 introduced on the server side, and give the SPA its first real coverage of the assistant-message path.
The shared .dash-filter-search primitive has flex: 1 so it stretches inside the horizontal .dash-filter-bar used by Sessions and Scheduler. The Memory rail is a flex column, so flex: 1 grows vertically and pushes the first list row well below the fold. Scope the override to direct children of .dash-split-pane-rail so Memory gets content-height sizing back while Sessions and Scheduler keep the horizontal behavior intact. No DOM change, no memory.js touch, no change to the shared primitive itself.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cefe86ab23
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (ctx.blockTypes.get(i) === "thinking") { | ||
| // Stream deltas already shipped this block via Map.set-idempotent | ||
| // updates in the client reducer. Skip redundant emission; the | ||
| // Map entry already holds the authoritative text. | ||
| ctx.seenBlockLengths.set(i, thinkingText.length); |
There was a problem hiding this comment.
Reconcile streamed thinking blocks with final assistant text
When a thinking block was streamed, this branch now suppresses all follow-up frames from the final assistant message. If the streamed deltas are incomplete or differ from the assistant’s canonical thinking text (e.g., stream produced "abc" but final content is "abcd"), the client never receives the missing suffix and keeps stale reasoning text. Before this change, the assistant path still emitted thinking updates and effectively corrected to the final value, so this is a regression for stream+assistant sequences where the two payloads diverge.
Useful? React with 👍 / 👎.
Codex P2: the thinking branch in handleAssistant was skipping emission entirely when the stream had already shipped the block. That only works if stream deltas exactly equal the final canonical text. When they diverge (stream='abc', final='abcd'), the client keeps the stale accumulated stream text and never sees the canonical 'abcd'. The client's thinking reducer is Map-indirect: thinking_start REPLACES the Map entry, thinking_delta appends. So emitting thinking_start + thinking_delta(fullText) from the final pass is idempotent AND correctly snaps to canonical. Text blocks use a dedicated reconcile frame because their reducer is array-of-blocks without that idempotence property. Updated the existing 'no redundant frames' test to assert the new correct shape (start + delta with fullText) and added a 'divergent canonical' regression test that locks in the 'd' being recovered from stream='abc' + final='abcd'.
Bumps 0.20.0 to 0.20.1 in every place it's referenced: - package.json - src/core/server.ts VERSION - src/mcp/server.ts MCP server identity - src/cli/index.ts phantom --version - README.md version + tests badges (1,799 to 1,807) - CLAUDE.md tagline + bun test count - CONTRIBUTING.md test count Tests: 1,807 pass / 10 skip / 0 fail. Typecheck + lint clean. v0.20.1 patches two bugs from v0.20.0 visual verification: #82 chat text_reconcile on stream+final handoff, restoring the originally-designed dormant path from PR1. Regression from commit 59112d2 (before v0.19.0 tagged). Also preserves thinking blocks' canonical-on-divergence via idempotent Map-indirect client reducer. #82 dash-filter-search scoped flex override inside .dash-split-pane-rail, fixing Memory explorer's ~60vh dead space above the first list row.
Summary
Two v0.20.0 regressions found during cheeks fleet verification, both fixed here with the smallest possible blast radius.
Bug 1: chat bubble duplicates the assistant response
When streaming is enabled, the final
SDKAssistantMessageinhandleAssistantre-emittedtext_startplustext_delta(fullText)for blocks thatstream_eventhad already covered. After the v0.19.0 reducer refactor in59112d2, the client'smessage.contentbecame an array with one entry pertext_start, so the redundant pair appended a second block with the sametext_block_idand the follow-uptext_deltaran its map-update across both entries. End state: the rendered text doubled.Fix: in
src/chat/sdk-to-wire-handlers.ts, whenctx.blockTypesalready tags the index astextthe handler emits a singlemessage.text_reconcileframe with the canonicalfull_text. Thinking blocks skip redundant emission entirely because the client's Map-keyed thinking reducer is already idempotent. Non-streaming callers keep the original diff-based emission. TheTextReconcileFrametype already exists insrc/chat/types.tsand the client reducer atchat-ui/src/lib/chat-store.ts:150-155already handles it with replace semantics, so this restores the originally-designed handoff rather than inventing a new one.Zero changes to
chat-uiproduction code. Zero wire protocol additions. Zero persisted-shape changes.Bug 2: memory explorer pushes the first list row below the fold
The shared
.dash-filter-searchprimitive hasflex: 1because it was authored to stretch horizontally inside.dash-filter-bar. The Memory rail drops it directly into.dash-split-pane-rail, which is a flex column, soflex: 1grows vertically and splits the remaining rail space between the search wrapper and the list roughly 50/50. On a 1200px viewport the first list row ends up about 60vh below the fold.Fix: add one scoped rule in
public/dashboard/dashboard.cssthat resets the flex sizing when.dash-filter-searchis a direct child of.dash-split-pane-rail. Sessions and Scheduler keep the horizontal behavior untouched.memory.jsis not modified.Regression history
The chat duplication surface traces to commit
59112d2(a legitimate reducer refactor that preserved interleaved text/tool content) unmasking a latent server double-emit. The wire protocol definedmessage.text_reconcilefor exactly this handoff but the server never emitted it. This PR wires the dormant path. The pre-regression Map-keyed reducer was accidentally idempotent under the double-emit, so the bug was invisible until the array-keyed reducer landed.The SPA had no test harness at all before this PR, which is how the regression shipped silently. A minimum vitest setup ships here so the reducer has coverage and future regressions of this shape get caught.
Test plan
bun run lint(biome) greenbun run typecheckgreenbun testgreen: 1806 pass, 10 skip, 0 fail, 40 translator tests (7 new)cd chat-ui && bun run typecheckgreencd chat-ui && bun run testgreen: 4 new reducer tests passcd chat-ui && bun run buildgreen/chat, send a short message, confirm no duplication/ui/dashboard/#/memory/episodeson a 1200px viewport, first row visible without scroll#/memory/factsand#/memory/proceduresCommits
chat: emit text_reconcile for blocks already streamed, restoring PR2 designchat: regression tests for stream-then-final text sequencechat-ui: vitest harness for reducer coveragechat-ui: chat-store reducer tests for reconcile semanticsdashboard: scope dash-filter-search flex override in split-pane-railOut of scope for this PR
mainafter merge, then tag).chat-store.tsproduction code. The reducer is already correct.memory.jsor the shared.dash-filter-searchrule.