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
10 changes: 9 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
role: "assistant",
parts: [],
}
let droppedUnsignedReasoning = false
// Anthropic adaptive thinking can persist assistant turns like:
// step-start, reasoning(signature), text(""), step-start,
// reasoning(signature). The empty text part is a structural separator,
Expand Down Expand Up @@ -368,14 +369,21 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
continue
}
if (msg.info.finish === "length" && part.metadata?.anthropic?.signature == null) {
droppedUnsignedReasoning = true
continue
}
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
})
}
}
if (assistantMessage.parts.length > 0) {
const hasSubstantivePart = assistantMessage.parts.some(
(part) => part.type !== "step-start" && (part.type !== "text" || part.text.trim().length > 0),
)
if (assistantMessage.parts.length > 0 && (!droppedUnsignedReasoning || hasSubstantivePart)) {
result.push(assistantMessage)
// Inject pending media as a user message for providers that don't support
// media (images, PDFs) in tool results
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,101 @@ function basePart(messageID: string, id: string) {
}
}

function assistantReasoningInput(
finish: string,
metadata?: Record<string, unknown>,
text = "partial answer",
): SessionV1.WithParts[] {
const userID = `m-user-reasoning-${finish}`
const assistantID = `m-assistant-reasoning-${finish}`

return [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u-reasoning"),
type: "text",
text: "continue the task",
},
] as SessionV1.Part[],
},
{
info: { ...assistantInfo(assistantID, userID), finish },
parts: [
{
...basePart(assistantID, "a-text-reasoning"),
type: "text",
text,
},
{
...basePart(assistantID, "a-reasoning"),
type: "reasoning",
text: "incomplete chain",
metadata,
time: { start: 0 },
},
{
...basePart(assistantID, "a-step-finish"),
type: "step-finish",
reason: finish,
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
] as SessionV1.Part[],
},
]
}

describe("session.message-v2.toModelMessage", () => {
test("drops a truncated assistant message left with only empty text", async () => {
const result = await MessageV2.toModelMessages(assistantReasoningInput("length", undefined, ""), model)

expect(result).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "continue the task" }],
},
])
})

test("drops unsigned reasoning from an assistant message truncated by length", async () => {
const result = await MessageV2.toModelMessages(assistantReasoningInput("length"), model)

expect(result[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "partial answer" }],
})
})

test("preserves signed reasoning from an assistant message truncated by length", async () => {
const result = await MessageV2.toModelMessages(
assistantReasoningInput("length", { anthropic: { signature: "signed" } }),
model,
)

expect(result[1]?.content).toContainEqual({
type: "reasoning",
text: "incomplete chain",
providerOptions: { anthropic: { signature: "signed" } },
})
})

test("preserves reasoning from an assistant message that stopped normally", async () => {
const result = await MessageV2.toModelMessages(assistantReasoningInput("stop"), model)

expect(result[1]?.content).toContainEqual({
type: "reasoning",
text: "incomplete chain",
providerOptions: undefined,
})
})

test("filters out messages with no parts", async () => {
const input: SessionV1.WithParts[] = [
{
Expand Down
Loading