Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/defer-approval-channel-context.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions packages/eve/src/harness/input-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<linear_context>issue metadata</linear_context>";
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: [
Expand Down
54 changes: 31 additions & 23 deletions packages/eve/src/harness/input-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ModelMessage[], { messages?: ModelMessage[] }>(
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 = "<linear_context>issue metadata</linear_context>";

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
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
Loading