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
51 changes: 37 additions & 14 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
) {
const result: UIMessage[] = []
const toolNames = new Set<string>()
// Providers (notably Bedrock) reject tool names that don't match /^[a-zA-Z0-9_-]+$/
// with a non-retryable ValidationException, which kills the session. A malformed
// name can land in the DB because tool-input-start writes the raw stream value
// before the repair callback runs (see processor.ts ensureToolCall + llm.ts
// experimental_repairToolCall). Sanitize on the way out so the bad name never
// reaches the provider and existing poisoned sessions self-heal.
const VALID_TOOL_NAME_RE = /^[a-zA-Z0-9_-]+$/
const isValidToolName = (name: string) => typeof name === "string" && VALID_TOOL_NAME_RE.test(name)
const safeToolName = (name: string) => (isValidToolName(name) ? name : "invalid")
// Track media from tool results that need to be injected as user messages
// for providers that don't support that media type in tool results.
//
Expand Down Expand Up @@ -786,7 +795,8 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
type: "step-start",
})
if (part.type === "tool") {
toolNames.add(part.tool)
const safeName = safeToolName(part.tool)
toolNames.add(safeName)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted
? "[Old tool result content cleared]"
Expand All @@ -811,7 +821,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
: outputText

assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
type: ("tool-" + safeName) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
Expand All @@ -824,7 +834,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
if (typeof output === "string") {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
type: ("tool-" + safeName) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
Expand All @@ -834,7 +844,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
} else {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
type: ("tool-" + safeName) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
Expand All @@ -846,16 +856,29 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
if (part.state.status === "pending" || part.state.status === "running") {
if (isValidToolName(part.tool)) {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
} else {
// Malformed tool name (e.g. landed in DB before the repair callback ran).
// We can't emit a tool_use/tool_result pair: Bedrock rejects the name with
// a ValidationException that kills the session permanently. Emit a text
// hint instead so the model still sees that something was interrupted.
const truncated = String(part.tool ?? "").slice(0, 64).replace(/[^\x20-\x7e]/g, "?")
assistantMessage.parts.push({
type: "text",
text: `[Previous tool call interrupted: unknown tool name "${truncated}"]`,
})
}
}
}
if (part.type === "reasoning") {
if (differentModel) {
Expand Down
69 changes: 69 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,75 @@ describe("session.message-v2.toModelMessage", () => {
])
})

test("sanitizes malformed pending tool names to avoid Bedrock ValidationException", async () => {
// Repro for #28989: a malformed tool name (one that fails Bedrock's
// /^[a-zA-Z0-9_-]+$/) lands in the DB via tool-input-start before the
// repair callback fires. On every subsequent turn the malformed name
// is re-serialized as `tool-${part.tool}`, Bedrock rejects the request
// with a non-retryable ValidationException, and the session dies.
const userID = "m-user"
const assistantID = "m-assistant"
const malformed = "functions.bash<|tool_call|>"

const input: MessageV2.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1"),
type: "text",
text: "run tool",
},
] as MessageV2.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1"),
type: "tool",
callID: "call-bad",
tool: malformed,
state: {
status: "pending",
input: { cmd: "ls" },
raw: "",
},
},
] as MessageV2.Part[],
},
]

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

// The malformed name must NOT appear as a `tool-${name}` part type
// (that's the shape that gets sent to providers as toolUse.name).
const serialized = JSON.stringify(result)
expect(serialized.includes(`tool-${malformed}`)).toBe(false)
// No assistant content block should declare itself as a tool-use referencing
// the malformed name.
const assistantMsg = result.find((m) => m.role === "assistant")
expect(assistantMsg?.content).not.toContainEqual(expect.objectContaining({ toolName: malformed }))

// The assistant turn should contain a text hint instead of a dangling
// tool_use referencing the malformed name.
expect(result).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
},
{
role: "assistant",
content: [
{
type: "text",
text: `[Previous tool call interrupted: unknown tool name "${malformed}"]`,
},
],
},
])
})

test("substitutes space for empty text between signed reasoning blocks", async () => {
// Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)]
const assistantID = "m-assistant"
Expand Down
Loading