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" });
}