fix(core): accept interleaved Copilot reasoning_opaque values (#54207)

This commit is contained in:
Aiden Cline
2026-10-09 17:03:36 -05:00
committed by GitHub
Unverified
parent 388406238b
commit b2a3926e87
4 changed files with 96 additions and 14 deletions
@@ -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": {
@@ -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
@@ -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", () => {
@@ -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)