Skip to content

feat: add claude conversation streaming - #262

Merged
centdix merged 5 commits into
mainfrom
improve-claude-streaming-ui
Jun 19, 2026
Merged

feat: add claude conversation streaming#262
centdix merged 5 commits into
mainfrom
improve-claude-streaming-ui

Conversation

@centdix

@centdix centdix commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Add live web-chat streaming for Claude Code worktrees using Claude's headless stream-json mode, aligned with the Codex streaming model from #260.

The WebSocket is now only the live tail: Claude publishes ordered messageDelta, messageUpsert, and conversationStatus events. HTTP history remains the canonical snapshot path for attach/refresh, but this PR intentionally does not add a post-completion persisted-transcript refetch or polling loop for streamed Claude sends; we will decide that separately later.

Changes

  • Add a Claude conversation stream service that launches new sessions with --session-id, resumes existing sessions with -r/--resume, and replays retained live events to late subscribers.
  • Extend the Claude CLI adapter to parse live text deltas plus finalized text, tool-use, and tool-result stream records.
  • Preserve Claude JSONL tool_use.id / tool_use_id as toolCallId so refreshed tool results stay grouped with their tool calls instead of rendering as user messages.
  • Wire Claude streamed sends, interrupts, WebSocket subscriptions, active status, and terminal-stale updates into the backend conversation routes.
  • Reuse the fix: refine agent chat ui #260 stream contract: no WebSocket snapshot events, no server-side live snapshot merging, and no frontend history polling after streamed Claude sends.
  • Enable Claude stream handling in the mobile chat UI using the same ordered live-event reconciliation as Codex.
  • Update CLI oneshot output and terminal refresh logic to handle Claude live/final message reconciliation and stale terminal refreshes.
  • Add focused backend and frontend tests for new sessions, resumed sessions, parser behavior, live event ordering/replay, tool-result grouping, stream reconciliation, and terminal refresh.

Test plan

  • bun test backend/src/__tests__/claude-conversation-stream-service.test.ts backend/src/__tests__/agents-ui-stream-service.test.ts
  • bun test backend/src/__tests__/claude-cli.test.ts backend/src/__tests__/claude-conversation-service.test.ts
  • bun run --cwd frontend test src/lib/worktree-conversation.test.ts src/lib/MobileChatSurface.test.ts
  • bun run --cwd frontend test src/lib/WorktreeConversationPanel.test.ts
  • bun test bin/src
  • bun test packages/api-contract/src
  • bun run --cwd backend check
  • bun run --cwd frontend check
  • git diff --check

@centdix
centdix force-pushed the improve-claude-streaming-ui branch 2 times, most recently from 3859af4 to 210348d Compare May 29, 2026 20:53
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@centdix
centdix force-pushed the improve-claude-streaming-ui branch from 210348d to e38fdaf Compare May 29, 2026 21:04
@centdix
centdix marked this pull request as ready for review May 29, 2026 21:44
centdix and others added 2 commits June 19, 2026 21:54
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@windmill-labs windmill-labs deleted a comment from claude Bot Jun 19, 2026
@windmill-labs windmill-labs deleted a comment from claude Bot Jun 19, 2026
@windmill-labs windmill-labs deleted a comment from claude Bot Jun 19, 2026
@centdix
centdix marked this pull request as draft June 19, 2026 19:58
@centdix
centdix marked this pull request as ready for review June 19, 2026 19:58
@centdix
centdix marked this pull request as draft June 19, 2026 20:07
@centdix
centdix marked this pull request as ready for review June 19, 2026 20:08
@centdix
centdix marked this pull request as draft June 19, 2026 20:10
@centdix
centdix marked this pull request as ready for review June 19, 2026 20:10
@centdix
centdix marked this pull request as draft June 19, 2026 20:13
@centdix
centdix marked this pull request as ready for review June 19, 2026 20:13
Resolved overlapping edits in agent-service.ts/.test.ts (kept main's
resume-by-id wording + extra fork/codex tests). Updated stale
instance-prefix tests to match main's d4e60ef (uppercase now allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@centdix
centdix marked this pull request as draft June 19, 2026 20:23
@centdix
centdix marked this pull request as ready for review June 19, 2026 20:23
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @centdix's task in 4m 31s —— View job


