diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index a8737d4..7961c18 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2379,7 +2379,7 @@ export const ContentPartsRenderer = memo(function ContentPartsRenderer({ role, }: ContentPartsRendererProps) { return ( -
+
{parts.map((part, i) => { if (part.type === "text") { return ( diff --git a/src/contexts/conversation-runtime-context.tsx b/src/contexts/conversation-runtime-context.tsx index 7d90ecc..33dfe66 100644 --- a/src/contexts/conversation-runtime-context.tsx +++ b/src/contexts/conversation-runtime-context.tsx @@ -161,26 +161,41 @@ function formatLivePlanEntries( return `Plan updated:\n${lines.join("\n")}` } -function buildStreamingTurnFromLiveMessage( +function buildStreamingTurnsFromLiveMessage( conversationId: number, liveMessage: LiveMessage -): MessageTurn | null { - const blocks: MessageTurn["blocks"] = [] +): MessageTurn[] { + // Split streaming content into multiple turns matching the historical + // pattern: each "round" (text/thinking + tool calls + tool results) is a + // separate turn. A new turn starts when a text/thinking/plan block appears + // after completed tool calls in the current group. + const groups: MessageTurn["blocks"][] = [[]] + let currentGroupHasCompletedTool = false for (const block of liveMessage.content) { + const isContentBlock = + block.type === "text" || block.type === "thinking" || block.type === "plan" + + if (isContentBlock && currentGroupHasCompletedTool) { + groups.push([]) + currentGroupHasCompletedTool = false + } + + const currentBlocks = groups[groups.length - 1] + switch (block.type) { case "text": if (block.text.length > 0) { - blocks.push({ type: "text", text: block.text }) + currentBlocks.push({ type: "text", text: block.text }) } break case "thinking": if (block.text.length > 0) { - blocks.push({ type: "thinking", text: block.text }) + currentBlocks.push({ type: "thinking", text: block.text }) } break case "plan": { - blocks.push({ + currentBlocks.push({ type: "thinking", text: formatLivePlanEntries(block.entries), }) @@ -192,7 +207,7 @@ function buildStreamingTurnFromLiveMessage( kind: block.info.kind, rawInput: block.info.raw_input, }) - blocks.push({ + currentBlocks.push({ type: "tool_use", tool_use_id: block.info.tool_call_id, tool_name: toolName, @@ -201,26 +216,31 @@ function buildStreamingTurnFromLiveMessage( const isFinalState = block.info.status === "completed" || block.info.status === "failed" if (isFinalState) { - blocks.push({ + currentBlocks.push({ type: "tool_result", tool_use_id: block.info.tool_call_id, output_preview: block.info.raw_output ?? block.info.content, is_error: block.info.status === "failed", }) + currentGroupHasCompletedTool = true } break } } } - if (blocks.length === 0) return null - - return { - id: `live-${conversationId}-${liveMessage.id}`, - role: "assistant", - blocks, - timestamp: new Date(liveMessage.startedAt).toISOString(), - } + const timestamp = new Date(liveMessage.startedAt).toISOString() + return groups + .filter((blocks) => blocks.length > 0) + .map((blocks, i) => ({ + id: + i === 0 + ? `live-${conversationId}-${liveMessage.id}` + : `live-${conversationId}-${liveMessage.id}-${i}`, + role: "assistant" as const, + blocks, + timestamp, + })) } function upsertExternalIdIndex( @@ -315,17 +335,17 @@ function reducer( const current = state.byConversationId.get(action.conversationId) if (!current) return state - // Convert liveMessage to a completed MessageTurn - const streamingTurn = current.liveMessage - ? buildStreamingTurnFromLiveMessage( + // Convert liveMessage to completed MessageTurns (split into rounds) + const streamingTurns = current.liveMessage + ? buildStreamingTurnsFromLiveMessage( current.conversationId, current.liveMessage ) - : null + : [] - // Promote: optimisticTurns + streamingTurn → localTurns + // Promote: optimisticTurns + streamingTurns → localTurns const promoted = [...current.localTurns, ...current.optimisticTurns] - if (streamingTurn) promoted.push(streamingTurn) + promoted.push(...streamingTurns) return updateSessionInState(state, action.conversationId, () => ({ ...current, @@ -609,18 +629,18 @@ export function ConversationRuntimeProvider({ phase: "optimistic", })) - // Phase 4: Streaming turn (live agent response) + // Phase 4: Streaming turns (live agent response, split into rounds) const streamingMessage = session.liveMessage - const streamingTurn = streamingMessage - ? buildStreamingTurnFromLiveMessage(conversationId, streamingMessage) - : null + const streamingTurns = streamingMessage + ? buildStreamingTurnsFromLiveMessage(conversationId, streamingMessage) + : [] const result = [...persisted, ...local, ...optimistic] - if (streamingTurn) { + for (const [i, turn] of streamingTurns.entries()) { result.push({ - key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}`, - turn: streamingTurn, + key: `streaming-${conversationId}-${streamingMessage?.id ?? "unknown"}-${i}`, + turn, phase: "streaming", }) }