/** * KHAELOR * File: tests/agent/steering.test.ts * Description: Steering tests — mid-turn instructions queue durably and inject only at the safe seams (ADR-11). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { rmSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { AnthropicMessage } from "../../src/anthropic/index.js"; import { assertPairingSafe, makeHarness, textTurn, toolTurn } from "./fixtures.js"; import type { AgentHarness } from "./fixtures.js"; let harness: AgentHarness | null = null; afterEach(async () => { if (harness !== null) { await harness.log.close(); rmSync(harness.dir, { recursive: true, force: true }); harness = null; } }); function messageText(message: AnthropicMessage | undefined): string { return (message?.content ?? []) .map((block) => (block.type === "text" ? block.text : "")) .join("\n"); } describe("queued steering", () => { it("queues mid-stream text and injects it at the post-tool-batch seam, never mid-stream", async () => { const STEER = "Also check the README while you are at it."; harness = await makeHarness({ turns: [ { ...toolTurn("req_1", "Reading the file.", [ { toolUseId: "toolu_1", toolName: "read", input: { file_path: "a.txt" } }, ]), // The user types while the model is streaming — queued, not injected. onStart: () => harness?.steering.queue(STEER), }, textTurn("req_2", "Done, including the README."), ], }); writeFileSync(path.join(harness.dir, "a.txt"), "alpha\n", "utf8"); harness.user("Read a.txt"); const outcome = await harness.kernel.runTurn(); expect(outcome.kind).toBe("done"); const events = harness.events(); const queued = events.find((event) => event.type === "user.steering-queued"); const injected = events.find((event) => event.type === "user.steering-injected"); if (queued?.type !== "user.steering-queued") throw new Error("expected steering-queued"); if (injected?.type !== "user.steering-injected") throw new Error("expected steering-injected"); expect(injected.payload.queuedEventId).toBe(queued.id); expect(injected.payload.seam).toBe("post-tool-batch"); // Injection lands AFTER the tool batch: afterSeq is the tool result's seq. const toolResult = events.find((event) => event.type === "tool.completed"); if (toolResult?.type !== "tool.completed") throw new Error("expected tool.completed"); expect(injected.payload.afterSeq).toBe(toolResult.seq); expect(injected.seq).toBeGreaterThan(toolResult.seq); // First request was already in flight — it must NOT contain the steering text. const [first, second] = harness.client.requests; expect(JSON.stringify(first?.messages)).not.toContain(STEER); // Second request: steering rides inside the tool-result user message — // never a new bare user message breaking role alternation. const lastMessage = second?.messages[second.messages.length - 1]; expect(lastMessage?.role).toBe("user"); expect(lastMessage?.content.some((block) => block.type === "tool_result")).toBe(true); expect(messageText(lastMessage)).toContain(STEER); assertPairingSafe(events); }); it("injects steering queued before the model call at the pre-model-call seam", async () => { const STEER = "Prefer a one-line answer."; harness = await makeHarness({ turns: [textTurn("req_1", "42.")] }); harness.user("What is the answer?"); harness.steering.queue(STEER); const outcome = await harness.kernel.runTurn(); expect(outcome.kind).toBe("done"); const injected = harness.events().find((event) => event.type === "user.steering-injected"); if (injected?.type !== "user.steering-injected") throw new Error("expected steering-injected"); expect(injected.payload.seam).toBe("pre-model-call"); // The queued text joined the pending user message of the FIRST request. const first = harness.client.requests[0]; const firstMessage = first?.messages[0]; expect(firstMessage?.role).toBe("user"); expect(messageText(firstMessage)).toContain("What is the answer?"); expect(messageText(firstMessage)).toContain(STEER); }); it("drains multiple queued instructions in order at one seam", async () => { harness = await makeHarness({ turns: [textTurn("req_1", "ok")] }); harness.user("Task"); harness.steering.queue("First note."); harness.steering.queue("Second note."); expect(harness.steering.pending()).toHaveLength(2); await harness.kernel.runTurn(); expect(harness.steering.pending()).toHaveLength(0); const text = messageText(harness.client.requests[0]?.messages[0]); expect(text.indexOf("First note.")).toBeGreaterThan(-1); expect(text.indexOf("First note.")).toBeLessThan(text.indexOf("Second note.")); }); });