Compare commits

...

1 Commits

Author SHA1 Message Date
Jack b6208a8c50 fix(core): preserve compatible reasoning replay 2026-07-16 07:42:32 +00:00
16 changed files with 169 additions and 22 deletions
+1 -1
View File
@@ -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`.
@@ -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(
@@ -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",
@@ -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
}
@@ -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
View File
@@ -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") {
@@ -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)
@@ -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(
@@ -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
View File
@@ -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({
@@ -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(
@@ -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
View File
@@ -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 }),
})
}
}
@@ -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" } },
@@ -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
View File
@@ -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