Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
46 changes: 46 additions & 0 deletions packages/core/test/github-copilot/copilot-chat-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down Expand Up @@ -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)
Expand Down
Loading