Skip to content

v0.20.1 patch: chat text reconcile + memory explorer spacing#82

Merged
mcheemaa merged 6 commits intomainfrom
v0.20.1-patch-bugs
Apr 17, 2026
Merged

v0.20.1 patch: chat text reconcile + memory explorer spacing#82
mcheemaa merged 6 commits intomainfrom
v0.20.1-patch-bugs

Conversation

@mcheemaa
Copy link
Copy Markdown
Member

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 SDKAssistantMessage in handleAssistant re-emitted text_start plus text_delta(fullText) for blocks that stream_event had already covered. After the v0.19.0 reducer refactor in 59112d2, the client's message.content became an array with one entry per text_start, so the redundant pair appended a second block with the same text_block_id and the follow-up text_delta ran its map-update across both entries. End state: the rendered text doubled.

Fix: in src/chat/sdk-to-wire-handlers.ts, when ctx.blockTypes already tags the index as text the handler emits a single message.text_reconcile frame with the canonical full_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. The TextReconcileFrame type already exists in src/chat/types.ts and the client reducer at chat-ui/src/lib/chat-store.ts:150-155 already handles it with replace semantics, so this restores the originally-designed handoff rather than inventing a new one.

Zero changes to chat-ui production 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-search primitive has flex: 1 because 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, so flex: 1 grows 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.css that resets the flex sizing when .dash-filter-search is a direct child of .dash-split-pane-rail. Sessions and Scheduler keep the horizontal behavior untouched. memory.js is 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 defined message.text_reconcile for 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) green
  • bun run typecheck green
  • bun test green: 1806 pass, 10 skip, 0 fail, 40 translator tests (7 new)
  • cd chat-ui && bun run typecheck green
  • cd chat-ui && bun run test green: 4 new reducer tests pass
  • cd chat-ui && bun run build green
  • Manual: open /chat, send a short message, confirm no duplication
  • Manual: send a long (100+ word) message, confirm single render through the stream
  • Manual: open /ui/dashboard/#/memory/episodes on a 1200px viewport, first row visible without scroll
  • Manual: repeat for #/memory/facts and #/memory/procedures
  • Manual: confirm Sessions and Scheduler filter bars unchanged

Commits

  1. chat: emit text_reconcile for blocks already streamed, restoring PR2 design
  2. chat: regression tests for stream-then-final text sequence
  3. chat-ui: vitest harness for reducer coverage
  4. chat-ui: chat-store reducer tests for reconcile semantics
  5. dashboard: scope dash-filter-search flex override in split-pane-rail

Out of scope for this PR

  • Version bump to 0.20.1 (lands as a separate commit on main after merge, then tag).
  • Any change to chat-store.ts production code. The reducer is already correct.
  • Any change to memory.js or the shared .dash-filter-search rule.

…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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/chat/sdk-to-wire-handlers.ts Outdated
Comment on lines +73 to +77
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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'.
@mcheemaa mcheemaa merged commit 706b0db into main Apr 17, 2026
1 check passed
mcheemaa added a commit that referenced this pull request Apr 17, 2026
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.
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.

1 participant