Selaa lähdekoodia

fix(core): preserve compatible reasoning replay

Jack 1 kuukausi sitten
vanhempi
sitoutus
b6208a8c50

+ 1 - 1
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`.

+ 7 - 11
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(

+ 1 - 1
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",

+ 6 - 1
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
         }

+ 6 - 1
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

+ 2 - 1
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") {

+ 5 - 2
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)

+ 12 - 0
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(

+ 14 - 0
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<BedrockConverse.BedrockConverseBody>(
+        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(

+ 10 - 0
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<Gemini.GeminiBody>(
+        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({

+ 28 - 0
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<OpenAIResponses.OpenAIResponsesBody>(
+        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(

+ 1 - 1
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,
       }),
     ]
   })

+ 6 - 2
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 }),
           })
         }
       }

+ 33 - 0
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" } },

+ 36 - 0
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"

+ 1 - 1
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