diff --git a/.husky/pre-commit b/.husky/pre-commit index 80a5efd..cf79dfe 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,12 +4,12 @@ # # Add homebrew to PATH for non-interactive shells -export PATH="/Users/s/Library/pnpm:/opt/homebrew/bin:$PATH" +export PATH="/opt/homebrew/bin:$HOME/.local/bin:$PATH" echo "🔍 Running Gitleaks to check for secrets..." # Check if gitleaks is installed -if ! command -v gitleaks &> /dev/null; then +if ! command -v gitleaks > /dev/null 2>&1; then echo "" echo "❌ ERROR: Gitleaks is not installed!" echo "" diff --git a/packages/copilot-sdk/package.json b/packages/copilot-sdk/package.json index 78848ed..b096670 100644 --- a/packages/copilot-sdk/package.json +++ b/packages/copilot-sdk/package.json @@ -108,6 +108,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", "@streamdown/code": "^1.0.1", + "@streamdown/math": "^1.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "html-to-image": "^1.11.13", diff --git a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts index 82fb6d8..f24ff01 100644 --- a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts +++ b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts @@ -272,8 +272,10 @@ export class AbstractAgentLoop implements AgentLoopActions { } // Create new abort controller for this batch + // Do NOT reset _isCancelled here — if stop() was called between the + // iteration check above and this line, we must not wipe that signal. + // _isCancelled is only reset in resetIterations() (called by sendMessage). this.abortController = new AbortController(); - this._isCancelled = false; this._isProcessing = true; this.setIteration(this._iteration + 1); diff --git a/packages/copilot-sdk/src/chat/ChatWithTools.ts b/packages/copilot-sdk/src/chat/ChatWithTools.ts index a743af0..7fdd7e4 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, @@ -281,6 +288,12 @@ export class ChatWithTools { const results = await this.agentLoop.executeToolCalls(toolCallInfos); this.debug("Tool results:", results); + // If stop() was called while tools were executing, don't restart the loop + if (this.agentLoop.isCancelled) { + this.debug("Skipping continueWithToolResults — loop was cancelled"); + return; + } + // Continue chat with tool results if (results.length > 0) { const toolResults = results.map((r) => ({ diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index f126098..bd6fc3c 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -118,6 +118,7 @@ export class AbstractChat { debug: init.debug, optimization: init.optimization, yourgptConfig: init.yourgptConfig, + streamMode: init.streamMode, }; // Use provided state or create default @@ -483,6 +484,15 @@ export class AbstractChat { // is not enough for React 18 to render the loading state. await new Promise((resolve) => setTimeout(resolve, 0)); + // If stop() was called during the macrotask yield, status will have been + // reset to "ready" — don't restart the loop in that case. + if (this.status === "ready" || this.status === "error") { + this.debug( + "Skipping processRequest — status reset during yield (stop was called)", + ); + return; + } + // Continue request await this.processRequest(); } catch (error) { @@ -1070,11 +1080,25 @@ export class AbstractChat { if (existing) { assistantMessage = existing; } else { - assistantMessage = createEmptyAssistantMessage() as T; + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + assistantMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(assistantMessage); } } else { - assistantMessage = createEmptyAssistantMessage() as T; + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + assistantMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(assistantMessage); } @@ -1107,64 +1131,99 @@ 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 - // Split on text content OR server-side tool executions (no text, tool-only turns) - if ( - chunk.type === "message:end" && - this.streamState !== null && - (this.streamState.content || - (this.streamState.toolResults?.size ?? 0) > 0) - ) { - this.debug("message:end mid-stream", { - messageId: this.streamState.messageId, - contentLength: this.streamState.content.length, - toolCallsInState: this.streamState.toolCalls?.length ?? 0, - chunkCount, - }); - - // 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; - } + // 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; } + + // multi-step (default): finalize current turn as its own UIMessage + // Must check both content AND toolResults so tool-only turns (no streamed + // text) are also finalized and streamState is reset — otherwise the next + // iteration's tool chunks get appended to the previous message's stream. if ( - turnMessage.toolCalls?.length && - Object.keys(toolCallsHidden).length > 0 + !this.streamState.content && + (this.streamState.toolResults?.size ?? 0) === 0 ) { - (turnMessage as T & { metadata?: Record }).metadata = - { + // 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, + }); + + // 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 = { ...(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 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 + // 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 @@ -1268,6 +1327,14 @@ export class AbstractChat { const messagesToInsert: T[] = []; let clientAssistantToolCalls: unknown[] | undefined; + // Track parent chain for inserted messages so they don't become + // orphan root children in the MessageTree. + const lastVisibleMsgs = this.state.messages; + let postEndInsertParentId: string | undefined = + lastVisibleMsgs.length > 0 + ? lastVisibleMsgs[lastVisibleMsgs.length - 1].id + : undefined; + for (const msg of chunk.messages) { // This is the client-tool assistant message already in state // (finalized by message:end but without toolCalls). @@ -1303,14 +1370,19 @@ export class AbstractChat { ) continue; // Everything else (server tool results) needs inserting - messagesToInsert.push({ + const insertedMsg = { id: generateMessageId(), role: msg.role as T["role"], content: msg.content ?? "", toolCalls: msg.tool_calls as T["toolCalls"], toolCallId: msg.tool_call_id, createdAt: new Date(), - } as T); + ...(postEndInsertParentId + ? { parentId: postEndInsertParentId } + : {}), + } as T; + postEndInsertParentId = insertedMsg.id; + messagesToInsert.push(insertedMsg); } // Merge OpenAI-format tool_calls into the existing last assistant message @@ -1332,8 +1404,9 @@ export class AbstractChat { } if (messagesToInsert.length > 0) { - // Insert server tool results before the last assistant message - const currentMessages = this.state.messages; + // Insert server tool results before the last assistant message. + // Use _allMessages() to preserve inactive branch messages. + const currentMessages = this._allMessages(); let insertIdx = currentMessages.length; for (let i = currentMessages.length - 1; i >= 0; i--) { if (currentMessages[i].role === "assistant") { @@ -1520,11 +1593,38 @@ 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[] = []; + // Track parent chain for inserted messages so they don't become + // orphan root children in the MessageTree (which would redirect + // the active path and blank the UI). + // In single-turn mode streamState is never reset between turns, so + // parenting from streamState.messageId would attach tool result messages + // as children of the current streaming assistant message. Instead parent + // from the message immediately before the streaming message. + let insertChainParentId: string | undefined; + if (this.config.streamMode === "single-turn" && this.streamState) { + const allMsgs = this._allMessages(); + const streamIdx = allMsgs.findIndex( + (m) => m.id === this.streamState!.messageId, + ); + insertChainParentId = + streamIdx > 0 ? allMsgs[streamIdx - 1].id : undefined; + } else { + insertChainParentId = this.streamState?.messageId; + } + // Build hidden map from stream state's toolResults const toolCallsHidden: Record = {}; if (this.streamState?.toolResults) { @@ -1544,6 +1644,31 @@ export class AbstractChat { continue; } + // single-turn: ALL assistant content (including intermediate tool-calling + // messages from earlier server iterations) is already accumulated into the + // one streaming message via message:delta. Inserting them from done.messages + // creates duplicate bubbles after streaming ends. Skip ALL assistant messages + // in single-turn mode — tool execution display is driven by streamState.toolResults. + if ( + this.config.streamMode === "single-turn" && + msg.role === "assistant" + ) { + continue; + } + + // single-turn: done.messages contains the FULL conversation history — + // every tool-result message from every previous turn is included. + // All server-tool results are already represented in streamState.toolResults + // (streamed via action:start/args/end). Inserting raw tool messages from + // done.messages creates duplicate cards attached to the wrong message. + // Skip ALL tool messages in single-turn mode. + if ( + this.config.streamMode === "single-turn" && + msg.role === "tool" + ) { + continue; + } + // The current streamed turn already becomes an assistant message from // streamState/tool_calls handling. Skip the duplicate copy from the // done payload, but keep assistant tool_call messages from earlier @@ -1577,13 +1702,19 @@ export class AbstractChat { toolCallId: msg.tool_call_id, createdAt: new Date(), metadata, + ...(insertChainParentId ? { parentId: insertChainParentId } : {}), } as T; + insertChainParentId = message.id; messagesToInsert.push(message); } if (messagesToInsert.length > 0) { - const currentMessages = this.state.messages; + // Use _allMessages() to preserve inactive branch messages. + // this.state.messages only returns the visible path; calling + // setMessages() with just that would destroy all other branches + // when tree.reset() rebuilds. + const currentMessages = this._allMessages(); const currentStreamIndex = this.streamState ? currentMessages.findIndex( (message) => message.id === this.streamState!.messageId, 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/experimental/renderers/GenUIFrame.tsx b/packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx index cfb2b3e..b7e1a4b 100644 --- a/packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx +++ b/packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx @@ -13,6 +13,8 @@ export interface GenUIFrameProps { className?: string; /** Max width of the iframe (default: none) */ maxWidth?: string; + /** Theme CSS variables to inject into the iframe (e.g. from getComputedStyle(document.documentElement)) */ + themeVars?: Record; /** Callback when a copilot.sendMessage() is called from inside the iframe */ onSendMessage?: (message: string) => void; /** Callback when a copilot.action() is called from inside the iframe */ @@ -28,6 +30,7 @@ export interface GenUIFrameProps { * - Auto-height via ResizeObserver * - Unique frame ID prevents cross-iframe interference * - Scripts deferred during streaming, executed on completion + * - Theme vars injected via postMessage (not baked into srcDoc) * - `window.copilot` bridge for iframe → parent communication * * @experimental @@ -37,14 +40,21 @@ export function GenUIFrame({ streaming = false, className, maxWidth, + themeVars, onSendMessage, onAction, }: GenUIFrameProps) { const iframeRef = React.useRef(null); const readyRef = React.useRef(false); + const themeVarsRef = React.useRef(themeVars); const [height, setHeight] = React.useState(0); const frameId = React.useRef(`genui_${++_frameIdCounter}`); + // Keep ref in sync so onLoad always sees latest themeVars + React.useEffect(() => { + themeVarsRef.current = themeVars; + }); + // During streaming: strip last incomplete line + remove scripts const displayHtml = React.useMemo(() => { if (!streaming) return html; @@ -53,20 +63,22 @@ export function GenUIFrame({ return lines.join("\n").replace(/]*>[\s\S]*?<\/script>/gi, ""); }, [html, streaming]); - // Static shell — loaded once per iframe instance - const shell = React.useMemo( - () => ` + // Static shell — never changes, no themeVars baked in + const shell = React.useMemo(() => { + const id = frameId.current; + return `