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(); 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 { this.promptCalls.push({ text, images: options.images }); return new Promise((resolve, reject) => { this.resolvePrompt = resolve; this.rejectPrompt = reject; }); } abort(): Promise { 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 { 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) => { 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) => { startRun(message: { text: string; attachmentIds: string[] }): Promise<{ runId: string }>; cancelRun(reason: string): Promise; }; 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) => { 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(); }); }); 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) => { create(): Promise<{ sessionId: string; workspaceRoot: string }>; delete(sessionId: string): Promise; get(sessionId: string): unknown; }; expect(Registry, "InMemorySessionRegistry must be exported").toBeDefined(); const registry = new Registry({ sessionsRoot: root, createSession: (options: Record) => ({ ...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(); }); });