test: cover complete fake agent flow
This commit is contained in:
@@ -19,6 +19,7 @@ export * from "./sessions/in-memory-session-registry.js";
|
||||
export * from "./sessions/session-registry.js";
|
||||
export * from "./skills/skill-catalog.js";
|
||||
export * from "./skills/skill-event-inference.js";
|
||||
export * from "./testing/fake-agent-session.js";
|
||||
export * from "./tools/mock-ads-metrics.js";
|
||||
export * from "./tools/save-report-draft.js";
|
||||
export * from "./tools/tool-policy.js";
|
||||
|
||||
@@ -82,8 +82,17 @@ export class HarnessSession {
|
||||
eventStore: this.#eventStore,
|
||||
...(skillInference === undefined ? {} : { skillInference }),
|
||||
});
|
||||
let eventQueue = Promise.resolve();
|
||||
let eventError: unknown;
|
||||
const unsubscribe = port.subscribe((event) => {
|
||||
void adapter.observe(event);
|
||||
eventQueue = eventQueue.then(async () => {
|
||||
if (eventError !== undefined) return;
|
||||
try {
|
||||
await adapter.observe(event);
|
||||
} catch (error) {
|
||||
eventError = error;
|
||||
}
|
||||
});
|
||||
});
|
||||
const active: ActiveRun = {
|
||||
runId,
|
||||
@@ -108,7 +117,11 @@ export class HarnessSession {
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => port.prompt(message.text, { images }))
|
||||
.then(() => this.#complete(active))
|
||||
.then(async () => {
|
||||
await eventQueue;
|
||||
if (eventError !== undefined) throw eventError;
|
||||
this.#complete(active);
|
||||
})
|
||||
.catch((error: unknown) => this.#fail(active, error));
|
||||
return { runId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
import type { AgentSessionPort } from "../agent/agent-session-port.js";
|
||||
|
||||
type Listener = (event: unknown) => void;
|
||||
type Driver = (
|
||||
session: FakeAgentSession,
|
||||
prompt: { text: string; images: ImageContent[] },
|
||||
) => Promise<void>;
|
||||
|
||||
export class FakeAgentSession implements AgentSessionPort {
|
||||
readonly #listeners = new Set<Listener>();
|
||||
readonly prompts: Array<{ text: string; images: ImageContent[] }> = [];
|
||||
public aborted = false;
|
||||
public disposed = false;
|
||||
|
||||
public constructor(private readonly driver: Driver) {}
|
||||
|
||||
public subscribe(listener: Listener): () => void {
|
||||
this.#listeners.add(listener);
|
||||
return () => this.#listeners.delete(listener);
|
||||
}
|
||||
|
||||
public async prompt(text: string, options: { images: ImageContent[] }): Promise<void> {
|
||||
const prompt = { text, images: options.images.map((image) => ({ ...image })) };
|
||||
this.prompts.push(prompt);
|
||||
await this.driver(this, prompt);
|
||||
}
|
||||
|
||||
public emit(event: unknown): void {
|
||||
for (const listener of [...this.#listeners]) listener(event);
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
public abort(): Promise<void> {
|
||||
this.aborted = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposed = true;
|
||||
this.#listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { FakeAgentSession } from "../../src/testing/fake-agent-session.js";
|
||||
@@ -129,6 +129,44 @@ describe("HarnessSession", () => {
|
||||
await expect(session.startRun({ text: "look", attachmentIds: ["att_1"] })).rejects.toMatchObject({ code: "MODEL_DOES_NOT_SUPPORT_IMAGES" });
|
||||
expect(createAgentPort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes async agent events before completing the run", async () => {
|
||||
const port = new FakePort();
|
||||
const events = new harness.InMemoryEventStore();
|
||||
let finishSkillLookup: (() => void) | undefined;
|
||||
const order: string[] = [];
|
||||
const session = new harness.HarnessSession({
|
||||
sessionId: "ses_1",
|
||||
workspaceRoot: root,
|
||||
eventStore: events,
|
||||
modelStore: await configuredModelStore(),
|
||||
attachmentStore: { toPiImages: () => Promise.resolve([]), removeAll: () => Promise.resolve() },
|
||||
approvalBroker: { cancelForRun: () => 0, cancelForSession: () => 0 },
|
||||
createAgentPort: () => Promise.resolve(port),
|
||||
createSkillInference: () => ({
|
||||
onReadRequested: () => new Promise<void>((resolve) => {
|
||||
finishSkillLookup = () => {
|
||||
order.push("requested");
|
||||
resolve();
|
||||
};
|
||||
}),
|
||||
onReadCompleted: () => { order.push("completed"); },
|
||||
}),
|
||||
});
|
||||
|
||||
await session.startRun({ text: "load skill", attachmentIds: [] });
|
||||
for (const listener of port.listeners) {
|
||||
listener({ type: "tool_execution_start", toolCallId: "call_1", toolName: "read", args: { path: "/skill/SKILL.md" } });
|
||||
listener({ type: "tool_execution_end", toolCallId: "call_1", toolName: "read", result: "ok", isError: false });
|
||||
}
|
||||
port.resolvePrompt();
|
||||
await vi.waitFor(() => expect(finishSkillLookup).toBeDefined());
|
||||
expect(events.listAfter("ses_1").events.at(-1)?.type).not.toBe("run.completed");
|
||||
|
||||
finishSkillLookup?.();
|
||||
await vi.waitFor(() => expect(events.listAfter("ses_1").events.at(-1)?.type).toBe("run.completed"));
|
||||
expect(order).toEqual(["requested", "completed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemorySessionRegistry", () => {
|
||||
|
||||
Reference in New Issue
Block a user