diff --git a/.changeset/defer-approval-channel-context.md b/.changeset/defer-approval-channel-context.md new file mode 100644 index 000000000..f3f8f7d69 --- /dev/null +++ b/.changeset/defer-approval-channel-context.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Tool approvals now resolve before channel context is added to the next model request, so approving a tool from channels such as Linear executes the tool instead of leaving a dangling tool call. diff --git a/packages/eve/src/harness/input-requests.test.ts b/packages/eve/src/harness/input-requests.test.ts index 08485bd9a..65fc4586c 100644 --- a/packages/eve/src/harness/input-requests.test.ts +++ b/packages/eve/src/harness/input-requests.test.ts @@ -319,6 +319,65 @@ describe("resolvePendingInput", () => { expect(hasDeferredStepInput(deferred.session)).toBe(false); }); + it("defers channel context until after tool approvals are resolved", () => { + const session = setPendingInputBatch({ + requests: [ + { + action: { + callId: "approval-call", + input: { command: "pwd" }, + kind: "tool-call", + toolName: "bash", + }, + allowFreeform: false, + display: "confirmation", + options: [ + { id: "approve", label: "Yes" }, + { id: "deny", label: "No" }, + ], + prompt: "Approve tool call: bash", + requestId: "approval-1", + } satisfies InputRequest, + ], + responseMessages: [ + { + content: [ + { + input: { command: "pwd" }, + toolCallId: "approval-call", + toolName: "bash", + type: "tool-call", + }, + { + approvalId: "approval-1", + toolCallId: "approval-call", + type: "tool-approval-request", + }, + ], + role: "assistant", + } satisfies ModelMessage, + ], + session: createHarnessSession(), + }); + + const context = "issue metadata"; + const result = resolvePendingInput({ + stepInput: { + context: [context], + inputResponses: [{ requestId: "approval-1", optionId: "approve" }], + }, + session, + }); + + expect(result.outcome).toBe("resolved"); + expect(result.messages.at(-1)?.role).toBe("tool"); + expect(hasDeferredStepInput(result.session)).toBe(true); + + const deferred = consumeDeferredStepInput({ session: result.session }); + expect(deferred.input).toEqual({ context: [context] }); + expect(hasDeferredStepInput(deferred.session)).toBe(false); + }); + it("resolves approval when follow-up text matches an option", () => { const session = setPendingInputBatch({ requests: [ diff --git a/packages/eve/src/harness/input-requests.ts b/packages/eve/src/harness/input-requests.ts index 631b372de..0eeb30d37 100644 --- a/packages/eve/src/harness/input-requests.ts +++ b/packages/eve/src/harness/input-requests.ts @@ -149,15 +149,13 @@ export function resolvePendingInput(input: { // Pending batch exists -- only resolve if we have actual responses. const resolvedStepInput = resolveTextMessageInput(pendingBatch, stepInput); const responses = resolvedStepInput?.inputResponses ?? []; + const resolvesApprovalBatch = pendingBatch.requests.some((request) => isApprovalRequest(request)); if (responses.length === 0 && resolvedStepInput?.message === undefined) { return { outcome: "unresolved", messages: baseHistory, session }; } - if ( - pendingBatch.requests.some((request) => isApprovalRequest(request)) && - hasUnansweredApproval({ pendingBatch, responses }) - ) { + if (resolvesApprovalBatch && hasUnansweredApproval({ pendingBatch, responses })) { session = queueDeferredStepInput(session, compactStepInput(resolvedStepInput)); return { deferredMessage: true, outcome: "unresolved", messages: baseHistory, session }; } @@ -208,26 +206,35 @@ export function resolvePendingInput(input: { const rejectedActions = buildRejectedActionBatch(pendingBatch, responses); session = clearPendingInputBatch(session); - // AI SDK cannot process tool-approval responses and a new user message - // in the same request. Defer the message so the approval is resolved in - // isolation; `consumeDeferredStepInput` replays it on the next step. - if ( - resolvedStepInput?.message !== undefined && - pendingBatch.requests.some((request) => isApprovalRequest(request)) - ) { - session = queueDeferredStepInput(session, { - message: resolvedStepInput.message, - }); + // AI SDK collects approval responses only from the tail tool message. + // Defer channel context and any follow-up message so the approval resolves + // in isolation; `consumeDeferredStepInput` replays them on the next step. + if (resolvesApprovalBatch) { + const deferredInput: { + context?: StepInput["context"]; + message?: StepInput["message"]; + } = {}; + if ((resolvedStepInput?.context?.length ?? 0) > 0) { + deferredInput.context = resolvedStepInput?.context; + } + if (resolvedStepInput?.message !== undefined) { + deferredInput.message = resolvedStepInput.message; + } - return { - consumedMessage: resolvedStepInput?.messageConsumed, - deferredMessage: true, - limitContinuation, - outcome: "resolved", - messages, - rejectedActions, - session, - }; + if (deferredInput.context !== undefined || deferredInput.message !== undefined) { + session = queueDeferredStepInput(session, deferredInput); + + return { + consumedMessage: resolvedStepInput?.messageConsumed, + deferredContext: deferredInput.context === undefined ? undefined : true, + deferredMessage: deferredInput.message === undefined ? undefined : true, + limitContinuation, + outcome: "resolved", + messages, + rejectedActions, + session, + }; + } } return { @@ -307,6 +314,7 @@ function hasUnansweredApproval(input: { type ResolvePendingInputResult = { readonly consumedMessage?: boolean; + readonly deferredContext?: boolean; readonly deferredMessage?: boolean; /** * Present when the resolved batch answered a session-limit continuation diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index de445cc9f..6d786a5a1 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -7,6 +7,7 @@ import { ToolLoopAgent, type UserContent, } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; @@ -6729,6 +6730,99 @@ describe("createToolLoopHarness", () => { ]); }); + it("keeps channel context after the approval-response model call", async () => { + setupMockAgent({ + finishReason: "stop", + response: { messages: [{ content: "Approved.", role: "assistant" }] }, + text: "Approved.", + toolCalls: [], + toolResults: [], + }); + + const readGenerateMessages = (index: number) => { + const agent = vi.mocked(ToolLoopAgent).mock.results[index]?.value; + if (agent === undefined) { + throw new Error(`ToolLoopAgent mock did not return instance ${String(index)}.`); + } + const call = vi.mocked(agent.generate).mock.calls[0]?.[0]; + if (call === undefined) { + throw new Error(`ToolLoopAgent instance ${String(index)} did not generate.`); + } + return call.messages; + }; + + const readPreparedMessages = async (index: number) => { + const settings = vi.mocked(ToolLoopAgent).mock.calls[index]?.[0]; + if (settings === undefined) { + throw new Error(`ToolLoopAgent mock did not receive settings ${String(index)}.`); + } + const messages = readGenerateMessages(index); + const prepareStep = getPrepareStep( + settings.prepareStep, + ); + const prepared = await prepareStep({ + context: undefined, + messages, + model: undefined, + stepNumber: 0, + steps: [], + }); + return prepared.messages ?? []; + }; + + const config = createTestConfig("conversation", undefined, { + resolveModel: vi.fn().mockResolvedValue( + new MockLanguageModelV3({ + modelId: "claude-sonnet-4-5", + provider: "anthropic.messages", + }), + ), + tools: new Map([ + [ + "bash", + { + description: "Run shell commands", + execute: vi.fn().mockResolvedValue("ok"), + inputSchema: jsonSchema({ type: "object" }), + name: "bash", + }, + ], + ]), + }); + const harness = createToolLoopHarness(config); + const context = "issue metadata"; + + const firstResult = await harness(createPendingBashApprovalSession(), { + context: [context], + inputResponses: [{ requestId: "approval-1", optionId: "approve" }], + }); + + const firstMessages = readGenerateMessages(0); + expect(typeof firstResult.next).toBe("function"); + expect(firstMessages.at(-1)?.role).toBe("tool"); + expect(firstMessages).not.toContainEqual({ content: context, role: "user" }); + expect((await readPreparedMessages(0)).at(-1)).toMatchObject({ + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + role: "tool", + }); + + const secondResult = await harness(firstResult.session); + + const secondMessages = readGenerateMessages(1); + expect(secondResult.next).toBeNull(); + expect(secondMessages.slice(0, firstMessages.length)).toEqual(firstMessages); + expect(secondMessages.at(-1)).toEqual({ content: context, role: "user" }); + expect((await readPreparedMessages(1)).at(-1)).toMatchObject({ + content: context, + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + role: "user", + }); + }); + it("deferred message lands as last non-system message after explicit approval denial", async () => { // Step 1: pending approval + user sends a follow-up message. The approval // remains pending and the message is deferred. Step 2: the user denies the diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index 0b4e30606..c8bf67dcf 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -584,7 +584,7 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { } session = continuation.session; - if (stepInput.input?.context !== undefined) { + if (stepInput.input?.context !== undefined && pending.deferredContext !== true) { for (const entry of stepInput.input.context) { messages.push({ content: entry, role: "user" }); }