From 7a2240e52ad150b4660f2ab3d6e26b496bc32580 Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Fri, 20 Mar 2026 19:38:05 +0530 Subject: [PATCH 1/7] fix --- packages/copilot-sdk/package.json | 1 + .../src/chat/classes/AbstractChat.ts | 142 +++++++----- .../src/ui/components/ui/markdown.tsx | 3 +- pnpm-lock.yaml | 219 +++++++++++++----- 4 files changed, 249 insertions(+), 116 deletions(-) diff --git a/packages/copilot-sdk/package.json b/packages/copilot-sdk/package.json index 3ece582..6320a8c 100644 --- a/packages/copilot-sdk/package.json +++ b/packages/copilot-sdk/package.json @@ -103,6 +103,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/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index 43b3d1e..a013879 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -799,11 +799,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); } @@ -836,61 +850,47 @@ 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", { + // Handle message:end mid-stream (server-side agent loop turn completed). + // Do NOT create a separate message for each turn — keep accumulating into + // the same message so the user sees one assistant bubble, not three. + // Just skip message:end entirely and let content continue flowing. + if (chunk.type === "message:end" && this.streamState) { + this.debug("message:end mid-stream (keeping streamState alive)", { messageId: this.streamState.messageId, contentLength: this.streamState.content.length, toolCallsInState: this.streamState.toolCalls?.length ?? 0, chunkCount, }); + // Don't reset streamState — next message:start will be ignored and + // subsequent deltas will append to the same message. + continue; + } - // 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 } - : {}), - }), + // Handle message:start after a mid-stream message:end. + // Since we keep streamState alive above, this only fires if streamState + // was null for another reason. Just skip it — deltas will flow into + // the existing streamState. + if (chunk.type === "message:start" && this.streamState !== null) { + this.debug( + "message:start mid-stream (streamState already active, skipping)", ); - this.callbacks.onMessageFinish?.(turnMessage); - - // Reset stream state for next turn - will be initialized on next message:start - this.streamState = null; continue; } - // Handle message:start after a mid-stream finalization + // Handle message:start when streamState is null (shouldn't happen in + // normal flow, but handle gracefully by creating a new message). if (chunk.type === "message:start" && this.streamState === null) { - this.debug("message:start after mid-stream end - creating new message"); - const newMessage = createEmptyAssistantMessage() as T; + this.debug( + "message:start with null streamState - creating new message", + ); + const visibleMessages = this.state.messages; + const currentLeafId = + visibleMessages.length > 0 + ? visibleMessages[visibleMessages.length - 1].id + : undefined; + const newMessage = createEmptyAssistantMessage(undefined, { + parentId: currentLeafId, + }) as T; this.state.pushMessage(newMessage); this.streamState = createStreamState(newMessage.id); this.callbacks.onMessageStart?.(newMessage.id); @@ -936,6 +936,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). @@ -954,14 +962,19 @@ export class AbstractChat { // Skip plain assistant text — already streamed if (msg.role === "assistant" && !msg.tool_calls?.length) 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 @@ -983,8 +996,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") { @@ -1106,11 +1120,25 @@ export class AbstractChat { ), }); - const currentStreamToolCallIds = new Set( - this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? [], - ); + const currentStreamToolCallIds = new Set([ + ...(this.streamState?.toolCalls?.map((toolCall) => toolCall.id) ?? + []), + // Also include IDs from toolResults (populated by action:start/args/end + // chunks for server-side tools). Without this, assistant messages with + // tool_calls from done.messages are treated as "new" and inserted as + // duplicates even though the tools were already executed in-stream. + ...(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). + let insertChainParentId: string | undefined = + this.streamState?.messageId; + // Build hidden map from stream state's toolResults const toolCallsHidden: Record = {}; if (this.streamState?.toolResults) { @@ -1163,13 +1191,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/ui/components/ui/markdown.tsx b/packages/copilot-sdk/src/ui/components/ui/markdown.tsx index b640a54..b586e39 100644 --- a/packages/copilot-sdk/src/ui/components/ui/markdown.tsx +++ b/packages/copilot-sdk/src/ui/components/ui/markdown.tsx @@ -1,6 +1,7 @@ import { memo, ComponentProps } from "react"; import { Streamdown, LinkSafetyConfig } from "streamdown"; import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; export type MarkdownProps = { children: string; @@ -45,7 +46,7 @@ function MarkdownComponent({ return (
= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -5504,9 +5491,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -5531,6 +5530,9 @@ packages: hast-util-to-string@3.0.1: resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -5916,6 +5918,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.39: + resolution: {integrity: sha512-FR2f6y85+81ZLO0GPhyQ+EJl/E5ILNWltJhpAeOTzRny952Z13x2867lTFDmvMZix//Ux3CuMQ2VkLXRbUwOFg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6194,6 +6200,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -6268,6 +6277,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} @@ -7064,6 +7076,9 @@ packages: rehype-harden@1.1.7: resolution: {integrity: sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw==} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -7076,6 +7091,9 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx@3.1.1: resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} @@ -7711,6 +7729,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -10909,6 +10930,15 @@ snapshots: react: 18.3.1 shiki: 3.20.0 + '@streamdown/math@1.0.2(react@18.3.1)': + dependencies: + katex: 0.16.39 + react: 18.3.1 + rehype-katex: 7.0.1 + remark-math: 6.0.0 + transitivePeerDependencies: + - supports-color + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -11170,6 +11200,8 @@ snapshots: dependencies: '@types/node': 20.19.27 + '@types/katex@0.16.8': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11772,6 +11804,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} @@ -12240,13 +12274,13 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.0.10(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.0.10(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.0.10 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -12280,32 +12314,12 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-next@16.1.5(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.1.5 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-next@16.1.5(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.1.5 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) @@ -12328,7 +12342,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12339,11 +12353,11 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -12354,18 +12368,18 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -12390,7 +12404,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13087,6 +13101,28 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -13098,6 +13134,10 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -13193,6 +13233,13 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -13537,6 +13584,10 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.39: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -13829,6 +13880,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -14002,6 +14065,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.39 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.8 @@ -15014,6 +15087,16 @@ snapshots: dependencies: unist-util-visit: 5.0.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.39 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -15044,6 +15127,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-mdx@3.1.1: dependencies: mdast-util-mdx: 3.0.0 @@ -15927,6 +16019,11 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 From 25cee8928e72c5da90e0f4777ae20a9625c3e99d Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Wed, 25 Mar 2026 19:40:35 +0530 Subject: [PATCH 2/7] fix: continue with tool execution --- packages/copilot-sdk/src/chat/AbstractAgentLoop.ts | 4 +++- packages/copilot-sdk/src/chat/ChatWithTools.ts | 6 ++++++ packages/copilot-sdk/src/chat/classes/AbstractChat.ts | 9 +++++++++ packages/copilot-sdk/src/react/hooks/useTool.ts | 9 ++++++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts b/packages/copilot-sdk/src/chat/AbstractAgentLoop.ts index c621214..e66170b 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 a858854..8f807bb 100644 --- a/packages/copilot-sdk/src/chat/ChatWithTools.ts +++ b/packages/copilot-sdk/src/chat/ChatWithTools.ts @@ -265,6 +265,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 e3e6943..90c3bbd 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -428,6 +428,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) { diff --git a/packages/copilot-sdk/src/react/hooks/useTool.ts b/packages/copilot-sdk/src/react/hooks/useTool.ts index f8c1c88..4ff1a96 100644 --- a/packages/copilot-sdk/src/react/hooks/useTool.ts +++ b/packages/copilot-sdk/src/react/hooks/useTool.ts @@ -240,8 +240,11 @@ export function useTools(tools: ToolSet): void { // Update ref when tools change toolsRef.current = tools; - // Create a stable key from tool names to detect actual changes - const toolsKey = Object.keys(tools).sort().join(","); + // Create a stable key from tool names + availability flags to detect actual changes + const toolsKey = Object.entries(tools) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, def]) => `${name}:${def.available ?? true}`) + .join(","); useEffect(() => { const currentTools = toolsRef.current; @@ -269,7 +272,7 @@ export function useTools(tools: ToolSet): void { registeredToolsRef.current = []; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toolsKey]); // Only re-run when tool names change, not on every render + }, [toolsKey]); // Re-run when tool names or availability flags change } /** From a2f5667b15482a681c3a317be3272ca10a5dbd2f Mon Sep 17 00:00:00 2001 From: Sahil Date: Sun, 29 Mar 2026 19:58:06 +0530 Subject: [PATCH 3/7] 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, From 8790f9af8f9dbbca1767488e7a9cd4a30480755b Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Mon, 30 Mar 2026 18:41:52 +0530 Subject: [PATCH 4/7] single stream --- .../copilot-sdk/src/chat/classes/AbstractChat.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index f2b0359..2283574 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 @@ -1495,6 +1496,18 @@ 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; + } + // 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 From bcaf0f42b48677b82970a6158872bbb40a369c20 Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Fri, 17 Apr 2026 13:47:26 +0530 Subject: [PATCH 5/7] fix(sdk): resolve post generative-ui-v2 merge stream bugs - AbstractChat: check toolResults.size in message:end so tool-only turns reset streamState and don't attach next turn's tools to previous message - AbstractChat: in single-turn mode parent insertChainParentId from message before the streaming message, not from streamState.messageId - AbstractChat: skip role:tool messages from done.messages in single-turn (already in streamState.toolResults, re-inserting creates duplicates) - connected-chat: restrict liveExecutions to last assistant message only; prefer metadata.toolExecutions for historical messages - connected-chat: filter unmatched executions to pending/executing only so stale completed cards don't bleed into new streaming message - connected-chat: null-safe message filter to prevent render crashes Co-Authored-By: Claude Sonnet 4.6 --- .../src/chat/classes/AbstractChat.ts | 38 +++- .../ui/components/composed/connected-chat.tsx | 186 +++++++++++------- 2 files changed, 148 insertions(+), 76 deletions(-) diff --git a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts index 20acb85..bd6fc3c 100644 --- a/packages/copilot-sdk/src/chat/classes/AbstractChat.ts +++ b/packages/copilot-sdk/src/chat/classes/AbstractChat.ts @@ -1151,7 +1151,13 @@ export class AbstractChat { } // multi-step (default): finalize current turn as its own UIMessage - if (!this.streamState.content) { + // 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 ( + !this.streamState.content && + (this.streamState.toolResults?.size ?? 0) === 0 + ) { // Nothing streamed yet for this turn — skip finalization } else { this.debug("message:end mid-stream", { @@ -1603,8 +1609,21 @@ export class AbstractChat { // 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). - let insertChainParentId: string | undefined = - this.streamState?.messageId; + // 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 = {}; @@ -1637,6 +1656,19 @@ export class AbstractChat { 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 diff --git a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx index 267c6bd..6031575 100644 --- a/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx +++ b/packages/copilot-sdk/src/ui/components/composed/connected-chat.tsx @@ -410,81 +410,102 @@ function CopilotChatBase( m.toolCalls.map((tc: { id: string }) => tc.id), ); - // Try live executions first (from agentLoop) - const liveExecutions = toolExecutions.filter( - (exec: ToolExecutionData) => toolCallIds.has(exec.id), - ); - - if (liveExecutions.length > 0) { - // Enrich live executions with results from tool messages if not already present - messageToolExecutions = liveExecutions.map( - (exec: ToolExecutionData) => { - if (!exec.result && toolResultsMap.has(exec.id)) { - const resultContent = toolResultsMap.get(exec.id)!; - try { - return { ...exec, result: JSON.parse(resultContent) }; - } catch { - return { - ...exec, - result: { success: false, message: resultContent }, - }; - } - } - return exec; - }, - ); + // Determine if this is the last assistant message — live agentLoop executions + // only apply to the current (last) streaming message. Applying them to older + // messages would show the current turn's tools on previous message bubbles. + const isLastMsg = + m.id === + [...messages] + .reverse() + .find((msg: UIMessage) => msg.role === "assistant")?.id; + + // For non-last messages, prefer saved metadata executions (correct completed + // status) over rebuilding from tool_calls or live agentLoop executions (which + // would show wrong executing status from a different turn). + const savedMeta = ( + m.metadata as { toolExecutions?: ToolExecutionData[] } + )?.toolExecutions; + if (!isLastMsg && savedMeta && savedMeta.length > 0) { + messageToolExecutions = savedMeta; } else { - // Build from stored tool_calls + tool messages (historical) - // Get hidden info from message metadata (set by handleJsonResponse) - const toolCallsHidden = ( - m.metadata as { toolCallsHidden?: Record } - )?.toolCallsHidden; - - messageToolExecutions = m.toolCalls.map( - (tc: { - id: string; - function: { name: string; arguments: string }; - }) => { - const resultContent = toolResultsMap.get(tc.id); - let result: ToolExecutionData["result"] = undefined; - if (resultContent) { + // Try live executions first (from agentLoop) — only for the last assistant message + const liveExecutions = isLastMsg + ? toolExecutions.filter((exec: ToolExecutionData) => + toolCallIds.has(exec.id), + ) + : []; + + if (liveExecutions.length > 0) { + // Enrich live executions with results from tool messages if not already present + messageToolExecutions = liveExecutions.map( + (exec: ToolExecutionData) => { + if (!exec.result && toolResultsMap.has(exec.id)) { + const resultContent = toolResultsMap.get(exec.id)!; + try { + return { ...exec, result: JSON.parse(resultContent) }; + } catch { + return { + ...exec, + result: { success: false, message: resultContent }, + }; + } + } + return exec; + }, + ); + } else { + // Build from stored tool_calls + tool messages (historical) + // Get hidden info from message metadata (set by handleJsonResponse) + const toolCallsHidden = ( + m.metadata as { toolCallsHidden?: Record } + )?.toolCallsHidden; + + messageToolExecutions = m.toolCalls.map( + (tc: { + id: string; + function: { name: string; arguments: string }; + }) => { + const resultContent = toolResultsMap.get(tc.id); + let result: ToolExecutionData["result"] = undefined; + if (resultContent) { + try { + result = JSON.parse(resultContent); + } catch { + result = { success: false, message: resultContent }; + } + } + let args: Record = {}; try { - result = JSON.parse(resultContent); + args = JSON.parse(tc.function.arguments || "{}"); } catch { - result = { success: false, message: resultContent }; + // Keep empty args + } + // Check hidden from metadata first (from server response), + // then fall back to registeredTools + let hidden = toolCallsHidden?.[tc.id]; + if (hidden === undefined) { + const toolDef = registeredTools?.find( + (t) => t.name === tc.function.name, + ); + hidden = toolDef?.hidden; } - } - let args: Record = {}; - try { - args = JSON.parse(tc.function.arguments || "{}"); - } catch { - // Keep empty args - } - // Check hidden from metadata first (from server response), - // then fall back to registeredTools - let hidden = toolCallsHidden?.[tc.id]; - if (hidden === undefined) { - const toolDef = registeredTools?.find( - (t) => t.name === tc.function.name, - ); - hidden = toolDef?.hidden; - } - return { - id: tc.id, - name: tc.function.name, - args, - status: (result - ? "completed" - : "pending") as ToolExecutionData["status"], - result, - timestamp: - m.createdAt instanceof Date - ? m.createdAt.getTime() - : Date.now(), - hidden, - }; - }, - ); + return { + id: tc.id, + name: tc.function.name, + args, + status: (result + ? "completed" + : "pending") as ToolExecutionData["status"], + result, + timestamp: + m.createdAt instanceof Date + ? m.createdAt.getTime() + : Date.now(), + hidden, + }; + }, + ); + } } } @@ -503,7 +524,17 @@ function CopilotChatBase( // For the last assistant message during streaming, attach any unmatched // tool executions (created by action:start before tool_calls arrive). // This enables progressive rendering of client tools like generative UI. + // IMPORTANT: Only run for the last assistant message — running for ALL + // assistant messages causes previous-turn tool cards (plan approval, specialist + // working indicators) to bleed into older message bubbles when agentLoop + // toolExecutions haven't been cleared between turns. + const isLastAssistant = + m.id === + [...messages] + .reverse() + .find((msg: UIMessage) => msg.role === "assistant")?.id; if ( + isLastAssistant && !messageToolExecutions && m.role === "assistant" && isLoading && @@ -517,8 +548,16 @@ function CopilotChatBase( (msg.toolCalls || []).map((tc: { id: string }) => tc.id), ), ); + // Only attach executions that are actively running (pending/executing). + // Completed/failed executions from a previous turn persist in agentLoop + // state between turns and would otherwise bleed into the new streaming + // message as stale plan cards or specialist indicators. const unmatchedExecutions = toolExecutions.filter( - (exec: ToolExecutionData) => !allMatchedIds.has(exec.id), + (exec: ToolExecutionData) => + !allMatchedIds.has(exec.id) && + (exec.status === "executing" || + exec.status === "pending" || + exec.approvalStatus === "required"), ); if (unmatchedExecutions.length > 0) { messageToolExecutions = unmatchedExecutions; @@ -551,7 +590,8 @@ function CopilotChatBase( }; }) // Filter out empty assistant messages that only had hidden tools - .filter((m) => { + .filter((m): m is NonNullable => { + if (!m) return false; if ( m.role === "assistant" && !m.content && From 71c645dec690a849763cd59d3f7bf39d2ee395bb Mon Sep 17 00:00:00 2001 From: ankushchhabradelta4infotech-ai Date: Fri, 17 Apr 2026 19:13:40 +0530 Subject: [PATCH 6/7] fix(llm-sdk): server config systemPrompt takes priority over request Previously request.systemPrompt || this.config.systemPrompt meant a client-sent prompt could silently override the server-configured one, breaking generativeUISystemPrompt and any server-side prompt setup. Co-Authored-By: Claude Sonnet 4.6 --- .../src/experimental/renderers/GenUIFrame.tsx | 78 ++++++++++++++++--- packages/llm-sdk/src/server/runtime.ts | 6 +- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx b/packages/copilot-sdk/src/experimental/renderers/GenUIFrame.tsx index cfb2b3e..5556795 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 `