Files
sw-ads-agent/packages/harness/tests/session.test.ts
T
2026-09-07 09:57:33 +08:00

123 lines
14 KiB
TypeScript

import { mkdtemp, mkdir, readFile, stat } 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, 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();
});
});
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);
});
});