Claude Code Review — PR #262: Claude conversation streaming

  • Gather context (diff, related files)
  • Review backend stream service + CLI adapter
  • Review server routing + lifecycle wiring
  • Review frontend/CLI parity
  • Post review feedback

Overall this is a well-structured PR that cleanly reuses the #260 Codex streaming contract. The unified ${messageId}:${blockIndex} / tool_result:${toolCallId} id scheme shared across live deltas, finalized blocks, and the persisted JSONL reader is the strongest part — it's what lets upserts replace streaming placeholders in place and lets refreshed tool results group correctly. Test coverage is genuinely good (replay, ordering, block identity, tool-result grouping). A few things worth a look:

1. Multi-block assistant records would collide on one id (fragility)

In backend/src/adapters/claude-cli.ts, toStreamMessage stamps every finalized block with the single per-run cursor state.blockIndex:

const id = block.kind === "toolResult"
  ? `tool_result:${block.toolCallId ?? `${state.messageId ?? "msg"}:${state.blockIndex}`}`
  : `${block.messageId ?? state.messageId ?? "msg"}:${state.blockIndex}`;

But buildStreamMessagesFromAssistantRecord flatMaps over all blocks in message.content. The whole scheme is only correct because of the assumption baked into the claude-stream-block-identity fixture — that Claude emits one content block per assistant record (rec-A-text and rec-A-tool carry the same msg_AAA id but separate single-element content arrays). If a build of Claude ever emits a consolidated multi-block assistant record (e.g. [text@0, tool_use@1] in one record), both blocks would be stamped msg_AAA:<lastBlockIndex> and silently collapse into one container.

The persisted reader (buildClaudeSessionFromText) handles the multi-block case correctly via nextBlockIndex(messageId), so the live path is the asymmetric one. Since this assumption is load-bearing and version-dependent (fixture is pinned to claude 2.1.170), consider either a defensive guard/log when a record yields >1 block, or deriving per-block indices in buildStreamMessagesFromAssistantRecord rather than relying on the cursor. Fix this →

2. Resume relies on -r preserving the session id (verify)

sendClaudeStreamingMessage keys the run by the existing sessionId and launches with -r <sessionId>, but the stream service ignores onSessionId / the result.session_id. setWorktreeConversationSession is only called for new sessions. If claude -r <id> ever forks to a new session id (some CLI versions did), then:

  • the live stream still works (run + subscribers keyed by the old id), but
  • Claude writes the turn to a JSONL under the new id, so the persisted-meta id goes stale and a later HTTP attach (readSession(oldId)) won't show those messages.

Given the PR explicitly defers any post-completion transcript refetch, the persisted path is the only safety net on reconnect, so it's worth confirming -r reuses the id on your target Claude version — and if not, persisting the resolved result.session_id.

3. worktree-conversation.ts change is a no-op

// before
const existingIndex = exactIndex === -1 ? optimisticIndex : exactIndex;
// after
const existingIndex = exactIndex !== -1 ? exactIndex : optimisticIndex;

These two are logically identical (both yield optimisticIndex when exactIndex === -1, else exactIndex). The added test exercises the exactIndex match path and would pass against the old code too. Not a bug — just flagging so reviewers don't hunt for a behavior change that isn't there. If the intent was readability, fine to keep; otherwise it's churn.

4. Interrupted messages finalize as "completed" (minor UX)

ClaudeConversationStreamService.interruptfinishRun(run, "completed"), so any in-progress block left when the user interrupts is broadcast with status: "completed" rather than something reflecting the interruption. Matches the response's interrupted: true flag at the turn level, so probably acceptable, but the per-message status reads as a clean finish.

Minor notes

  • sendSelectedConversationMessage opens a stream with the pending id via syncConversationStream(true) before the send, then reconnects to the real session id once the response returns — an extra connect on first-message turns. Harmless thanks to the stream service's liveMessages retention/replay, just slightly wasteful.
  • The 30s COMPLETED_RUN_RETENTION_MS window is fine since HTTP history (JSONL) is the canonical source on reconnect; worth a one-line comment that reconnects after the window rely on the persisted snapshot.

Nothing here is blocking. Items 1 and 2 are the ones I'd want resolved or explicitly confirmed before merge, since both hinge on Claude CLI output behavior that the unified-id design depends on.

@centdix
centdix merged commit 6b04e83 into main Jun 19, 2026
3 checks passed
@centdix
centdix deleted the improve-claude-streaming-ui branch June 19, 2026 20:29
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