Skip to content

Second tool approval in a thread fails when contextHandler returns history beyond recentMessages #411

Open

Description

@montana-signal-core-ai

If a contextHandler returns more history than recentMessages fetched, the second tool approval in a thread can't be continued. The first approval works. After the next user message, the tool asks for approval again, and the continuation throws as soon as the agent builds its context. It fails the same way whether the first call was approved or declined.

Repro with convex-test and vitest (environment: "edge-runtime"), using the agent's test component the way this repo's own tests do.

agent.ts:

import {
  Agent,
  createTool,
  docsToModelMessages,
  listMessages,
  saveMessage,
} from "@convex-dev/agent";
import agentComponent from "@convex-dev/agent/test";
import { MockLanguageModelV4 } from "ai/test";
import { defineTestApp } from "convex-test";
import { defineSchema } from "convex/server";
import { v } from "convex/values";
import { z } from "zod/v4";

const app = defineTestApp({
  schema: defineSchema({}),
  components: { agent: agentComponent },
});
const component = app.components.agent;

const usage = {
  inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
  outputTokens: { total: 1, text: 1, reasoning: 0 },
};
const reply = (content: any) => ({
  content: [content],
  finishReason: { unified: "stop" as const, raw: undefined },
  usage,
  warnings: [],
});
const callTool = (n: number) =>
  reply({
    type: "tool-call",
    toolCallId: `call-${n}`,
    toolName: "deleteFile",
    input: JSON.stringify({ name: `file-${n}.txt` }),
  });

// Two user messages, each of which calls a tool that needs approval.
const model = new MockLanguageModelV4({
  doGenerate: [
    callTool(1),
    reply({ type: "text", text: "Deleted file-1.txt" }),
    callTool(2),
    reply({ type: "text", text: "Deleted file-2.txt" }),
  ],
});

const agent = new Agent(component, {
  name: "repro",
  languageModel: model,
  tools: {
    deleteFile: createTool({
      inputSchema: z.object({ name: z.string() }),
      needsApproval: true,
      execute: async (_ctx, { name }) => `deleted ${name}`,
    }),
  },
  contextOptions: { recentMessages: 1 },
  // Build the context from the whole thread, e.g. to summarize old turns.
  contextHandler: async (ctx, { threadId }) => {
    const { page } = await listMessages(ctx, component, {
      threadId: threadId!,
      paginationOpts: { cursor: null, numItems: 100 },
    });
    return docsToModelMessages(page.reverse());
  },
});

const send = app.action({
  args: { threadId: v.string(), text: v.string() },
  handler: async (ctx, { threadId, text }) => {
    const { messageId } = await saveMessage(ctx, component, {
      threadId,
      prompt: text,
    });
    const result = await agent.generateText(
      ctx,
      { threadId },
      { promptMessageId: messageId },
    );
    const request = result.content.find(
      (part) => part.type === "tool-approval-request",
    );
    return request!.approvalId;
  },
});

const approve = app.mutation({
  args: { threadId: v.string(), approvalId: v.string() },
  handler: (ctx, args) => agent.approveToolCall(ctx, args),
});

const resume = app.action({
  args: { threadId: v.string(), messageId: v.string() },
  handler: async (ctx, { threadId, messageId }) => {
    const result = await agent.generateText(
      ctx,
      { threadId },
      { promptMessageId: messageId },
    );
    return result.text;
  },
});

const createThread = app.mutation({
  args: {},
  handler: (ctx) => agent.createThread(ctx, {}).then((t) => t.threadId),
});

export const { api, createTest } = app.defineModules({
  repro: { createThread, send, approve, resume },
});

repro.test.ts:

import { expect, test } from "vitest";
import { api, createTest } from "./agent";

test("second tool approval in a thread", async () => {
  const t = createTest();
  const threadId = await t.mutation(api.repro.createThread, {});

  for (const n of [1, 2]) {
    const approvalId = await t.action(api.repro.send, {
      threadId,
      text: `Delete file-${n}.txt`,
    });
    const { messageId } = await t.mutation(api.repro.approve, {
      threadId,
      approvalId,
    });
    // n = 2 throws "Continuation context cannot add approval requests"
    const text = await t.action(api.repro.resume, { threadId, messageId });
    expect(text).toBe(`Deleted file-${n}.txt`);
  }
});

Error:

Error: Continuation context cannot add approval requests
 ❯ prepareApprovalContext src/vercel/client/prepareApprovalContext.ts:94:15
 ❯ fetchContextWithPrompt src/vercel/client/search.ts:711:25
 ❯ startGeneration src/vercel/client/start.ts:194:19
 ❯ Agent.generateText src/vercel/index.ts:533:55

On 0.7.7 the same steps fail with Continuation context cannot add approval decisions.

Expected: the second continuation runs the approved tool and the model answers, like the first one does. Actual: it throws before calling the model. With recentMessages: 4 or more, or with no contextHandler, the test passes. With recentMessages: 3 it still fails.

I think the cause is that approvalContinuation in src/vercel/client/search.ts builds storedContext only from the messages the agent fetched itself (searchMessages plus recentMessages), which here is the current turn plus one earlier message. The handler also returns the first turn's tool-approval-request and tool-approval-response, which are stored in the thread, but prepareApprovalContext can't find them in storedContext, so it treats them as added. This looks like the case left over after #402/#404.

Versions: @convex-dev/agent 0.7.8 (also 0.7.7), ai 7.0.28, @ai-sdk/provider 4.0.3, convex 1.46.0, convex-helpers 0.1.128, zod 3.25.76, convex-test 0.0.62, vitest 5.0.1, @edge-runtime/vm 5.0.0.

For now, our handler removes tool-approval-request and tool-approval-response parts from messages in earlier turns, which avoids the error.

Activity

  1. changed the title [-]Approval continuation rejects earlier approvals a contextHandler supplies from outside recentMessages (0.7.8, follow-up to #402)[/-] [+]Second tool approval in a thread fails when contextHandler returns history beyond recentMessages[/+] on Oct 15, 2026
  2. robelest commented on Oct 20, 2026

    @robelest
    Collaborator

    Thanks for the precise repro and diagnosis. Fix is up in #416: approval requests and decisions no longer reach context handlers, so stripping them in your handler won't be needed once it ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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