143 lines
16 KiB
TypeScript
143 lines
16 KiB
TypeScript
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<void> { this.promptOptions = options; return new Promise((resolve) => { this.finish = resolve; }); }
|
|
async abort(): Promise<void> { 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<AgentSessionPort> { 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("<skill name="); expect(prompt.mock.calls[0]![0]).toContain("account=demo date=2026-09-06");
|
|
const events = context.events.listAfter(context.session.sessionId).events; expect(events.find((e) => 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);
|
|
});
|
|
});
|