Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const approvalRequest = {
type: "tool-approval-request" as const,
};

function createPendingApprovalSession(): HarnessSession {
function createPendingApprovalSession(history?: readonly ModelMessage[]): HarnessSession {
const session: HarnessSession = {
agent: {
modelReference: { id: "generate-approval-resume-model" },
Expand All @@ -48,7 +48,7 @@ function createPendingApprovalSession(): HarnessSession {
},
compaction: { recentWindowSize: 10, threshold: 100_000 },
continuationToken: "http:generate-approval-resume-session",
history: [{ content: "Run pwd.", role: "user" }],
history: [...(history ?? [{ content: "Run pwd.", role: "user" }])],
sessionId: "generate-approval-resume-session",
};

Expand Down Expand Up @@ -82,6 +82,47 @@ function createPendingApprovalSession(): HarnessSession {
});
}

function createModel(): MockLanguageModelV4 {
return new MockLanguageModelV4({
doGenerate: {
content: [{ text: "The command returned /workspace.", type: "text" }],
finishReason: { raw: undefined, unified: "stop" },
usage,
warnings: [],
},
modelId: "generate-approval-resume-model",
provider: "eve-integration-mock",
});
}

function createConfig(
model: MockLanguageModelV4,
execute: (input: unknown, options: unknown) => Promise<string>,
): ToolLoopHarnessConfig {
const tools: ToolLoopHarnessConfig["tools"] = new Map([
[
toolCall.toolName,
{
description: "Run a shell command.",
execute,
inputSchema: jsonSchema({ type: "object" }),
name: toolCall.toolName,
toModelOutput: (output) => {
if (typeof output !== "string") {
throw new TypeError("Expected the bash test tool to return a string.");
}
return { type: "text", value: `canonical:${output}` };
},
},
],
]);
return {
mode: "conversation",
resolveModel: async (): Promise<LanguageModel> => model,
tools,
};
}

function findPart(
messages: readonly ModelMessage[],
type: "tool-approval-response" | "tool-call" | "tool-result",
Expand All @@ -102,42 +143,14 @@ function findPart(
describe("tool loop generate approval resume (real AI SDK)", () => {
it("persists the approved pre-model tool result without an event handler", async () => {
const execute = vi.fn(async () => "/workspace");
const model = new MockLanguageModelV4({
doGenerate: {
content: [{ text: "The command returned /workspace.", type: "text" }],
finishReason: { raw: undefined, unified: "stop" },
usage,
warnings: [],
const model = createModel();

const result = await createToolLoopHarness(createConfig(model, execute))(
createPendingApprovalSession(),
{
inputResponses: [{ optionId: "approve", requestId: approvalRequest.approvalId }],
},
modelId: "generate-approval-resume-model",
provider: "eve-integration-mock",
});
const tools: ToolLoopHarnessConfig["tools"] = new Map([
[
toolCall.toolName,
{
description: "Run a shell command.",
execute,
inputSchema: jsonSchema({ type: "object" }),
name: toolCall.toolName,
toModelOutput: (output) => {
if (typeof output !== "string") {
throw new TypeError("Expected the bash test tool to return a string.");
}
return { type: "text", value: `canonical:${output}` };
},
},
],
]);
const config: ToolLoopHarnessConfig = {
mode: "conversation",
resolveModel: async (): Promise<LanguageModel> => model,
tools,
};

const result = await createToolLoopHarness(config)(createPendingApprovalSession(), {
inputResponses: [{ optionId: "approve", requestId: approvalRequest.approvalId }],
});
);

expect(model.doGenerateCalls).toHaveLength(1);
expect(model.doStreamCalls).toHaveLength(0);
Expand Down Expand Up @@ -175,4 +188,74 @@ describe("tool loop generate approval resume (real AI SDK)", () => {
role: "assistant",
});
});

// Acceptance gate for the HITL non-blocking plan (research/hitl-request-lifecycle.md):
// once messages run as normal turns while an approval is open, the approval batch is
// restored *after* that intervening exchange. This proves the AI SDK accepts the
// late-spliced transcript shape before any behavior change lands.
it("splices the approved batch after an intervening conversation turn", async () => {
const interveningHistory: readonly ModelMessage[] = [
{ content: "Run pwd.", role: "user" },
{ content: "Any update on that command?", role: "user" },
{
content: [{ text: "Still waiting for approval to run pwd.", type: "text" }],
role: "assistant",
},
];
const execute = vi.fn(async () => "/workspace");
const model = createModel();

const result = await createToolLoopHarness(createConfig(model, execute))(
createPendingApprovalSession(interveningHistory),
{
inputResponses: [{ optionId: "approve", requestId: approvalRequest.approvalId }],
},
);

expect(model.doGenerateCalls).toHaveLength(1);
expect(execute).toHaveBeenCalledExactlyOnceWith(
toolCall.input,
expect.objectContaining({ toolCallId: toolCall.toolCallId }),
);

// The provider prompt keeps the intervening turn before the restored batch.
const providerPrompt = model.doGenerateCalls[0]?.prompt ?? [];
const interveningIndex = providerPrompt.findIndex(
(message) =>
message.role === "user" && JSON.stringify(message.content).includes("Any update"),
);
const toolCallIndex = providerPrompt.findIndex(
(message) =>
message.role === "assistant" &&
Array.isArray(message.content) &&
message.content.some((part) => part.type === "tool-call"),
);
expect(interveningIndex).toBeGreaterThanOrEqual(0);
expect(toolCallIndex).toBeGreaterThan(interveningIndex);
expect(findPart(providerPrompt, "tool-result")).toMatchObject({
output: { type: "text", value: "canonical:/workspace" },
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
});

// Committed history: intervening exchange first, then the restored batch, exactly once.
expect(result.session.history.map((message) => message.role)).toEqual([
"user",
"user",
"assistant",
"assistant",
"tool",
"tool",
"assistant",
]);
expect(findPart(result.session.history, "tool-call")).toEqual(toolCall);
expect(findPart(result.session.history, "tool-approval-response")).toMatchObject({
approvalId: approvalRequest.approvalId,
approved: true,
});
expect(result.session.history.at(-1)).toMatchObject({
content: [{ text: "The command returned /workspace.", type: "text" }],
role: "assistant",
});
});
});
Loading