diff --git a/CONTEXT.md b/CONTEXT.md index 5e5955d344a1..2d429e4be953 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -132,7 +132,7 @@ _Avoid_: Response envelope - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. -- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. +- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata. Visible reasoning remains canonical until target-protocol lowering: OpenAI Chat-compatible targets replay it as `reasoning_content`, while protocols that require signed native reasoning lower unsigned reasoning to ordinary assistant text. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30f1..c0833d663799 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -74,17 +74,13 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") - return sameModel - ? [ - { - type: "reasoning", - text: item.text, - providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, - }, - ] - : item.text.length > 0 - ? [{ type: "text", text: item.text }] - : [] + return [ + { + type: "reasoning", + text: item.text, + providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + }, + ] const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) if (item.provider?.executed !== true) return [call] const result = toolResult( diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5798b665a86a..5401597b56f3 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -457,7 +457,7 @@ Recent work ) expect(messages[0]?.content).toEqual([ - { type: "text", text: "Visible thought" }, + { type: "reasoning", text: "Visible thought", providerMetadata: undefined }, { type: "tool-call", id: "hosted-old-model", diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 1c0dcd32a433..d6746cf58652 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -447,10 +447,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( continue } if (part.type === "reasoning") { + const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata) + if (!signature) { + content.push({ type: "text", text: part.text }) + continue + } content.push({ type: "thinking", thinking: part.text, - signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), + signature, }) continue } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index c447a1a39d34..e0217fc65c57 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -348,9 +348,14 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( continue } if (part.type === "reasoning") { + const signature = reasoningSignature(part) + if (!signature) { + content.push({ text: part.text }) + continue + } content.push({ reasoningContent: { - reasoningText: { text: part.text, signature: reasoningSignature(part) }, + reasoningText: { text: part.text, signature }, }, }) continue diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c4bb9476a49d..ff2451c9f3d1 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -236,7 +236,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR continue } if (part.type === "reasoning") { - parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) }) + const signature = thoughtSignature(part.providerMetadata) + parts.push(signature ? { text: part.text, thought: true, thoughtSignature: signature } : { text: part.text }) continue } if (part.type === "tool-call") { diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c921b..74d4421e9768 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -383,9 +383,12 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ continue } if (part.type === "reasoning") { - flushText() const reasoning = lowerReasoning(part) - if (!reasoning) continue + if (!reasoning) { + content.push({ type: "text", text: part.text }) + continue + } + flushText() if (store !== false) { if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) reasoningReferences.add(reasoning.id) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 898931295849..eae42eb47436 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -362,6 +362,18 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("lowers unsigned foreign reasoning to assistant text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }), + ) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", content: [{ type: "text", text: "foreign thought" }] }], + }) + }), + ) + it.effect("parses text, reasoning, and usage stream fixtures", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 46657331a344..88b95d24d61e 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -355,6 +355,20 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("lowers unsigned foreign reasoning to assistant text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([{ role: "assistant", content: [{ text: "foreign thought" }] }]) + }), + ) + it.effect("emits provider-error for throttlingException", () => Effect.gen(function* () { const body = eventStreamBody( diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 1dc253c0ea88..0ddf88ed6150 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -431,6 +431,16 @@ describe("Gemini route", () => { }), ) + it.effect("lowers unsigned foreign reasoning to assistant text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }), + ) + + expect(prepared.body.contents).toEqual([{ role: "model", parts: [{ text: "foreign thought" }] }]) + }), + ) + it.effect("emits streamed tool calls and maps finish reason", () => Effect.gen(function* () { const body = sseEvents({ diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af47..e11c23f0cbb5 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -818,6 +818,34 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("lowers foreign reasoning without continuation state to assistant text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "text", text: "before" }, + { type: "reasoning", text: "foreign thought" }, + { type: "text", text: "after" }, + ]), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + role: "assistant", + content: [ + { type: "output_text", text: "before" }, + { type: "output_text", text: "foreign thought" }, + { type: "output_text", text: "after" }, + ], + }, + ]) + }), + ) + it.effect("streams each reasoning summary part as a separate block", () => Effect.gen(function* () { const response = yield* LLMClient.generate( diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c362..730704bbd497 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -110,7 +110,7 @@ const messages = (input: readonly ModelMessage[]) => { Message.make({ role: message.role, content: content(message.content), - native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined, + native: isRecord(message.providerOptions) ? message.providerOptions : undefined, }), ] }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..242915789a6e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -243,6 +243,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + const replaysForeignReasoning = + model.api.npm === "@ai-sdk/openai-compatible" && + typeof model.capabilities.interleaved === "object" && + model.capabilities.interleaved.field === "reasoning_content" const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( @@ -360,7 +364,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { - if (differentModel) { + if (differentModel && !replaysForeignReasoning) { if (part.text.trim().length > 0) assistantMessage.parts.push({ type: "text", @@ -371,7 +375,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( assistantMessage.parts.push({ type: "reasoning", text: part.text, - providerMetadata: part.metadata, + ...(differentModel ? {} : { providerMetadata: part.metadata }), }) } } diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc17481..5f0debe95ae6 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -332,6 +332,39 @@ describe("session.llm-native.request", () => { ]) }) + it.effect("preserves OpenAI-compatible reasoning fields through the native adapter", () => + Effect.gen(function* () { + const compatible = { + ...baseModel, + providerID: ProviderV2.ID.make("moonshotai"), + api: { + id: "kimi-k2.5", + url: "https://api.moonshot.test/v1", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + ...baseModel.capabilities, + interleaved: { field: "reasoning_content" as const }, + }, + } + const prepared = yield* prepareNativeRequest({ + model: compatible, + apiKey: "test-key", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + providerOptions: { openaiCompatible: { reasoning_content: "thinking" } }, + }, + ], + }) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", content: "answer", reasoning_content: "thinking" }], + }) + }), + ) + test("selects native request routes for provider packages", () => { const openai = LLMNative.model({ model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 9bb688aeddb0..6ede516a4f93 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -684,6 +684,42 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("preserves foreign reasoning for OpenAI-compatible reasoning fields", async () => { + const assistantID = "m-assistant" + const compatible = { + ...model, + api: { ...model.api, npm: "@ai-sdk/openai-compatible" }, + capabilities: { ...model.capabilities, reasoning: true, interleaved: { field: "reasoning_content" } }, + } satisfies Provider.Model + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "m-user", undefined, { providerID: "other", modelID: "other" }), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "thinking", + metadata: { anthropic: { signature: "foreign" } }, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a2"), + type: "text", + text: "answer", + }, + ] as SessionV1.Part[], + }, + ] + + expect(ProviderTransform.message(await MessageV2.toModelMessages(input, compatible), compatible, {})).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + providerOptions: { openaiCompatible: { reasoning_content: "thinking" } }, + }, + ]) + }) + test("replaces compacted tool output with placeholder", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/specs/v2/session.md b/specs/v2/session.md index f3c4211c6c60..95a4231155a1 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -49,7 +49,7 @@ SessionExecution.resume(sessionID) The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. -Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. +Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native metadata replays only while the historical assistant model matches the selected continuation model. After a model switch, visible reasoning remains canonical without opaque metadata until the target protocol either replays it in a compatible reasoning field or lowers unsigned reasoning to ordinary assistant text. ## Context Epochs