From a2f5667b15482a681c3a317be3272ca10a5dbd2f Mon Sep 17 00:00:00 2001 From: Sahil Date: Sun, 29 Mar 2026 19:58:06 +0530 Subject: [PATCH] feat(copilot-sdk): add streamMode prop for agent response bubble behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'multi-step' (default) — one bubble per server agent iteration (OpenAI/LiteLLM style), default unchanged - 'single-turn' — all iterations collapsed into one bubble per user turn (Vercel AI SDK / Claude.ai style) Usage: Co-Authored-By: Claude Sonnet 4.6 --- .../copilot-sdk/src/chat/ChatWithTools.ts | 7 + .../src/chat/classes/AbstractChat.ts | 131 ++++++++++++------ packages/copilot-sdk/src/chat/types/chat.ts | 9 ++ .../src/react/provider/CopilotProvider.tsx | 11 ++ 4 files changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/copilot-sdk/src/chat/ChatWithTools.ts b/packages/copilot-sdk/src/chat/ChatWithTools.ts index 66a7126..0b428ca 100644 --- a/packages/copilot-sdk/src/chat/ChatWithTools.ts +++ b/packages/copilot-sdk/src/chat/ChatWithTools.ts @@ -57,6 +57,12 @@ export interface ChatWithToolsConfig { yourgptConfig?: YourGPTConfig; /** Enable debug logging */ debug?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * - `'multi-step'` (default) — one bubble per server agent iteration. + * - `'single-turn'` — all iterations collapsed into one bubble per user turn. + */ + streamMode?: "multi-step" | "single-turn"; /** Initial messages */ initialMessages?: UIMessage[]; /** Initial tools to register */ @@ -152,6 +158,7 @@ export class ChatWithTools { onCreateSession: config.onCreateSession, yourgptConfig: config.yourgptConfig, debug: config.debug, + streamMode: config.streamMode, initialMessages: config.initialMessages, state: config.state, transport: config.transport, diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index 3fa471c..f2b0359 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -1107,58 +1107,93 @@ export class AbstractChat { return; } - // Handle message:end mid-stream (server-side agent loop turn completed) - // This creates separate messages for each turn instead of combining them - if (chunk.type === "message:end" && this.streamState?.content) { - this.debug("message:end mid-stream", { - messageId: this.streamState.messageId, - contentLength: this.streamState.content.length, - toolCallsInState: this.streamState.toolCalls?.length ?? 0, - chunkCount, - }); + // Handle message:end mid-stream (server-side agent loop turn completed). + // Behaviour depends on streamMode: + // 'multi-step' (default) — finalize a new UIMessage per iteration. + // 'single-turn' — skip entirely; keep accumulating into the + // same streamState so all iterations collapse + // into one bubble (Vercel AI SDK / Claude.ai style). + if (chunk.type === "message:end" && this.streamState) { + if (this.config.streamMode === "single-turn") { + this.debug( + "message:end mid-stream (single-turn: keeping streamState alive)", + { + messageId: this.streamState.messageId, + contentLength: this.streamState.content.length, + chunkCount, + }, + ); + continue; + } - // Finalize current message with its content and tool calls - const turnMessage = streamStateToMessage(this.streamState) as T; + // multi-step (default): finalize current turn as its own UIMessage + if (!this.streamState.content) { + // Nothing streamed yet for this turn — skip finalization + } else { + this.debug("message:end mid-stream", { + messageId: this.streamState.messageId, + contentLength: this.streamState.content.length, + toolCallsInState: this.streamState.toolCalls?.length ?? 0, + chunkCount, + }); - // Add toolCallsHidden metadata if applicable - const toolCallsHidden: Record = {}; - for (const [id, result] of this.streamState.toolResults) { - if (result.hidden !== undefined) { - toolCallsHidden[id] = result.hidden; + // Finalize current message with its content and tool calls + const turnMessage = streamStateToMessage(this.streamState) as T; + + // Add toolCallsHidden metadata if applicable + const toolCallsHidden: Record = {}; + for (const [id, result] of this.streamState.toolResults) { + if (result.hidden !== undefined) { + toolCallsHidden[id] = result.hidden; + } } - } - if ( - turnMessage.toolCalls?.length && - Object.keys(toolCallsHidden).length > 0 - ) { - (turnMessage as T & { metadata?: Record }).metadata = - { + if ( + turnMessage.toolCalls?.length && + Object.keys(toolCallsHidden).length > 0 + ) { + ( + turnMessage as T & { metadata?: Record } + ).metadata = { ...(turnMessage as T & { metadata?: Record }) .metadata, toolCallsHidden, }; - } + } - this.state.updateMessageById( - this.streamState.messageId, - (existing) => ({ - ...turnMessage, - ...(existing.parentId !== undefined - ? { parentId: existing.parentId } - : {}), - ...(existing.childrenIds !== undefined - ? { childrenIds: existing.childrenIds } - : {}), - }), - ); - this.callbacks.onMessageFinish?.(turnMessage); + this.state.updateMessageById( + this.streamState.messageId, + (existing) => ({ + ...turnMessage, + ...(existing.parentId !== undefined + ? { parentId: existing.parentId } + : {}), + ...(existing.childrenIds !== undefined + ? { childrenIds: existing.childrenIds } + : {}), + }), + ); + this.callbacks.onMessageFinish?.(turnMessage); - // Reset stream state for next turn - will be initialized on next message:start - this.streamState = null; - continue; + // Reset stream state — next message:start will create a new message + this.streamState = null; + continue; + } } - // Handle message:start after a mid-stream finalization + // Handle message:start mid-stream: + // single-turn — streamState is still alive, skip to keep accumulating. + // multi-step — streamState was reset to null above; fall through to + // the message:start === null handler below. + if (chunk.type === "message:start" && this.streamState !== null) { + if (this.config.streamMode === "single-turn") { + this.debug( + "message:start mid-stream (single-turn: streamState already active, skipping)", + ); + continue; + } + } + + // Handle message:start after a mid-stream finalization (multi-step mode) if (chunk.type === "message:start" && this.streamState === null) { this.debug("message:start after mid-stream end - creating new message"); // Capture the current leaf BEFORE pushing the new message so the @@ -1428,9 +1463,17 @@ export class AbstractChat { ), }); - const currentStreamToolCallIds = new Set( - this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? [], - ); + // In single-turn mode all server-tool IDs land in streamState.toolResults + // (via action:start/args/end chunks). Include them so done.messages doesn't + // re-insert those tools as duplicates. + const currentStreamToolCallIds = new Set([ + ...(this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? + []), + ...(this.config.streamMode === "single-turn" && + this.streamState?.toolResults + ? Array.from(this.streamState.toolResults.keys()) + : []), + ]); const messagesToInsert: T[] = []; // Build hidden map from stream state's toolResults diff --git a/packages/copilot-sdk/src/chat/types/chat.ts b/packages/copilot-sdk/src/chat/types/chat.ts index 898c40a..e282dfa 100644 --- a/packages/copilot-sdk/src/chat/types/chat.ts +++ b/packages/copilot-sdk/src/chat/types/chat.ts @@ -99,6 +99,15 @@ export interface ChatConfig { yourgptConfig?: YourGPTConfig; /** Enable debug logging */ debug?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * + * - `'multi-step'` (default) — each server agent iteration gets its own + * assistant bubble. Mirrors OpenAI / LiteLLM multi-turn structure. + * - `'single-turn'` — all iterations are accumulated into one bubble, + * finalized when the server sends `done`. Same as Vercel AI SDK / Claude.ai. + */ + streamMode?: "multi-step" | "single-turn"; /** Available tools (passed to LLM) */ tools?: ToolDefinition[]; /** Optional prompt/tool optimization controls */ diff --git a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx index 2bc0dd3..a0e3144 100644 --- a/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx +++ b/packages/copilot-sdk/src/react/provider/CopilotProvider.tsx @@ -320,6 +320,15 @@ export interface CopilotProviderProps { parseError?: (status: number, body: unknown) => string | null | undefined; /** Enable/disable streaming (default: true) */ streaming?: boolean; + /** + * Controls how multi-turn agent responses appear in the UI. + * + * - `'multi-step'` (default) — each server agent iteration gets its own + * assistant bubble. Mirrors OpenAI / LiteLLM multi-turn structure. + * - `'single-turn'` — all iterations are accumulated into one bubble, + * finalized when the server sends `done`. Same as Vercel AI SDK / Claude.ai. + */ + streamMode?: "multi-step" | "single-turn"; /** * Custom headers to send with each request * Can be static object or getter function for dynamic resolution. @@ -560,6 +569,7 @@ export function CopilotProvider(props: CopilotProviderProps) { onError, parseError, streaming, + streamMode, headers, body, debug = false, @@ -658,6 +668,7 @@ export function CopilotProvider(props: CopilotProviderProps) { yourgptConfig, initialMessages: uiInitialMessages, streaming, + streamMode, headers, body, parseError,