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
76 changes: 76 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"

type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]

Expand Down Expand Up @@ -249,9 +250,84 @@ export namespace ProviderTransform {
})
}

// Fix assistant messages where text/reasoning and tool-call parts are interleaved.
// Anthropic requires all text/reasoning parts to come before tool-call parts.
// The AI SDK's convertToModelMessages can produce interleaved content when
// multi-step conversations have text in between tool calls across steps.
// Also ensures every tool-call has a matching tool-result in the next message.
function repairAssistantMessages(msgs: ModelMessage[]): ModelMessage[] {
const log = Log.create({ service: "transform" })
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]

if (msg.role === "assistant" && Array.isArray(msg.content)) {
const parts = msg.content as any[]
const hasToolCalls = parts.some((p) => p.type === "tool-call")
if (hasToolCalls) {
const nonToolParts = parts.filter((p) => p.type !== "tool-call")
const toolParts = parts.filter((p) => p.type === "tool-call")

const wasInterleaved = parts.some((p, idx) => {
if (p.type === "tool-call") return false
return parts.slice(0, idx).some((prev) => prev.type === "tool-call")
})

if (wasInterleaved) {
log.warn("repairAssistantMessages: reordering interleaved text/tool-call parts", {
messageIndex: i,
originalOrder: parts.map((p) => p.type),
})
}

msg.content = [...nonToolParts, ...toolParts]

const callIds = toolParts.map((p) => p.toolCallId as string)
const next = msgs[i + 1]
const existingResults = new Set<string>()
if (next?.role === "tool" && Array.isArray(next.content)) {
for (const p of next.content as any[]) {
if (p.type === "tool-result" && p.toolCallId) existingResults.add(p.toolCallId)
}
}

const missing = callIds.filter((id) => !existingResults.has(id))
if (missing.length > 0) {
log.warn("repairAssistantMessages: patching orphaned tool-calls", {
missing,
messageIndex: i,
})

const patches = missing.map((id) => {
const call = toolParts.find((p) => p.toolCallId === id)
return {
type: "tool-result" as const,
toolCallId: id,
toolName: (call?.toolName as string) ?? "unknown",
output: { type: "text" as const, value: "[Tool execution was interrupted]" },
}
})

if (!next || next.role !== "tool" || !Array.isArray(next.content)) {
result.push(msg)
result.push({ role: "tool", content: patches })
continue
}

next.content = [...next.content, ...patches]
}
}
}

result.push(msg)
}
return result
}

export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
msgs = repairAssistantMessages(msgs)
if (
(model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
Expand Down
142 changes: 138 additions & 4 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,134 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () =
})
})

describe("ProviderTransform.message - repair interleaved assistant messages", () => {
const model = {
id: "anthropic/claude-sonnet-4",
providerID: "anthropic",
api: {
id: "claude-sonnet-4-20250514",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
name: "Claude Sonnet 4",
capabilities: {
temperature: true,
reasoning: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } },
limit: { context: 200000, output: 8192 },
status: "active",
options: {},
headers: {},
} as any

test("reorders interleaved text and tool-call parts in assistant message", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "text", text: "First text" },
{ type: "tool-call", toolCallId: "call_1", toolName: "task", input: {} },
{ type: "tool-call", toolCallId: "call_2", toolName: "task", input: {} },
{ type: "text", text: "Second text" },
{ type: "tool-call", toolCallId: "call_3", toolName: "bash", input: {} },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "call_1", toolName: "task", output: { type: "text", value: "ok" } },
{ type: "tool-result", toolCallId: "call_2", toolName: "task", output: { type: "text", value: "ok" } },
{ type: "tool-result", toolCallId: "call_3", toolName: "bash", output: { type: "text", value: "ok" } },
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {}) as any[]

expect(result).toHaveLength(2)
const types = result[0].content.map((p: any) => p.type)
expect(types).toEqual(["text", "text", "tool-call", "tool-call", "tool-call"])
})

test("injects tool-result when assistant has tool-call but next message is not tool", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "call_1", toolName: "bash", input: { command: "ls" } },
],
},
{
role: "user",
content: "hello",
},
] as any[]

const result = ProviderTransform.message(msgs, model, {}) as any[]

expect(result).toHaveLength(3)
expect(result[1].role).toBe("tool")
expect(result[1].content[0].type).toBe("tool-result")
expect(result[1].content[0].toolCallId).toBe("call_1")
expect(result[1].content[0].output.value).toBe("[Tool execution was interrupted]")
})

test("patches existing tool message when some tool-results are missing", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "call_1", toolName: "bash", input: {} },
{ type: "tool-call", toolCallId: "call_2", toolName: "read", input: {} },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "call_1", toolName: "bash", output: { type: "text", value: "ok" } },
],
},
] as any[]

const result = ProviderTransform.message(msgs, model, {}) as any[]

expect(result).toHaveLength(2)
expect(result[1].role).toBe("tool")
expect(result[1].content).toHaveLength(2)
expect(result[1].content[1].toolCallId).toBe("call_2")
expect(result[1].content[1].output.value).toBe("[Tool execution was interrupted]")
})

test("does not modify messages when all tool-calls have matching tool-results", () => {
const msgs = [
{
role: "assistant",
content: [
{ type: "tool-call", toolCallId: "call_1", toolName: "bash", input: {} },
],
},
{
role: "tool",
content: [
{ type: "tool-result", toolCallId: "call_1", toolName: "bash", output: { type: "text", value: "done" } },
],
},
{ role: "user", content: "thanks" },
] as any[]

const result = ProviderTransform.message(msgs, model, {})

expect(result).toHaveLength(3)
expect(result[1].content).toHaveLength(1)
})
})

describe("ProviderTransform.message - DeepSeek reasoning content", () => {
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
const msgs = [
Expand Down Expand Up @@ -674,9 +802,9 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
release_date: "2023-04-01",
},
{},
)
) as any[]

expect(result).toHaveLength(1)
expect(result).toHaveLength(2)
expect(result[0].content).toEqual([
{
type: "tool-call",
Expand All @@ -686,6 +814,9 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => {
},
])
expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Let me think about this...")
// repairAssistantMessages injects a tool-result for the orphaned tool-call
expect(result[1].role).toBe("tool")
expect(result[1].content[0].toolCallId).toBe("test")
})

test("Non-DeepSeek providers leave reasoning content unchanged", () => {
Expand Down Expand Up @@ -963,16 +1094,19 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
},
] as any[]

const result = ProviderTransform.message(msgs, anthropicModel, {})
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]

expect(result).toHaveLength(1)
// repairAssistantMessages adds a tool message for the orphaned tool-call
expect(result).toHaveLength(2)
expect(result[0].content).toHaveLength(1)
expect(result[0].content[0]).toEqual({
type: "tool-call",
toolCallId: "123",
toolName: "bash",
input: { command: "ls" },
})
expect(result[1].role).toBe("tool")
expect(result[1].content[0].toolCallId).toBe("123")
})

test("keeps messages with valid text alongside empty parts", () => {
Expand Down
Loading