diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 004fb77f91a0..83f7f9d0367c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1428,8 +1428,13 @@ export namespace Provider { const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) if (combined) opts.signal = combined - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + // Strip responses item ids unless we are deliberately replaying stored Azure items. + // Azure uses a dedicated SDK package, so the check must cover both providers. + if ( + (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") && + opts.body && + opts.method === "POST" + ) { const body = JSON.parse(opts.body as string) const isAzure = model.providerID.includes("azure") const keepIds = isAzure && body.store === true diff --git a/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts b/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts index 83e46015dd1e..6a549a9eb0af 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts @@ -6,7 +6,7 @@ import { } from "@ai-sdk/provider" import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils" import { z } from "zod/v4" -import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types" +import type { OpenAIResponsesInput, OpenAIResponsesInputItem, OpenAIResponsesReasoning } from "./openai-responses-api-types" import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell" /** @@ -118,13 +118,58 @@ export async function convertToOpenAIResponsesInput({ } case "assistant": { + const assistantInput: OpenAIResponsesInput = [] const reasoningMessages: Record = {} const toolCallParts: Record = {} + let pendingOutput: OpenAIResponsesInputItem | undefined + let pendingReasoning: OpenAIResponsesInputItem | undefined + + const pushAssistantOutput = (item: OpenAIResponsesInputItem) => { + if (pendingReasoning !== undefined) { + assistantInput.push(pendingReasoning) + pendingReasoning = undefined + assistantInput.push(item) + return + } + + if (pendingOutput !== undefined) { + assistantInput.push(pendingOutput) + } + + pendingOutput = item + } + + const pushAssistantReasoning = (item: OpenAIResponsesInputItem) => { + if (pendingOutput !== undefined) { + assistantInput.push(item) + assistantInput.push(pendingOutput) + pendingOutput = undefined + return + } + + if (pendingReasoning !== undefined) { + assistantInput.push(pendingReasoning) + } + + pendingReasoning = item + } + + const flushAssistantPending = () => { + if (pendingReasoning !== undefined) { + assistantInput.push(pendingReasoning) + pendingReasoning = undefined + } + + if (pendingOutput !== undefined) { + assistantInput.push(pendingOutput) + pendingOutput = undefined + } + } for (const part of content) { switch (part.type) { case "text": { - input.push({ + pushAssistantOutput({ role: "assistant", content: [{ type: "output_text", text: part.text }], id: (part.providerOptions?.openai?.itemId as string) ?? undefined, @@ -140,7 +185,7 @@ export async function convertToOpenAIResponsesInput({ if (hasLocalShellTool && part.toolName === "local_shell") { const parsedInput = localShellInputSchema.parse(part.input) - input.push({ + pushAssistantOutput({ type: "local_shell_call", call_id: part.toolCallId, id: (part.providerOptions?.openai?.itemId as string) ?? undefined, @@ -157,7 +202,7 @@ export async function convertToOpenAIResponsesInput({ break } - input.push({ + pushAssistantOutput({ type: "function_call", call_id: part.toolCallId, name: part.toolName, @@ -171,7 +216,7 @@ export async function convertToOpenAIResponsesInput({ case "tool-result": { if (store) { // use item references to refer to tool results from built-in tools - input.push({ type: "item_reference", id: part.toolCallId }) + pushAssistantOutput({ type: "item_reference", id: part.toolCallId }) } else { warnings.push({ type: "other", @@ -197,7 +242,7 @@ export async function convertToOpenAIResponsesInput({ if (store) { if (reasoningMessage === undefined) { // use item references to refer to reasoning (single reference) - input.push({ type: "item_reference", id: reasoningId }) + pushAssistantReasoning({ type: "item_reference", id: reasoningId }) // store unused reasoning message to mark id as used reasoningMessages[reasoningId] = { @@ -231,7 +276,7 @@ export async function convertToOpenAIResponsesInput({ encrypted_content: providerOptions?.reasoningEncryptedContent, summary: summaryParts, } - input.push(reasoningMessages[reasoningId]) + pushAssistantReasoning(reasoningMessages[reasoningId]) } else { reasoningMessage.summary.push(...summaryParts) } @@ -247,6 +292,9 @@ export async function convertToOpenAIResponsesInput({ } } + flushAssistantPending() + input.push(...assistantInput) + break } diff --git a/packages/opencode/test/provider/openai-responses-input.test.ts b/packages/opencode/test/provider/openai-responses-input.test.ts new file mode 100644 index 000000000000..8234b7e7ef98 --- /dev/null +++ b/packages/opencode/test/provider/openai-responses-input.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test" +import { convertToOpenAIResponsesInput } from "../../src/provider/sdk/copilot/responses/convert-to-openai-responses-input" + +describe("convertToOpenAIResponsesInput", () => { + test("emits reasoning before an assistant message when replay order arrives reversed", async () => { + const result = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { + itemId: "msg_123", + }, + }, + }, + { + type: "reasoning", + text: "thinking", + providerOptions: { + copilot: { + itemId: "rs_123", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + ], + }, + ] as any, + systemMessageMode: "system", + store: false, + }) + + expect(result.input).toMatchObject([ + { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted", + summary: [{ type: "summary_text", text: "thinking" }], + }, + { + role: "assistant", + id: "msg_123", + content: [{ type: "output_text", text: "Hello" }], + }, + ]) + }) + + test("emits reasoning before a function call when replay order arrives reversed", async () => { + const result = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_123", + toolName: "read_file", + input: { filePath: "/README.md" }, + providerExecuted: false, + providerOptions: { + openai: { + itemId: "fc_123", + }, + }, + }, + { + type: "reasoning", + text: "thinking", + providerOptions: { + copilot: { + itemId: "rs_456", + reasoningEncryptedContent: "encrypted", + }, + }, + }, + ], + }, + ] as any, + systemMessageMode: "system", + store: false, + }) + + expect(result.input).toMatchObject([ + { + type: "reasoning", + id: "rs_456", + encrypted_content: "encrypted", + summary: [{ type: "summary_text", text: "thinking" }], + }, + { + type: "function_call", + id: "fc_123", + call_id: "call_123", + name: "read_file", + arguments: '{"filePath":"/README.md"}', + }, + ]) + }) +}) \ No newline at end of file