Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
18 changes: 7 additions & 11 deletions packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/session-runner-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion packages/llm/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion packages/llm/src/protocols/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/llm/src/protocols/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
7 changes: 5 additions & 2 deletions packages/llm/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions packages/llm/test/provider/anthropic-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions packages/llm/test/provider/bedrock-converse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions packages/llm/test/provider/gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
28 changes: 28 additions & 0 deletions packages/llm/test/provider/openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/llm/native-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
]
})
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand All @@ -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 }),
})
}
}
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/test/session/llm-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion specs/v2/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading