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
15 changes: 10 additions & 5 deletions packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
const assistant = (message: SessionMessage.Assistant, model: Model) => {
const sameModel =
String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
// Reasoning continuation metadata survives failed turns (#38620): replaying a
// tool_use part without its preceding signed/redacted thinking block makes
// Anthropic reject the request with a 400 once thinking is enabled, so the
// reasoning provider state must be reused whenever the turn stays on the same
// model. Tool execution metadata from a failed turn is still not trusted.
const reuseToolMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
Expand All @@ -79,17 +84,17 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
providerMetadata: item.providerMetadata,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
const call = toolCall(item, reuseToolMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
reuseToolMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined,
)
return result ? [call, result] : [call]
})
Expand All @@ -101,7 +106,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
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),
toolResult(item, reuseToolMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined),
)
.filter((message) => message !== undefined)
.map(Message.tool)
Expand Down
133 changes: 131 additions & 2 deletions packages/core/test/session-runner-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ Recent work
])
})

test("drops provider-native continuation metadata from failed assistant turns", () => {
test("preserves reasoning continuation metadata on failed same-model assistant turns", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
Expand Down Expand Up @@ -370,7 +370,11 @@ Recent work
)

expect(messages[0]?.content).toEqual([
{ type: "reasoning", text: "Partial thought", providerMetadata: undefined },
{
type: "reasoning",
text: "Partial thought",
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
},
{
type: "tool-call",
id: "hosted-failed",
Expand Down Expand Up @@ -399,6 +403,131 @@ Recent work
])
})

test("preserves Anthropic thinking signature on failed assistant turns (#38620)", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-failed-anthropic"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed-anthropic",
text: "Signed thought",
providerMetadata: { anthropic: { signature: "sig_failed" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed-anthropic",
name: "web_search",
provider: {
executed: true,
metadata: { anthropic: { signature: "call_sig_failed" } },
},
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
error: { type: "unknown", message: "Provider turn interrupted" },
content: [],
structured: {},
}),
time: { created, completed: created },
}),
],
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
time: { created, completed: created },
}),
],
model,
)

// The replayed tool_use must be preceded by its signed thinking block or
// Anthropic rejects the request with a 400 once thinking is enabled.
expect(messages[0]?.content).toEqual([
{
type: "reasoning",
text: "Signed thought",
providerMetadata: { anthropic: { signature: "sig_failed" } },
},
{
type: "tool-call",
id: "hosted-failed-anthropic",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
providerMetadata: undefined,
},
{
type: "tool-result",
id: "hosted-failed-anthropic",
name: "web_search",
result: {
type: "error",
value: {
error: { type: "unknown", message: "Provider turn interrupted" },
content: [],
structured: {},
},
},
providerExecuted: true,
cache: undefined,
metadata: undefined,
providerMetadata: undefined,
},
])
})

test("preserves redacted thinking data on failed assistant turns (#38620)", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-failed-redacted"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-failed-redacted",
text: "",
providerMetadata: { anthropic: { redactedData: "redacted-blob-failed" } },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed-redacted",
name: "web_search",
provider: {
executed: true,
metadata: {},
},
state: SessionMessage.ToolStateError.make({
status: "error",
input: { query: "Effect" },
error: { type: "unknown", message: "Provider turn interrupted" },
content: [],
structured: {},
}),
time: { created, completed: created },
}),
],
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
time: { created, completed: created },
}),
],
model,
)

expect(messages[0]?.content[0]).toEqual({
type: "reasoning",
text: "",
providerMetadata: { anthropic: { redactedData: "redacted-blob-failed" } },
})
})

test("drops provider-native continuation metadata after a model switch", () => {
const messages = toLLMMessages(
[
Expand Down
Loading