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..f40064534736 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 = { @@ -89,6 +90,8 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre providerOptions: input.provider.options, }) 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) @@ -98,6 +101,9 @@ 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 @@ -108,7 +114,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre content: x, }), ), - ...input.messages, + ...input.messages.slice(previousAssistant + 1), ] const params = yield* input.plugin.trigger( diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..9e130c67f24c 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,14 @@ const layer = Layer.effect( reasoningMap: {}, } let aborted = false + let responseId: string | undefined + + const responseIdFromMetadata = (metadata: unknown) => { + if (!isRecord(metadata)) return undefined + for (const value of Object.values(metadata)) { + if (isRecord(value) && typeof value.responseId === "string") return value.responseId + } + } const parse = (e: unknown) => MessageV2.fromError(e, { @@ -532,6 +541,7 @@ const layer = Layer.effect( return case "finish": + responseId = responseIdFromMetadata(value.providerMetadata) return } }) @@ -636,6 +646,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 +697,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..b35cfcc1f12e 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) { @@ -1142,11 +1143,13 @@ const layer = Layer.effect( const task = tasks.pop() if (task?.type === "subtask") { + previousResponseId = undefined yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs }) continue } if (task?.type === "compaction") { + previousResponseId = undefined const result = yield* compaction.process({ messages: msgs, parentID: lastUser.id, @@ -1283,7 +1286,9 @@ 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 diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c5cea6b797fd..f16f337f864a 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -418,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"),