test(agent): added tests for aside timing and stale-yield queue draining

- Added an agent loop test proving aside messages are delivered after tool results, before the next model request, without interrupting tool execution.
- Added a yield-queue test confirming stale entries are excluded, the queue is cleared, and re-draining yields no messages.
This commit is contained in:
can1357
2026-06-08 06:11:04 +02:00
parent 4a2f33c905
commit dbd09cf4ee
2 changed files with 92 additions and 0 deletions
+73
View File
@@ -768,6 +768,79 @@ describe("agentLoop with AgentMessage", () => {
);
expect(sawInterruptInContext).toBe(true);
});
it("injects aside messages at the step boundary without interrupting tools", async () => {
const toolSchema = z.object({ value: z.string() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};
const context: AgentContext = { systemPrompt: [""], messages: [], tools: [tool] };
const mock = createMockModel({
responses: [
{
content: [
{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
{ type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
],
},
{ content: ["done"] },
],
});
const asideMessage = createUserMessage("bg-job-complete");
let asideDelivered = false;
const config: AgentLoopConfig = {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
getAsideMessages: async () => {
if (!asideDelivered && executed.length >= 1) {
asideDelivered = true;
return [asideMessage];
}
return [];
},
};
const events: AgentEvent[] = [];
const stream = agentLoop([createUserMessage("start")], context, config, undefined, mock.stream);
for await (const event of stream) {
events.push(event);
}
// Asides are non-interrupting: BOTH tools in the batch run (steering would skip the 2nd).
expect(executed).toEqual(["first", "second"]);
// The aside lands after the tool results, before the next model call.
const seq = events.flatMap(event => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(seq).toContain("bg-job-complete");
expect(seq.indexOf("tool:tool-2")).toBeLessThan(seq.indexOf("bg-job-complete"));
// The model saw it on the very next request — delivered mid-run, no yield required.
const sawAsideInContext = mock.calls[1]?.context.messages.some(
m => m.role === "user" && typeof m.content === "string" && m.content === "bg-job-complete",
);
expect(sawAsideInContext).toBe(true);
});
});
it("refreshes tools and system prompt between same-turn model calls", async () => {
@@ -151,4 +151,23 @@ describe("YieldQueue", () => {
expect(harness.streamingMessages.map(messageText)).toEqual(["second", "first"]);
});
test("drainMessages builds non-stale entries, clears the queue, returns nothing on re-drain", async () => {
const harness = createHarness(true);
harness.queue.register<Entry>("items", {
isStale: entry => entry.stale === true,
build: entries => userMessage(entries.map(entry => entry.id).join(",")),
});
harness.queue.enqueue("items", { id: "keep" });
harness.queue.enqueue("items", { id: "drop", stale: true });
const drained = harness.queue.drainMessages();
expect(drained.map(messageText)).toEqual(["keep"]);
// Pull-based drain has no injection side effects and empties the queue.
expect(harness.streamingMessages).toHaveLength(0);
expect(harness.idleBatches).toHaveLength(0);
expect(harness.queue.has()).toBe(false);
expect(harness.queue.drainMessages()).toEqual([]);
});
});