194 lines
8.1 KiB
TypeScript
194 lines
8.1 KiB
TypeScript
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as harness from "../src/index.js";
|
|
|
|
type Listener = (event: unknown) => void;
|
|
class FakePort {
|
|
listeners = new Set<Listener>();
|
|
promptCalls: Array<{ text: string; images: unknown[] }> = [];
|
|
aborted = false;
|
|
disposed = false;
|
|
resolvePrompt!: () => void;
|
|
rejectPrompt!: (error: Error) => void;
|
|
|
|
subscribe(listener: Listener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
prompt(text: string, options: { images: unknown[] }): Promise<void> {
|
|
this.promptCalls.push({ text, images: options.images });
|
|
return new Promise((resolve, reject) => {
|
|
this.resolvePrompt = resolve;
|
|
this.rejectPrompt = reject;
|
|
});
|
|
}
|
|
|
|
abort(): Promise<void> {
|
|
this.aborted = true;
|
|
this.resolvePrompt?.();
|
|
return Promise.resolve();
|
|
}
|
|
|
|
dispose(): void {
|
|
this.disposed = true;
|
|
}
|
|
}
|
|
|
|
let root = "";
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), "agent-studio-sessions-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await rm(root, { recursive: true, force: true });
|
|
});
|
|
|
|
async function configuredModelStore(supportsImages = true): Promise<harness.InMemoryModelConnectionStore> {
|
|
const store = new harness.InMemoryModelConnectionStore({
|
|
validateUrl: (url) => Promise.resolve({ normalizedUrl: url, addresses: ["93.184.216.34"], redirectPolicy: "same-origin" }),
|
|
});
|
|
await store.configure("ses_1", {
|
|
providerId: "training",
|
|
api: "openai-responses",
|
|
apiKey: "secret",
|
|
baseUrl: "https://api.example.com/v1",
|
|
modelId: "training-model",
|
|
supportsImages,
|
|
});
|
|
return store;
|
|
}
|
|
|
|
describe("HarnessSession", () => {
|
|
it("returns a run id before prompt completion and rejects overlap", async () => {
|
|
const port = new FakePort();
|
|
const events = new harness.InMemoryEventStore();
|
|
const Session = Reflect.get(harness, "HarnessSession") as unknown as new (options: Record<string, unknown>) => {
|
|
startRun(message: { text: string; attachmentIds: string[] }): Promise<{ runId: string }>;
|
|
};
|
|
expect(Session, "HarnessSession must be exported").toBeDefined();
|
|
const session = new Session({
|
|
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),
|
|
});
|
|
|
|
const first = await session.startRun({ text: "first", attachmentIds: [] });
|
|
expect(first.runId).toMatch(/^run_/);
|
|
expect(port.promptCalls).toHaveLength(1);
|
|
await expect(session.startRun({ text: "second", attachmentIds: [] })).rejects.toMatchObject({ code: "SESSION_BUSY" });
|
|
port.resolvePrompt();
|
|
await vi.waitFor(() => expect(events.listAfter("ses_1").events.at(-1)?.type).toBe("run.completed"));
|
|
});
|
|
|
|
it("distinguishes user cancellation from timeout", async () => {
|
|
vi.useFakeTimers();
|
|
const port = new FakePort();
|
|
const events = new harness.InMemoryEventStore();
|
|
const Session = Reflect.get(harness, "HarnessSession") as unknown as new (options: Record<string, unknown>) => {
|
|
startRun(message: { text: string; attachmentIds: string[] }): Promise<{ runId: string }>;
|
|
cancelRun(reason: string): Promise<boolean>;
|
|
};
|
|
const session = new Session({
|
|
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), runTimeoutMs: 50,
|
|
});
|
|
|
|
await session.startRun({ text: "cancel me", attachmentIds: [] });
|
|
await session.cancelRun("user");
|
|
expect(events.listAfter("ses_1").events.at(-1)).toMatchObject({ type: "run.cancelled", payload: { reason: "user" } });
|
|
|
|
await session.startRun({ text: "time out", attachmentIds: [] });
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
expect(events.listAfter("ses_1").events.at(-1)).toMatchObject({ type: "run.cancelled", payload: { reason: "timeout" } });
|
|
});
|
|
|
|
it("rejects images for a text-only model before launching the port", async () => {
|
|
const createAgentPort = vi.fn(() => Promise.resolve(new FakePort()));
|
|
const Session = Reflect.get(harness, "HarnessSession") as unknown as new (options: Record<string, unknown>) => {
|
|
startRun(message: { text: string; attachmentIds: string[] }): Promise<{ runId: string }>;
|
|
};
|
|
const session = new Session({
|
|
sessionId: "ses_1", workspaceRoot: root, eventStore: new harness.InMemoryEventStore(),
|
|
modelStore: await configuredModelStore(false),
|
|
attachmentStore: { toPiImages: () => Promise.resolve([{ type: "image", data: "AA==", mimeType: "image/png" }]), removeAll: () => Promise.resolve() },
|
|
approvalBroker: { cancelForRun: () => 0, cancelForSession: () => 0 }, createAgentPort,
|
|
});
|
|
|
|
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", () => {
|
|
it("creates isolated workspaces and disposes a deleted session", async () => {
|
|
await mkdir(root, { recursive: true });
|
|
const Registry = Reflect.get(harness, "InMemorySessionRegistry") as unknown as new (options: Record<string, unknown>) => {
|
|
create(): Promise<{ sessionId: string; workspaceRoot: string }>;
|
|
delete(sessionId: string): Promise<boolean>;
|
|
get(sessionId: string): unknown;
|
|
};
|
|
expect(Registry, "InMemorySessionRegistry must be exported").toBeDefined();
|
|
const registry = new Registry({
|
|
sessionsRoot: root,
|
|
createSession: (options: Record<string, unknown>) => ({ ...options, dispose: () => Promise.resolve() }),
|
|
});
|
|
|
|
const created = await registry.create();
|
|
expect(created.sessionId).toMatch(/^ses_/);
|
|
expect(created.workspaceRoot.startsWith(await realpath(root))).toBe(true);
|
|
await expect(registry.delete(created.sessionId)).resolves.toBe(true);
|
|
await expect(registry.delete(created.sessionId)).resolves.toBe(false);
|
|
expect(registry.get(created.sessionId)).toBeUndefined();
|
|
});
|
|
});
|