Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
3e9c4d1
test: reproduce missing step boundary causing tool_use/tool_result mi…
altendky Mar 9, 2026
0ae5109
fix(session): inject synthetic step-start when tool/text parts are in…
altendky Mar 9, 2026
1c25929
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 9, 2026
d9a49c2
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 11, 2026
2644edd
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 16, 2026
4304196
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 17, 2026
da4bb11
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 18, 2026
22027ef
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 20, 2026
30fa606
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 20, 2026
eba5393
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 21, 2026
612eb8a
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 21, 2026
e295e87
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 23, 2026
e21e02c
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 29, 2026
a28fe95
fix: await async toModelMessages in interleaving test
altendky Mar 29, 2026
2e8d199
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 30, 2026
7e169eb
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Mar 31, 2026
b4a3f54
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 1, 2026
bb43495
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 3, 2026
87a6e58
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 6, 2026
dfc09a2
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 7, 2026
41c34b1
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 8, 2026
cd94e0d
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 8, 2026
67eca65
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 9, 2026
492475d
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 12, 2026
a03a215
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 13, 2026
7c07fc6
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 16, 2026
339f060
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 17, 2026
e162133
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 17, 2026
63bb13d
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 18, 2026
5cfa786
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 19, 2026
97f3c74
feat: update codex plugin to support 5.5 (#23789)
rekram1-node Apr 22, 2026
69e2f3b
chore: bump Bun to 1.3.13 (#23791)
Hona Apr 22, 2026
a45d9a9
fix(app): improve icon override handling in project edit dialog (#23768)
Brendonovich Apr 22, 2026
ed3d364
chore: update nix node_modules hashes
opencode-agent[bot] Apr 22, 2026
bb69648
fix: preserve BOM in text tool round-trips (#23797)
Hona Apr 22, 2026
bfb954e
chore: generate
opencode-agent[bot] Apr 22, 2026
0595c28
test: fix cross-spawn stderr race on Windows CI (#23808)
Hona Apr 22, 2026
6aa475f
chore: generate
opencode-agent[bot] Apr 22, 2026
88c5f6b
fix: consolidate project avatar source logic (#23819)
Brendonovich Apr 22, 2026
2a480a9
fix(tui): fail fast on invalid session startup (#23837)
nexxeln Apr 22, 2026
266e965
chore: generate
opencode-agent[bot] Apr 22, 2026
ddda777
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 22, 2026
9af7a88
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 23, 2026
bd2e656
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 24, 2026
23b07b3
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 27, 2026
7b99adf
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 29, 2026
c1077aa
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 29, 2026
d45eb11
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky Apr 30, 2026
544a545
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky May 1, 2026
265713a
Merge branch 'dev' into test/missing-step-boundary-interleaving
altendky May 3, 2026
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
18 changes: 17 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,17 +854,32 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
role: "assistant",
parts: [],
}
// Track whether we've seen a tool part in the current step.
// If text/reasoning appears after a tool part without an intervening
// step-start, it means a step boundary was lost (e.g. finish-step
// handler threw during a retryable error). Inject a synthetic
// step-start to force the AI SDK to split content into separate blocks,
// preventing invalid interleaved tool_use/text in one assistant message.
let sawTool = false
for (const part of msg.parts) {
if (part.type === "text" || part.type === "reasoning") {
if (sawTool) {
assistantMessage.parts.push({ type: "step-start" })
sawTool = false
}
}
if (part.type === "text")
assistantMessage.parts.push({
type: "text",
text: part.text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
if (part.type === "step-start")
if (part.type === "step-start") {
sawTool = false
assistantMessage.parts.push({
type: "step-start",
})
}
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
Expand Down Expand Up @@ -936,6 +951,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
sawTool = true
}
if (part.type === "reasoning") {
if (differentModel) {
Expand Down
98 changes: 98 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,104 @@ describe("session.message-v2.toModelMessage", () => {
},
])
})

test("does not produce interleaved tool-call and text/reasoning in a single assistant block when step boundaries are missing", async () => {
// When the finish-step handler in processor.ts throws during a retryable error,
// step-finish for step 1 and step-start for step 2 are never saved. Both steps'
// content merges into one DB message without boundaries. On replay,
// convertToModelMessages() produces a single assistant block with interleaved
// tool_use/reasoning/text, which the Anthropic API rejects with:
// "tool_use ids were found without tool_result blocks immediately after"
// or "Expected thinking or redacted_thinking, but found tool_use"
//
// Real-world DB evidence from session ses_32fb35486ffeeJAHmplKU1gB2t:
// step-start → text → tool(write, error, input={}) → [96s gap] → text → tool(write, completed) → step-finish
// The error tool had "Tool execution aborted" and empty input — no step boundary between the two steps.
const userID = "m-user"
const assistantID = "m-assistant"

const input: MessageV2.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1"),
type: "text",
text: "write the file",
},
] as MessageV2.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "s1"),
type: "step-start",
},
{
...basePart(assistantID, "t1"),
type: "text",
text: "Now let me write the implementation.",
},
{
// Step 1's tool — errored with empty input (aborted before tool-call event)
...basePart(assistantID, "tool1"),
type: "tool",
callID: "call-1",
tool: "write",
state: {
status: "error",
input: {},
error: "Tool execution aborted",
time: { start: 0, end: 1 },
},
},
// NO step-finish or step-start here — the boundary was lost
{
...basePart(assistantID, "t2"),
type: "text",
text: "Now let me write the implementation.",
},
{
// Step 2's tool — completed successfully on retry
...basePart(assistantID, "tool2"),
type: "tool",
callID: "call-2",
tool: "write",
state: {
status: "completed",
input: { filePath: "/tmp/test.ts", content: "export const x = 1" },
output: "ok",
title: "Write",
metadata: {},
time: { start: 2, end: 3 },
},
},
// step-finish only exists for the final step
] as MessageV2.Part[],
},
]

const result = await MessageV2.toModelMessages(input, model)

// Structural invariant: in every assistant ModelMessage, no text or reasoning
// part should appear AFTER a tool-call part. If it does, the Anthropic API
// will reject the message because it expects tool_result immediately after tool_use.
for (const msg of result) {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
let sawToolCall = false
for (const part of msg.content as any[]) {
if (part.type === "tool-call") sawToolCall = true
if (sawToolCall && (part.type === "text" || part.type === "reasoning")) {
throw new Error(
`Invalid interleaving: found "${part.type}" part after "tool-call" in the same assistant message. ` +
`This violates the Anthropic API requirement that tool_result must immediately follow tool_use. ` +
`Content types in this message: [${(msg.content as any[]).map((p: any) => p.type).join(", ")}]`,
)
}
}
}
})
})

describe("session.message-v2.fromError", () => {
Expand Down
Loading