diff --git a/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts index c4e15e0b4fb2..d4629eae8570 100644 --- a/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/core/src/github-copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -82,10 +82,12 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV3Pro for (const part of content) { const partMetadata = getOpenAIMetadata(part) - // Check for reasoningOpaque on any part (may be attached to text/tool-call) + // reasoningOpaque may be attached to any part. Interleaved thinking yields one per + // reasoning segment; like the Copilot VS Code client, replay the latest opaque value + // with the concatenated reasoning text. const partOpaque = (part.providerOptions as { copilot?: { reasoningOpaque?: string } })?.copilot ?.reasoningOpaque - if (partOpaque && !reasoningOpaque) { + if (partOpaque) { reasoningOpaque = partOpaque } @@ -95,7 +97,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV3Pro break } case "reasoning": { - if (part.text) reasoningText = part.text + if (part.text) reasoningText = (reasoningText ?? "") + part.text break } case "tool-call": { diff --git a/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts index 280970c41b4f..600d202d1b95 100644 --- a/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts @@ -465,17 +465,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { const delta = choice.delta - // Capture reasoning_opaque for Copilot multi-turn reasoning - if (delta.reasoning_opaque) { - if (reasoningOpaque != null) { - throw new InvalidResponseDataError({ - data: delta, - message: - "Multiple reasoning_opaque values received in a single response. Only one thinking part per response is supported.", - }) - } - reasoningOpaque = delta.reasoning_opaque - } + // Interleaved thinking (Claude) sends a new reasoning_opaque before each tool call. + // Keep the latest, matching the Copilot VS Code client, which replays only that one. + if (delta.reasoning_opaque) reasoningOpaque = delta.reasoning_opaque // enqueue reasoning before text deltas (Copilot uses reasoning_text): const reasoningContent = delta.reasoning_text diff --git a/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts index 65f4b6a5369c..c305b6571819 100644 --- a/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts +++ b/packages/core/test/github-copilot/convert-to-copilot-messages.test.ts @@ -475,6 +475,48 @@ describe("reasoning (copilot-specific)", () => { }, ]) }) + + test("should replay interleaved reasoning as concatenated text with the latest reasoning_opaque", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Read the readme first.", + providerOptions: { copilot: { reasoningOpaque: "opaque-first" } }, + }, + { + type: "tool-call", + toolCallId: "call_first", + toolName: "read_file", + input: { filePath: "/README.md" }, + providerOptions: { copilot: { reasoningOpaque: "opaque-first" } }, + }, + { + type: "reasoning", + text: "Then the manifest.", + providerOptions: { copilot: { reasoningOpaque: "opaque-second" } }, + }, + { + type: "tool-call", + toolCallId: "call_second", + toolName: "read_file", + input: { filePath: "/package.json" }, + providerOptions: { copilot: { reasoningOpaque: "opaque-second" } }, + }, + ], + }, + ]) + + expect(result).toMatchObject([ + { + role: "assistant", + reasoning_text: "Read the readme first.Then the manifest.", + reasoning_opaque: "opaque-second", + }, + ]) + }) }) describe("full conversation", () => { diff --git a/packages/core/test/github-copilot/copilot-chat-model.test.ts b/packages/core/test/github-copilot/copilot-chat-model.test.ts index bc1e2ecd956e..8bc83f8ecc4f 100644 --- a/packages/core/test/github-copilot/copilot-chat-model.test.ts +++ b/packages/core/test/github-copilot/copilot-chat-model.test.ts @@ -71,6 +71,15 @@ const FIXTURES = { `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`, `data: [DONE]`, ], + + // Interleaved thinking (Claude) sends a new reasoning_opaque before each tool call + interleavedReasoningOpaque: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"Read the readme first."}}],"created":1791590400,"id":"interleaved","model":"claude-opus-5.5"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/README.md\\"}","name":"read_file"},"id":"call_first","index":0,"type":"function"}],"reasoning_opaque":"opaque-first"}}],"created":1791590400,"id":"interleaved","model":"claude-opus-5.5"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"Then the manifest."}}],"created":1791590401,"id":"interleaved","model":"claude-opus-5.5"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/package.json\\"}","name":"read_file"},"id":"call_second","index":1,"type":"function"}],"reasoning_opaque":"opaque-second"}}],"created":1791590401,"id":"interleaved","usage":{"completion_tokens":42,"prompt_tokens":1200,"total_tokens":1242},"model":"claude-opus-5.5"}`, + `data: [DONE]`, + ], } function createMockFetch(chunks: string[]) { @@ -482,6 +491,43 @@ describe("doStream", () => { }) }) + test("should pair each interleaved reasoning segment with its own reasoning_opaque", async () => { + const mockFetch = createMockFetch(FIXTURES.interleavedReasoningOpaque) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + expect(parts.filter((p) => p.type === "error")).toEqual([]) + expect( + parts.filter((p) => p.type === "reasoning-delta" || p.type === "reasoning-end" || p.type === "tool-call"), + ).toMatchObject([ + { type: "reasoning-delta", delta: "Read the readme first." }, + { type: "reasoning-end", providerMetadata: { copilot: { reasoningOpaque: "opaque-first" } } }, + { + type: "tool-call", + toolCallId: "call_first", + providerMetadata: { copilot: { reasoningOpaque: "opaque-first" } }, + }, + { type: "reasoning-delta", delta: "Then the manifest." }, + { type: "reasoning-end", providerMetadata: { copilot: { reasoningOpaque: "opaque-second" } } }, + { + type: "tool-call", + toolCallId: "call_second", + providerMetadata: { copilot: { reasoningOpaque: "opaque-second" } }, + }, + ]) + expect(parts.at(-1)).toMatchObject({ + type: "finish", + finishReason: { unified: "tool-calls" }, + providerMetadata: { copilot: { reasoningOpaque: "opaque-second" } }, + }) + }) + test("should include response metadata from first chunk", async () => { const mockFetch = createMockFetch(FIXTURES.basicText) const model = createModel(mockFetch)