mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(core): accept interleaved Copilot reasoning_opaque values (#54207)
This commit is contained in:
committed by
GitHub
Unverified
parent
388406238b
commit
b2a3926e87
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user