import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import sharp from "sharp"; import { FileAttachmentStore, InMemoryApprovalBroker, InMemoryEventStore, InMemoryModelConnectionStore, InMemorySessionRegistry, PiAgentSessionFactory, SafeWorkspace, SkillCatalog, type AgentSessionFactory, type AgentSessionFactoryInput, type AgentSessionPort, } from "../src/index.js"; class FakePort implements AgentSessionPort { readonly modelId = "fake-model"; constructor(public readonly supportsImages = true) {} listeners = new Set<(event: unknown) => void>(); promptOptions: { images?: unknown[] } | undefined; disposed = false; aborted = false; private finish: (() => void) | undefined; prompt(_text: string, options?: { images?: unknown[] }): Promise { this.promptOptions = options; return new Promise((resolve) => { this.finish = resolve; }); } async abort(): Promise { this.aborted = true; this.finish?.(); } complete(): void { this.finish?.(); } subscribe(listener: (event: unknown) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } dispose(): void { this.disposed = true; } emit(event: unknown): void { for (const listener of this.listeners) listener(event); } } class FakeFactory implements AgentSessionFactory { port: FakePort; input?: AgentSessionFactoryInput; constructor(supportsImages = true) { this.port = new FakePort(supportsImages); } async create(input: AgentSessionFactoryInput): Promise { this.input = input; return this.port; } } async function setup(supportsImages = true) { const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages); const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000); const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages }); return { root, sessionsRoot, events, attachments, models, catalog, factory, registry, session }; } describe("session lifecycle", () => { it("creates a real Pi AgentSession for the exact configured model without model network access", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-real-pi-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const cwd = path.join(root, "session"); await mkdir(cwd); const catalog = new SkillCatalog(root, path.join(root, "catalog")); await catalog.reload(); const models = new InMemoryModelConnectionStore({ ALLOW_PRIVATE_LLM_BASE_URLS: "false" }); await models.configure("ses_real", { providerId: "training", api: "openai-responses", baseUrl: "http://127.0.0.1:9/v1", apiKey: "fake-no-network", modelId: "training-model", supportsImages: true }); const workspace = await SafeWorkspace.create(cwd); const factory = new PiAgentSessionFactory(path.join(root, "unused-fixture.json"), catalog, models, new InMemoryApprovalBroker()); const port = await factory.create({ sessionId: "ses_real", cwd, workspace, getRun: () => undefined, skillRequested: vi.fn(), skillLoaded: vi.fn(), toolRequested: vi.fn() }); expect(port.modelId).toBe("training-model"); expect(port.supportsImages).toBe(true); port.dispose(); await models.remove("ses_real"); }); it("rejects unconfigured models instead of falling back", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload(); const models = new InMemoryModelConnectionStore({}); const registry = new InMemorySessionRegistry(sessionsRoot, new InMemoryEventStore(), new FileAttachmentStore(sessionsRoot), models, catalog, new FakeFactory()); const session = await registry.create(); await expect(registry.send(session.sessionId, { text: "hello", attachmentIds: [] })).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" }); await expect(models.createRuntime(session.sessionId)).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" }); }); it("accepts a run without waiting for prompt completion and rejects concurrency", async () => { const context = await setup(); const result = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); expect(result.runId).toMatch(/^run_/); expect(context.session.busy).toBe(true); await expect(context.registry.send(context.session.sessionId, { text: "again", attachmentIds: [] })).rejects.toMatchObject({ code: "SESSION_BUSY" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); }); it("cancels an active run and disposes on deletion", async () => { const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); await context.registry.cancel(context.session.sessionId, "user"); expect(context.factory.port.aborted).toBe(true); await vi.waitFor(() => expect(context.session.busy).toBe(false)); await context.registry.delete(context.session.sessionId); expect(context.factory.port.disposed).toBe(true); await expect(stat(context.session.cwd)).rejects.toThrow(); }); it("maps fake Pi deltas and tool events centrally", async () => { const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "流式" } }); context.factory.input?.toolRequested("tool_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_1", toolName: "mock_ads_metrics", args: {} }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "tool_1", toolName: "mock_ads_metrics", result: { ok: true }, isError: false }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const runEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId); expect(runEvents.map((event) => event.type)).toContain("assistant.delta"); expect(runEvents.filter((event) => event.type === "tool.requested")).toHaveLength(1); expect(runEvents.map((event) => event.type)).toContain("tool.completed"); }); it("uses Extension preflight as the sole tool request source when Pi start arrives first", async () => { const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_race", toolName: "read", args: { path: "/absolute/private/path" } }); context.factory.input?.toolRequested("tool_race", "read", { path: ".agents/skills/ads-analysis/SKILL.md" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const requests = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId && event.type === "tool.requested"); expect(requests).toHaveLength(1); expect(requests[0]?.payload.arguments).toEqual({ path: ".agents/skills/ads-analysis/SKILL.md" }); }); it("derives skill requested/loaded callbacks with the same toolCallId", async () => { const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete(); }); it("preserves Slash user text and emits command activation without a fake toolCallId", async () => { const context = await setup(); const dir = path.join(context.root, ".agents/skills/swads-daily-report"); await mkdir(dir); await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: report\ndisable-model-invocation: true\n---\nINVOKED BODY"); await context.catalog.reload(); const prompt = vi.spyOn(context.factory.port, "prompt"); const original = "/swads-daily-report account=demo date=2026-09-06"; await context.registry.send(context.session.sessionId, { text: original, attachmentIds: [] }); await vi.waitFor(() => expect(prompt).toHaveBeenCalled()); expect(prompt.mock.calls[0]![0]).toContain(" e.type === "user.message")?.payload).toMatchObject({ text: original }); const activations = events.filter((e) => e.type === "skill.loaded" || e.type === "skill.requested"); expect(activations).toHaveLength(2); for (const e of activations) { expect(e.payload.source).toBe("command"); expect(e.payload.toolCallId).toBeUndefined(); } context.factory.port.complete(); }); it("emits only report metadata on completion and preserves classified tool failures", async () => { const context = await setup(false); await context.registry.send(context.session.sessionId, { text: "report", attachmentIds: [] }); const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`; const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { png: `${url}/png`, html: `${url}/html`, json: `${url}/json` } }; context.factory.port.emit({ type: "tool_execution_end", toolCallId: "render", toolName: "render_swads_daily_report", isError: false, result: { content: [{ type: "text", text: "/private/SHOULD_NOT_LEAK full report body" }], details: artifact } }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "whoami", toolName: "swads_cli", isError: true, result: { content: [{ type: "text", text: "SWADS_AUTH_FAILED" }] } }); const events = context.events.listAfter(context.session.sessionId).events; const generated = events.find((e) => e.type === "report.generated"); expect(generated?.payload).toMatchObject({ assistantMessageId: context.session.currentRun!.assistantMessageId, artifact }); expect(JSON.stringify(events)).not.toContain("SHOULD_NOT_LEAK"); expect(events.find((e) => e.type === "tool.failed")?.payload).toMatchObject({ error: "SWADS_AUTH_FAILED" }); context.factory.port.complete(); }); }); describe("multimodal session path", () => { it("maps a normalized image to the installed Pi ImageContent shape", async () => { const context = await setup(true); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] }); await vi.waitFor(() => expect(context.factory.port.promptOptions).toBeDefined()); const imagePart = context.factory.port.promptOptions?.images?.[0] as { type: string; mimeType: string; data: string }; expect(imagePart).toMatchObject({ type: "image", mimeType: "image/png" }); expect(imagePart.data).toBe(Buffer.from(imagePart.data, "base64").toString("base64")); const serializedEvents = JSON.stringify(context.events.listAfter(context.session.sessionId).events); expect(serializedEvents).not.toContain(imagePart.data); context.factory.port.complete(); }); it("returns 422 semantics for a text-only model", async () => { const context = await setup(false); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await expect(context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] })).rejects.toMatchObject({ code: "MODEL_DOES_NOT_SUPPORT_IMAGES", statusCode: 422 }); }); it("rejects too many images before creating a run", async () => { const context = await setup(); const ids = Array.from({ length: 5 }, (_, index) => `att_${index}`); await expect(context.registry.send(context.session.sessionId, { text: "x", attachmentIds: ids })).rejects.toMatchObject({ code: "TOO_MANY_ATTACHMENTS" }); }); it("cleans attachments when deleting the session", async () => { const context = await setup(); const data = await sharp({ create: { width: 2, height: 2, channels: 3, background: "blue" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.delete(context.session.sessionId); expect(context.attachments.get(context.session.sessionId, item.attachmentId)).toBeUndefined(); }); }); describe("fake Pi integration flow", () => { it("runs message → tool → approval → report → completion without a real model", async () => { const context = await setup(true); const { runId } = await context.registry.send(context.session.sessionId, { text: "生成报告", attachmentIds: [] }); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "正在分析" } }); context.factory.input?.toolRequested("metrics_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { spend: 1 }, isError: false }); context.factory.input?.toolRequested("save_1", "save_report_draft", { content: "报告正文" }); const signal = context.session.currentRun?.controller.signal; expect(signal).toBeDefined(); const pending = context.registry.approvals.request({ sessionId: context.session.sessionId, runId, toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium" }, signal!); const required = context.events.listAfter(context.session.sessionId).events.findLast((event) => event.type === "approval.required"); expect(required?.type).toBe("approval.required"); if (!required || required.type !== "approval.required") throw new Error("approval event missing"); context.registry.approvals.approve(required.payload.approvalId); expect((await pending).status).toBe("approved"); await context.session.workspace.writeReport("报告正文"); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "save_1", toolName: "save_report_draft", result: { path: "report.md" }, isError: false }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); expect(await readFile(path.join(context.session.cwd, "report.md"), "utf8")).toBe("报告正文"); const types = context.events.listAfter(context.session.sessionId).events.map((event) => event.type); expect(types.indexOf("approval.required")).toBeLessThan(types.indexOf("approval.resolved")); expect(types.at(-1)).toBe("run.completed"); await context.registry.delete(context.session.sessionId); }); });