From 8951a730e57a1bc46370d01bd7c677f14a391e1b Mon Sep 17 00:00:00 2001 From: Joao Marcos <87662918+joaomj@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:43:59 -0300 Subject: [PATCH 1/2] feat: chain Responses API response IDs --- packages/opencode/src/provider/transform.ts | 7 +++ packages/opencode/src/session/llm.ts | 1 + packages/opencode/src/session/llm/request.ts | 28 +++++++---- packages/opencode/src/session/processor.ts | 15 ++++++ packages/opencode/src/session/prompt.ts | 5 ++ .../opencode/test/provider/transform.test.ts | 46 ++++++++++++++++++- 6 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92d84eac8cb0..0f14ab461665 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1087,6 +1087,7 @@ export function options(input: { model: Provider.Model sessionID: string providerOptions?: Record + previousResponseId?: string }): Record { const result: Record = {} @@ -1106,11 +1107,17 @@ export function options(input: { input.model.api.npm === "@ai-sdk/xai" ) { result["store"] = false + if (input.previousResponseId) { + result["previousResponseId"] = input.previousResponseId + } } if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = false result["promptCacheKey"] = input.sessionID + if (input.previousResponseId) { + result["previousResponseId"] = input.previousResponseId + } } if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..c74f0fec2399 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -45,6 +45,7 @@ export type StreamInput = { tools: Record retries?: number toolChoice?: "auto" | "required" | "none" + previousResponseId?: string } export type StreamRequest = StreamInput & { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107df..84d370c2a907 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -33,6 +33,7 @@ type PrepareInput = { readonly plugin: Plugin.Interface readonly flags: RuntimeFlags.Info readonly isWorkflow: boolean + readonly previousResponseId?: string } export type Prepared = { @@ -53,6 +54,12 @@ export type Prepared = { const mergeOptions = (target: Record, source: Record | undefined): Record => mergeDeep(target, source ?? {}) as Record +export function isResponsesApiModel(model: Pick): boolean { + return ["@ai-sdk/openai", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/azure", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) +} + export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) { const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth" const system = [ @@ -87,6 +94,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre model: input.model, sessionID: input.sessionID, providerOptions: input.provider.options, + previousResponseId: input.previousResponseId, }) const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) if ( @@ -101,15 +109,17 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const messages = isOpenaiOauth || input.isWorkflow ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + : input.previousResponseId && isResponsesApiModel(input.model) + ? input.messages.slice(-1) + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] const params = yield* input.plugin.trigger( "chat.params", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..d19a91ff7d70 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -31,6 +31,7 @@ export type Result = "compact" | "stop" | "continue" export interface Handle { readonly message: SessionV1.Assistant + readonly responseId?: () => string | undefined readonly updateToolCall: ( toolCallID: string, update: (part: SessionV1.ToolPart) => SessionV1.ToolPart, @@ -113,6 +114,17 @@ const layer = Layer.effect( reasoningMap: {}, } let aborted = false + let responseId: string | undefined + + const responseIdFromMetadata = (metadata: unknown) => { + if (!metadata || typeof metadata !== "object") return undefined + for (const value of Object.values(metadata)) { + if (!isRecord(value)) continue + const id = value.responseId + if (typeof id === "string") return id + } + return undefined + } const parse = (e: unknown) => MessageV2.fromError(e, { @@ -532,6 +544,7 @@ const layer = Layer.effect( return case "finish": + responseId = responseIdFromMetadata(value.providerMetadata) return } }) @@ -636,6 +649,7 @@ const layer = Layer.effect( yield* Effect.gen(function* () { ctx.currentText = undefined ctx.reasoningMap = {} + responseId = undefined yield* status.set(ctx.sessionID, { type: "busy" }) const stream = llm.stream(streamInput) @@ -686,6 +700,7 @@ const layer = Layer.effect( get message() { return ctx.assistantMessage }, + responseId: () => responseId, updateToolCall, completeToolCall, process, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..2d91bb263366 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1083,6 +1083,7 @@ const layer = Layer.effect( const ctx = yield* InstanceState.context let structured: unknown let step = 0 + let previousResponseId: string | undefined const session = yield* sessions.get(sessionID).pipe(Effect.orDie) while (true) { @@ -1147,6 +1148,7 @@ const layer = Layer.effect( } if (task?.type === "compaction") { + previousResponseId = undefined const result = yield* compaction.process({ messages: msgs, parentID: lastUser.id, @@ -1283,8 +1285,11 @@ const layer = Layer.effect( tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, + previousResponseId, }) + previousResponseId = result === "continue" ? handle.responseId?.() : undefined + if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4c462bd3dce2..94961b5c606c 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -156,6 +156,48 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) + test("should set previousResponseId for openai provider", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + previousResponseId: "resp_123", + }) + expect(result.previousResponseId).toBe("resp_123") + }) + + test("should not set previousResponseId when it is undefined", () => { + const openaiModel = { + ...mockModel, + providerID: "openai", + api: { + id: "gpt-4", + url: "https://api.openai.com", + npm: "@ai-sdk/openai", + }, + } + const result = ProviderTransform.options({ + model: openaiModel, + sessionID, + }) + expect(result.previousResponseId).toBeUndefined() + }) + + test("should identify Responses API providers", () => { + for (const npm of ["@ai-sdk/openai", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/azure", "@ai-sdk/github-copilot"]) { + expect(LLMRequestPrep.isResponsesApiModel({ api: { npm } } as any)).toBe(true) + } + expect(LLMRequestPrep.isResponsesApiModel({ api: { npm: "@ai-sdk/anthropic" } } as any)).toBe(false) + }) + test("should set store=false for xAI provider by default", () => { const xaiModel = { ...mockModel, @@ -872,8 +914,8 @@ describe("ProviderTransform.providerOptions", () => { }, }) - expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ - openai: { forceReasoning: true, reasoningEffort: "medium" }, + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium", previousResponseId: "resp_123" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "medium", previousResponseId: "resp_123" }, }) }) From 44226c485290d2a902b10ecece917ba77b5efe3b Mon Sep 17 00:00:00 2001 From: Joao Marcos <87662918+joaomj@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:30:08 -0300 Subject: [PATCH 2/2] fix: safely chain stored responses --- packages/opencode/src/provider/transform.ts | 7 -- packages/opencode/src/session/llm/request.ts | 32 +++--- packages/opencode/src/session/processor.ts | 7 +- packages/opencode/src/session/prompt.ts | 2 +- .../opencode/test/provider/transform.test.ts | 106 ++++++++++-------- 5 files changed, 79 insertions(+), 75 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ce72a2c6c512..d6f817b50e4f 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1087,7 +1087,6 @@ export function options(input: { model: Provider.Model sessionID: string providerOptions?: Record - previousResponseId?: string }): Record { const result: Record = {} @@ -1107,17 +1106,11 @@ export function options(input: { input.model.api.npm === "@ai-sdk/xai" ) { result["store"] = false - if (input.previousResponseId) { - result["previousResponseId"] = input.previousResponseId - } } if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = false result["promptCacheKey"] = input.sessionID - if (input.previousResponseId) { - result["previousResponseId"] = input.previousResponseId - } } if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 84d370c2a907..f40064534736 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -54,12 +54,6 @@ export type Prepared = { const mergeOptions = (target: Record, source: Record | undefined): Record => mergeDeep(target, source ?? {}) as Record -export function isResponsesApiModel(model: Pick): boolean { - return ["@ai-sdk/openai", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/azure", "@ai-sdk/github-copilot"].includes( - model.api.npm, - ) -} - export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) { const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth" const system = [ @@ -94,9 +88,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre model: input.model, sessionID: input.sessionID, providerOptions: input.provider.options, - previousResponseId: input.previousResponseId, }) const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) + const previousResponseId = input.previousResponseId && options.store === true ? input.previousResponseId : undefined + if (previousResponseId) options.previousResponseId = previousResponseId if ( input.model.api.npm === "@ai-sdk/azure" && (input.provider.options.useCompletionUrls || input.model.options.useCompletionUrls || options.useCompletionUrls) @@ -106,20 +101,21 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre } if (isOpenaiOauth) options.instructions = system.join("\n") + const previousAssistant = previousResponseId + ? input.messages.findLastIndex((message) => message.role === "assistant") + : -1 const messages = isOpenaiOauth || input.isWorkflow ? input.messages - : input.previousResponseId && isResponsesApiModel(input.model) - ? input.messages.slice(-1) - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages.slice(previousAssistant + 1), + ] const params = yield* input.plugin.trigger( "chat.params", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index d19a91ff7d70..9e130c67f24c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -117,13 +117,10 @@ const layer = Layer.effect( let responseId: string | undefined const responseIdFromMetadata = (metadata: unknown) => { - if (!metadata || typeof metadata !== "object") return undefined + if (!isRecord(metadata)) return undefined for (const value of Object.values(metadata)) { - if (!isRecord(value)) continue - const id = value.responseId - if (typeof id === "string") return id + if (isRecord(value) && typeof value.responseId === "string") return value.responseId } - return undefined } const parse = (e: unknown) => diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2d91bb263366..b35cfcc1f12e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1143,6 +1143,7 @@ const layer = Layer.effect( const task = tasks.pop() if (task?.type === "subtask") { + previousResponseId = undefined yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) continue } @@ -1287,7 +1288,6 @@ const layer = Layer.effect( toolChoice: format.type === "json_schema" ? "required" : undefined, previousResponseId, }) - previousResponseId = result === "continue" ? handle.responseId?.() : undefined if (structured !== undefined) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 85d536794f76..f16f337f864a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -156,48 +156,6 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.store).toBe(false) }) - test("should set previousResponseId for openai provider", () => { - const openaiModel = { - ...mockModel, - providerID: "openai", - api: { - id: "gpt-4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - } - const result = ProviderTransform.options({ - model: openaiModel, - sessionID, - previousResponseId: "resp_123", - }) - expect(result.previousResponseId).toBe("resp_123") - }) - - test("should not set previousResponseId when it is undefined", () => { - const openaiModel = { - ...mockModel, - providerID: "openai", - api: { - id: "gpt-4", - url: "https://api.openai.com", - npm: "@ai-sdk/openai", - }, - } - const result = ProviderTransform.options({ - model: openaiModel, - sessionID, - }) - expect(result.previousResponseId).toBeUndefined() - }) - - test("should identify Responses API providers", () => { - for (const npm of ["@ai-sdk/openai", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/azure", "@ai-sdk/github-copilot"]) { - expect(LLMRequestPrep.isResponsesApiModel({ api: { npm } } as any)).toBe(true) - } - expect(LLMRequestPrep.isResponsesApiModel({ api: { npm: "@ai-sdk/anthropic" } } as any)).toBe(false) - }) - test("should set store=false for xAI provider by default", () => { const xaiModel = { ...mockModel, @@ -460,6 +418,66 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { expect(result.textVerbosity).toBe("low") }) + test("Bedrock Mantle continues stored responses with only new messages", async () => { + const model = { + ...createGpt5Model("openai.gpt-5.5"), + id: "amazon-bedrock/openai.gpt-5.5", + providerID: "amazon-bedrock", + api: { + id: "openai.gpt-5.5", + url: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + npm: "@ai-sdk/amazon-bedrock/mantle", + }, + options: { store: true }, + } + const input = { + user: { + id: "msg_user-test", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "amazon-bedrock", modelID: "openai.gpt-5.5" }, + } as any, + sessionID, + model, + agent: { name: "test", mode: "primary", options: {}, permission: [] } as any, + system: ["Stay concise"], + messages: [ + { role: "user", content: "First turn" }, + { role: "assistant", content: "First reply" }, + { role: "user", content: "Follow-up" }, + ], + tools: {}, + provider: { id: "amazon-bedrock", options: {} } as any, + auth: undefined, + plugin: { + trigger: (_name: string, _input: unknown, output: unknown) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, + } as any, + flags: { outputTokenMax: 32_000, client: "test" } as any, + isWorkflow: false, + previousResponseId: "resp_123", + } as any + const result = await Effect.runPromise(LLMRequestPrep.prepare(input)) + + expect(result.params.options.previousResponseId).toBe("resp_123") + expect(result.messages.at(-1)).toEqual({ role: "user", content: "Follow-up" }) + expect(result.messages.some((message) => message.role === "system" && message.content.includes("Stay concise"))).toBe( + true, + ) + expect(JSON.stringify(result.messages)).not.toContain("First turn") + expect(JSON.stringify(result.messages)).not.toContain("First reply") + + const stateless = await Effect.runPromise( + LLMRequestPrep.prepare({ ...input, model: { ...model, options: { store: false } } }), + ) + expect(stateless.params.options.previousResponseId).toBeUndefined() + expect(JSON.stringify(stateless.messages)).toContain("First turn") + expect(JSON.stringify(stateless.messages)).toContain("First reply") + }) + test("openai-compatible gpt-5 models omit Responses-only reasoningSummary", () => { const model = { ...createGpt5Model("gpt-5.4"), @@ -914,8 +932,8 @@ describe("ProviderTransform.providerOptions", () => { }, }) - expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium", previousResponseId: "resp_123" })).toEqual({ - openai: { forceReasoning: true, reasoningEffort: "medium", previousResponseId: "resp_123" }, + expect(ProviderTransform.providerOptions(model, { reasoningEffort: "medium" })).toEqual({ + openai: { forceReasoning: true, reasoningEffort: "medium" }, }) })