Repository navigation
Second tool approval in a thread fails when contextHandler returns history beyond recentMessages #411
Description
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
[-]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[/+]robelest commentedon Oct 20, 2026
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.