From dbd09cf4ee5fddf01ed0944d4b272b4cfd51f59a Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 8 Jun 2026 06:11:04 +0200 Subject: [PATCH] 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. --- packages/agent/test/agent-loop.test.ts | 73 +++++++++++++++++++ .../test/session/yield-queue.test.ts | 19 +++++ 2 files changed, 92 insertions(+) diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 55a9ac7a8..743b1f362 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -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 = { + 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 () => { diff --git a/packages/coding-agent/test/session/yield-queue.test.ts b/packages/coding-agent/test/session/yield-queue.test.ts index 200ffcc79..8264d4ad4 100644 --- a/packages/coding-agent/test/session/yield-queue.test.ts +++ b/packages/coding-agent/test/session/yield-queue.test.ts @@ -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("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([]); + }); });