Skip to content

@cloudflare/ai-chat: Tool approval denial does not produce a tool_result #955

@msutkowski

Description

@msutkowski

Note: claude wrote up the information below, but it's accurate. You can see how we're working around it below, but the TLDR is:

We have a tool where we want to show a different indicator based on the usage being accepted or rejected. While you can reject a tool use today, it doesn't accurately keep the state for the last message. If this doesn't make sense or there is a better way to handle this, let me know!

Summary

When a user denies a tool with needsApproval: true, the @cloudflare/ai-chat library sets the tool part state to "approval-responded" instead of "output-denied". The AI SDK's convertToModelMessages does not generate a tool_result for "approval-responded", causing the Anthropic API to reject subsequent requests with:

tool_use ids were found without tool_result blocks immediately after: <toolCallId>. Each tool_use block must have a corresponding tool_result block in the next message.

Root Cause

There is a mismatch between @cloudflare/ai-chat and the Vercel AI SDK (ai) in how tool approval denials are represented.

What @cloudflare/ai-chat does

_applyToolApproval (index.ts L1805-1825) always sets the state to "approval-responded", regardless of whether the tool was approved or denied:

private async _applyToolApproval(toolCallId: string, approved: boolean): Promise<boolean> {
  return this._findAndUpdateToolPart(
    toolCallId,
    "_applyToolApproval",
    ["input-available", "approval-requested"],
    (part) => ({
      ...part,
      state: "approval-responded", // Always "approval-responded", even for denials
      approval: {
        ...(part.approval as Record<string, unknown> | undefined),
        approved,
      },
    })
  );
}

What the AI SDK expects

convertToModelMessages (convert-to-model-messages.ts) only generates a tool_result for these states:

State Generates tool_result?
"output-available" Yes (with tool output)
"output-error" Yes (with error text)
"output-denied" Yes (with "Tool execution denied.")
"approval-responded" No

The "approval-responded" state generates a tool-approval-response message part, but the switch statement that generates tool_result entries does not have a case for "approval-responded":

switch (toolPart.state) {
  case 'output-denied':    // ← generates tool_result with error-text
  case 'output-error':     // ← generates tool_result with error-text
  case 'output-available': // ← generates tool_result with output
  // "approval-responded" is NOT handled — no tool_result generated
}

The gap

For denied approvals, _applyToolApproval should set the state to "output-denied" (not "approval-responded"), so that convertToModelMessages generates the required tool_result for the Anthropic API.

Additionally: _applyToolResult does not accept approval states

A related issue: _applyToolResult only accepts "input-available" state:

private async _applyToolResult(toolCallId, _toolName, output) {
  return this._findAndUpdateToolPart(
    toolCallId,
    "_applyToolResult",
    ["input-available"], // Does not accept "approval-requested" or "approval-responded"
    ...
  );
}

This means you cannot use addToolOutput to provide a tool result for a needsApproval tool — the state is "approval-requested" when the user acts, so _applyToolResult rejects it. This makes it impossible to follow the Vercel-recommended pattern for client-side denial:

// Vercel's recommended approach for client-side denial — does not work
// with @cloudflare/ai-chat because _applyToolResult rejects the state
addToolOutput({
  tool: 'navigateUser',
  toolCallId: invocation.toolCallId,
  state: 'output-error',
  errorText: 'Tool execution denied by user',
})

Vercel AI SDK team guidance

From a Vercel support thread (Amy Egan, Vercel Staff):

The 'output-denied' state you noticed in UIToolInvocation is an internal state used by the framework, not something you can set directly. For client-side tools, you can use the output-error state:

addToolOutput({
  tool: 'yourToolName',
  toolCallId: invocation.toolCallId,
  state: 'output-error',
  errorText: 'Tool execution denied by user'
})

That tells the LLM that the tool execution failed, and it will handle it appropriately. This is semantically correct since from the tool's perspective, a user denial is effectively an execution failure.

Suggested fix

Option A: Fix _applyToolApproval for denials

When approved is false, set the state to "output-denied" instead of "approval-responded":

private async _applyToolApproval(toolCallId: string, approved: boolean): Promise<boolean> {
  return this._findAndUpdateToolPart(
    toolCallId,
    "_applyToolApproval",
    ["input-available", "approval-requested"],
    (part) => ({
      ...part,
      state: approved ? "approval-responded" : "output-denied",
      approval: {
        ...(part.approval as Record<string, unknown> | undefined),
        approved,
      },
    })
  );
}

Option B: Expand _applyToolResult to accept approval states

Allow _applyToolResult to accept "approval-requested" so that addToolOutput works for needsApproval tools:

private async _applyToolResult(toolCallId, _toolName, output) {
  return this._findAndUpdateToolPart(
    toolCallId,
    "_applyToolResult",
    ["input-available", "approval-requested", "approval-responded"],
    ...
  );
}

Option C: Pass state and errorText through the addToolOutput wrapper

The addToolOutput wrapper in react.tsx currently only forwards output. It should also forward state and errorText to the AI SDK's addToolOutput, and include them in the CF_AGENT_TOOL_RESULT message to the server.

Current workaround

We normalize denied approvals in the server's onChatMessage before calling convertToModelMessages:

private normalizeApprovalDenials(messages: typeof this.messages): typeof this.messages {
  return messages.map((msg) => {
    if (msg.role !== "assistant") return msg;
    let changed = false;
    const parts = msg.parts.map((part: any) => {
      if (part.state === "approval-responded" && part.approval?.approved === false) {
        changed = true;
        return { ...part, state: "output-denied" };
      }
      return part;
    });
    return changed ? { ...msg, parts } : msg;
  });
}

Affected versions

  • @cloudflare/ai-chat@0.1.1
  • ai@6.0.86

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions