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
39 changes: 31 additions & 8 deletions packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,41 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
)
return result ? [call, result] : [call]
})
const meaningful = content.filter((part) => {
const retained = content.filter((part) => {
if (part.type === "text") return part.text !== ""
if (part.type !== "reasoning") return true
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
})
const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true)
.map((item) =>
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
)
.filter((message) => message !== undefined)
.map(Message.tool)
// A turn that reasoned before calling a tool must keep the two together: Anthropic
// rejects a tool_use that is not preceded by its thinking block. The reasoning part is
// dropped above when it carries neither text nor provider state, which is what a failed
// step looks like once its provider metadata is stripped — and reasoning text is empty
// whenever thinking display is "omitted", the default on current Anthropic models. Drop
// the tool parts as well in that case so the replayed turn stays self-consistent rather
// than replaying orphaned calls.
// Only when replaying to the same model. A model switch lowers reasoning to plain text
// on purpose, so the tool calls stay valid and must be kept.
const droppedReasoning =
sameModel &&
message.content.some((item) => item.type === "reasoning") &&
!retained.some((part) => part.type === "reasoning")
const meaningful = droppedReasoning
? retained.filter((part) => part.type !== "tool-call" && part.type !== "tool-result")
: retained
const results = droppedReasoning
? []
: message.content
.filter(
(item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true,
)
.map((item) =>
toolResult(
item,
reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined,
),
)
.filter((message) => message !== undefined)
.map(Message.tool)
if (meaningful.length === 0) return results
return [
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
Expand Down
76 changes: 76 additions & 0 deletions packages/core/test/session-runner-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,80 @@ Recent work
},
])
})

test("drops tool parts when a failed turn loses its reasoning block", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-thinking-failed"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
// Reasoning text is empty whenever thinking display is "omitted", so once the
// failed turn's provider metadata is stripped there is nothing left to replay
// and the reasoning part is dropped as empty.
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed",
text: "",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "orphan-call",
name: "read",
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
],
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
time: { created, completed: created },
}),
],
model,
)

// The tool call must not survive on its own: Anthropic rejects a tool_use that is not
// preceded by its thinking block.
expect(messages).toEqual([])
})

test("keeps tool parts when reasoning survives a failed turn as text", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-text-reasoning-failed"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-partial",
text: "Partial thought",
providerMetadata: { openai: { itemId: "rs_failed" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "kept-call",
name: "read",
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
],
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
time: { created, completed: created },
}),
],
model,
)

expect(messages[0]?.content).toEqual([
{ type: "reasoning", text: "Partial thought", providerMetadata: undefined },
{ type: "tool-call", id: "kept-call", name: "read", input: { path: "README.md" } },
])
})
})
Loading