diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index e05111fc6ad7..ff7a22f15b6c 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.5", ]) for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 980dd4da844f..ca33580783e6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -814,7 +814,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( } if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + // Only strip provider metadata when crossing provider boundaries (e.g. Anthropic → OpenAI). + // Metadata is provider-namespaced so a different provider ignores unknown keys, but + // passing it is still unnecessary. Within the same provider (e.g. compaction using a + // different model variant), metadata MUST be preserved — Anthropic requires thinking + // block signatures to be byte-identical on replay. + const differentProvider = model.providerID !== msg.info.providerID const media: Array<{ mime: string; url: string }> = [] if ( @@ -836,7 +841,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( assistantMessage.parts.push({ type: "text", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentProvider ? {} : { providerMetadata: part.metadata }), }) if (part.type === "step-start") assistantMessage.parts.push({ @@ -874,7 +879,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + ...(differentProvider ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.state.status === "error") { @@ -887,7 +892,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: part.state.input, output, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + ...(differentProvider ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } else { assistantMessage.parts.push({ @@ -897,7 +902,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: part.state.input, errorText: part.state.error, ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + ...(differentProvider ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } } @@ -911,14 +916,14 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: part.state.input, errorText: "[Tool execution was interrupted]", ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), + ...(differentProvider ? {} : { callProviderMetadata: providerMeta(part.metadata) }), }) } if (part.type === "reasoning") { assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentProvider ? {} : { providerMetadata: part.metadata }), }) } } diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 477b5815b2f3..3680240b72a6 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -3,17 +3,18 @@ import type { Provider } from "@/provider" import { ProviderTransform } from "@/provider" import type { MessageV2 } from "./message-v2" -const COMPACTION_BUFFER = 20_000 - export function usable(input: { cfg: Config.Info; model: Provider.Model }) { const context = input.model.limit.context if (context === 0) return 0 - const reserved = - input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) + // Reserve headroom so compaction triggers before the next turn overflows. + // maxOutputTokens() is capped at 32K (OUTPUT_TOKEN_MAX) regardless of the + // model's raw output limit, so this is never excessively aggressive. + // Users can override via config.compaction.reserved if needed (#12924). + const reserved = input.cfg.compaction?.reserved ?? ProviderTransform.maxOutputTokens(input.model) return input.model.limit.input ? Math.max(0, input.model.limit.input - reserved) - : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model)) + : Math.max(0, context - reserved) } export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 037613d469af..bc73e04f14ed 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -457,20 +457,20 @@ describe("session.compaction.isOverflow", () => { ), ) - // ─── Bug reproduction tests ─────────────────────────────────────────── - // These tests demonstrate that when limit.input is set, isOverflow() - // does not subtract any headroom for the next model response. This means - // compaction only triggers AFTER we've already consumed the full input - // budget, leaving zero room for the next API call's output tokens. + // ─── Headroom reservation tests ────────────────────────────────────── + // These tests verify that when limit.input is set, isOverflow() + // correctly reserves headroom (maxOutputTokens, capped at 32K) so + // compaction triggers before the next API call overflows. // - // Compare: without limit.input, usable = context - output (reserves space). - // With limit.input, usable = limit.input (reserves nothing). + // Previously (bug), the limit.input path only subtracted a 20K buffer + // while the non-input path subtracted the full maxOutputTokens — an + // asymmetry that let sessions grow ~12K tokens too large before compacting. // // Related issues: #10634, #8089, #11086, #12621 // Open PRs: #6875, #12924 it.live( - "BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", + "no headroom when limit.input is set — compaction should trigger near boundary", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service @@ -496,7 +496,7 @@ describe("session.compaction.isOverflow", () => { ) it.live( - "BUG: without limit.input, same token count correctly triggers compaction", + "without limit.input, same token count correctly triggers compaction", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service @@ -516,7 +516,7 @@ describe("session.compaction.isOverflow", () => { ) it.live( - "BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", + "asymmetry — limit.input model does not allow more usage than equivalent model without it", provideTmpdirInstance(() => Effect.gen(function* () { const compact = yield* SessionCompaction.Service @@ -524,7 +524,7 @@ describe("session.compaction.isOverflow", () => { const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 }) const withoutInputLimit = createModel({ context: 200_000, output: 32_000 }) - // 170K total tokens — well above context-output (168K) but below input limit (200K) + // 181K total tokens — above usable (context - maxOutput = 168K) const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } } const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit }) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 231d58c21a91..1c63524bcfdd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -58,6 +58,17 @@ const model: Provider.Model = { release_date: "2026-01-01", } +const model2: Provider.Model = { + ...model, + id: ModelID.make("other-model"), + providerID: ProviderID.make("other"), + api: { + ...model.api, + id: "other-model", + }, + name: "Other Model", +} + function userInfo(id: string): MessageV2.User { return { id, @@ -443,7 +454,200 @@ describe("session.message-v2.toModelMessage", () => { }) }) - test("omits provider metadata when assistant model differs", async () => { + test("preserves reasoning providerMetadata when model matches", async () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent"), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { openai: { signature: "sig-match" } }, + time: { start: 0 }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-match" } } }], + }, + ]) + }) + + test("preserves reasoning providerMetadata when same provider, different model (e.g. compaction)", async () => { + const assistantID = "m-assistant" + // model2sameProvider: same providerID as model but different model variant (simulates compaction) + const model2sameProvider: Provider.Model = { + ...model, + id: ModelID.make("test-model-variant"), + api: { ...model.api, id: "test-model-variant" }, + name: "Test Model Variant", + } + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: model2sameProvider.providerID, + modelID: model2sameProvider.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { openai: { signature: "sig-same-provider" } }, + time: { start: 0 }, + }, + ] as MessageV2.Part[], + }, + ] + + // Same provider, different model — signatures must be preserved (compaction use case) + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-same-provider" } } }], + }, + ]) + }) + + test("omits reasoning providerMetadata when provider differs (cross-provider model switch)", async () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { openai: { signature: "sig-different" } }, + time: { start: 0 }, + }, + ] as MessageV2.Part[], + }, + ] + + // Different provider — provider-specific metadata is stripped + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: undefined }], + }, + ]) + }) + + test("omits text providerMetadata when provider differs", async () => { + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-parent", undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "text", + text: "done", + metadata: { openai: { assistant: "meta" } }, + }, + ] as MessageV2.Part[], + }, + ] + + // Different provider — provider-specific metadata is stripped + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ]) + }) + + test("omits tool callProviderMetadata when provider differs", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID, undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "ok", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + metadata: { openai: { tool: "meta" } }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "ok" }, + }, + ], + }, + ]) + }) + + test("handles undefined metadata gracefully", async () => { const userID = "m-user" const assistantID = "m-assistant" @@ -459,16 +663,24 @@ describe("session.message-v2.toModelMessage", () => { ] as MessageV2.Part[], }, { - info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }), + info: assistantInfo(assistantID, userID, undefined, { + providerID: model2.providerID, + modelID: model2.api.id, + }), parts: [ { ...basePart(assistantID, "a1"), type: "text", text: "done", - metadata: { openai: { assistant: "meta" } }, }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -480,7 +692,6 @@ describe("session.message-v2.toModelMessage", () => { metadata: {}, time: { start: 0, end: 1 }, }, - metadata: { openai: { tool: "meta" } }, }, ] as MessageV2.Part[], }, @@ -495,6 +706,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "reasoning", text: "thinking", providerOptions: undefined }, { type: "tool-call", toolCallId: "call-1",