feat: initial commit

This commit is contained in:
Jeffrey Wu
2026-09-07 09:57:33 +08:00
commit f39f6ac881
66 changed files with 9859 additions and 0 deletions
@@ -0,0 +1,17 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import sharp from "sharp";
import { FileAttachmentStore } from "../src/index.js";
async function image(format: "jpeg" | "png" | "webp") { const pipeline = sharp({ create: { width: 4, height: 3, channels: 3, background: "#25f4ee" } }); return format === "jpeg" ? pipeline.jpeg().toBuffer() : format === "png" ? pipeline.png().toBuffer() : pipeline.webp().toBuffer(); }
function upload(filename: string, mimetype: string, data: Buffer) { return { filename, mimetype, stream: (async function* () { yield data; })() }; }
describe("FileAttachmentStore", () => {
it.each([["jpeg", "photo.jpg", "image/jpeg"], ["png", "photo.png", "image/png"], ["webp", "photo.webp", "image/webp"]] as const)("normalizes valid %s", async (format, name, mime) => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("s", upload(name, mime, await image(format))); expect(item).toMatchObject({ sessionId: "s", mediaType: mime, width: 4, height: 3, status: "ready" }); });
it("rejects spoofed MIME and SVG", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); await expect(store.add("s", upload("fake.jpg", "image/jpeg", Buffer.from("not jpeg")))).rejects.toMatchObject({ code: "ATTACHMENT_MAGIC_MISMATCH" }); await expect(store.add("s", upload("x.svg", "image/svg+xml", Buffer.from("<svg/>")))).rejects.toMatchObject({ code: "ATTACHMENT_TYPE_UNSUPPORTED" }); });
it("rejects an oversized stream before decoding", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root, 10); await expect(store.add("s", upload("x.jpg", "image/jpeg", Buffer.alloc(11)))).rejects.toMatchObject({ code: "ATTACHMENT_TOO_LARGE" }); });
it("isolates attachments across sessions", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); expect(store.get("two", item.attachmentId)).toBeUndefined(); });
it("removes all session attachment state", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); await store.removeAll("one"); expect(store.get("one", item.attachmentId)).toBeUndefined(); });
});
+121
View File
@@ -0,0 +1,121 @@
import { mkdtemp, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
InMemoryApprovalBroker,
InMemoryEventStore,
SafeWorkspace,
SkillCatalog,
createSaveReportDraftTool,
getMockAdsMetrics,
mockAdsMetricsParameters,
redactValue,
validateBaseUrl,
} from "../src/index.js";
import { Value } from "typebox/value";
const projectRoot = path.resolve(import.meta.dirname, "../../..");
describe("InMemoryEventStore", () => {
it("allocates monotonic per-session sequences atomically", () => {
const store = new InMemoryEventStore();
const first = store.append("s", { type: "run.completed", runId: "r", payload: {} });
const second = store.append("s", { type: "run.completed", runId: "r", payload: {} });
expect([first.sequence, second.sequence]).toEqual([1, 2]);
expect(store.append("other", { type: "run.completed", runId: "r", payload: {} }).sequence).toBe(1);
});
it("replays by sequence and event id", () => {
const store = new InMemoryEventStore();
const one = store.append("s", { type: "run.completed", payload: {} });
store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).events).toHaveLength(1);
expect(store.listAfter("s", { eventId: one.eventId }).events).toHaveLength(1);
});
it("reports a retained-history gap", () => {
const store = new InMemoryEventStore(2);
for (let index = 0; index < 4; index++) store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).gap?.oldestAvailableSequence).toBe(3);
});
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
});
describe("ApprovalBroker", () => {
const request = { sessionId: "s", runId: "r", toolCallId: "t", toolName: "save_report_draft", arguments: { content: "x" }, riskLevel: "medium" as const };
it("approves and is idempotent for the same decision", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; });
const pending = broker.request(request, new AbortController().signal); const first = broker.approve(id); const second = broker.approve(id);
expect((await pending).status).toBe("approved"); expect(second.decision).toEqual(first.decision);
});
it("denies and rejects a conflicting repeat", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; }); const pending = broker.request(request, new AbortController().signal); broker.deny(id); expect((await pending).status).toBe("denied"); expect(() => broker.approve(id)).toThrow(/already resolved/i);
});
it("expires", async () => {
const broker = new InMemoryApprovalBroker(5); const decision = await broker.request(request, new AbortController().signal); expect(decision.status).toBe("expired");
});
it("cancels immediately with AbortSignal", async () => {
const controller = new AbortController(); const broker = new InMemoryApprovalBroker(1_000); const pending = broker.request(request, controller.signal); controller.abort(); expect((await pending).status).toBe("cancelled");
});
it("redacts sensitive arguments", () => {
expect(redactValue({ apiKey: "secret", nested: { authorization: "Bearer x" } })).toEqual({ apiKey: "[REDACTED]", nested: { authorization: "[REDACTED]" } });
});
});
describe("SafeWorkspace", () => {
it("writes only fixed report.md through an atomic operation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const result = await workspace.writeReport("正文", "报告"); expect(result.path).toBe("report.md"); expect(await readFile(path.join(root, "report.md"), "utf8")).toContain("# 报告");
});
it("rejects .. traversal and absolute escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await expect(workspace.resolveExisting("../outside")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" }); await expect(workspace.resolveExisting("/etc/passwd")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects a symlink escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await symlink("/etc/passwd", path.join(root, "linked")); await expect(workspace.resolveExisting("linked")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects protected paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await writeFile(path.join(root, ".env"), "KEY=x"); await expect(workspace.assertReadPath(".env")).rejects.toMatchObject({ code: "PROTECTED_PATH" });
});
it("save tool schema exposes no output path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const tool = createSaveReportDraftTool(workspace); expect((tool.parameters as { properties: Record<string, unknown> }).properties).not.toHaveProperty("path");
});
});
describe("mock_ads_metrics", () => {
it("validates days in the TypeBox schema", () => {
expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 7 })).toBe(true); expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 31 })).toBe(false);
});
it("computes deterministic metrics and handles precision", async () => {
const metrics = await getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "demo-account", 7); expect(metrics.spend).toBe(4415.5); expect(metrics.campaigns).toHaveLength(3); expect(metrics.roas).toBeGreaterThan(4);
});
it("rejects unknown accounts", async () => {
await expect(getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "missing", 7)).rejects.toThrow("Unknown demo account");
});
});
describe("SkillCatalog", () => {
it("discovers .agents and .pi skills without reading bodies into the catalog", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await createSkill(root, ".pi/skills/two", "two", "Second skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect(catalog.list().filter((item) => ["one", "two"].includes(item.name))).toHaveLength(2); expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
});
it("loads path-delimited AGENT_SKILLS_DIRS and de-duplicates canonical paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const extra = path.join(root, "extra"); await createSkill(root, "extra/custom", "custom", "Extra skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), [extra, extra].join(path.delimiter)); await catalog.reload(); expect(catalog.list().filter((item) => item.name === "custom")).toHaveLength(1);
});
it("turns an unreadable extra directory into a visible diagnostic", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); 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"), path.join(root, "missing")); await catalog.reload(); expect(catalog.list().some((item) => item.diagnostics.some((diagnostic) => diagnostic.severity === "error"))).toBe(true);
});
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
it("rejects metadata, userinfo, dangerous protocols, and private HTTPS", async () => {
await expect(validateBaseUrl("https://169.254.169.254/latest", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" }); await expect(validateBaseUrl("https://user:pass@example.com", false, false)).rejects.toMatchObject({ code: "INVALID_BASE_URL" }); await expect(validateBaseUrl("file:///etc/passwd", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" }); await expect(validateBaseUrl("https://127.0.0.1/v1", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" });
});
it("allows explicit loopback HTTP only in development", async () => {
await expect(validateBaseUrl("http://localhost:11434/v1", false, true)).resolves.toContain("localhost"); await expect(validateBaseUrl("http://localhost:11434/v1", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" });
});
it("rejects credential query parameters", async () => { await expect(validateBaseUrl("https://8.8.8.8/v1?api_key=x", false, false)).rejects.toMatchObject({ code: "CREDENTIAL_IN_URL" }); });
});
async function createSkill(root: string, relative: string, name: string, description: string) { const directory = path.join(root, relative); await mkdir(directory, { recursive: true }); await writeFile(path.join(directory, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\nSECRET BODY\n`); }
@@ -0,0 +1,101 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { EncryptedFileCredentialVault, InMemoryModelConnectionStore, modelCredentialIdentity } from "../src/index.js";
const model = {
providerId: "training",
api: "openai-responses" as const,
baseUrl: "https://8.8.8.8/v1",
modelId: "training-model",
supportsImages: true,
};
describe("EncryptedFileCredentialVault", () => {
it("persists only authenticated ciphertext and decrypts after restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const masterKey = randomBytes(32).toString("base64");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, masterKey);
await first.set(identity, "secret-model-key");
const raw = await readFile(filePath, "utf8");
expect(raw).not.toContain("secret-model-key");
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
const restarted = new EncryptedFileCredentialVault(filePath, masterKey);
await expect(restarted.get(identity)).resolves.toBe("secret-model-key");
});
it("restores a credential for a new Web Session and preserves it when the old Session is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const first = new InMemoryModelConnectionStore({}, vault);
await first.configure("session-one", { ...model, apiKey: "persistent-secret" });
expect(first.getRedacted("session-one")).toMatchObject({ keyConfigured: true, credentialStorage: "encrypted" });
await first.remove("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: true, keyHint: "••••cret", credentialStorage: "encrypted" });
});
it("deletes the persisted credential only through Clear Credentials", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const store = new InMemoryModelConnectionStore({}, vault);
await store.configure("session-one", { ...model, apiKey: "persistent-secret" });
await store.clearCredentials("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: false });
});
it("fails closed for an invalid or different master key", async () => {
expect(() => new EncryptedFileCredentialVault("unused", "not-a-32-byte-key")).toThrow(/32-byte key/);
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await first.set(identity, "secret-model-key");
const wrong = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await expect(wrong.get(identity)).rejects.toMatchObject({ code: "CREDENTIAL_DECRYPTION_FAILED" });
});
it("loads a strict environment default and restores its encrypted credential", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
await vault.set(modelCredentialIdentity(model), "environment-default-secret");
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
AGENT_DISPLAY_NAME: "Training Model",
AGENT_SUPPORTS_IMAGES: "true",
}, vault);
await store.initialize();
expect(store.getRedacted("fresh-session")).toMatchObject({ source: "environment", modelId: model.modelId, displayName: "Training Model", supportsImages: true, keyConfigured: true, credentialStorage: "encrypted" });
});
it("uses an environment API key without returning the raw credential", async () => {
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
OPENAI_API_KEY: "environment-api-secret",
});
await store.initialize();
const redacted = store.getRedacted("fresh-session");
expect(redacted).toMatchObject({ source: "environment", keyConfigured: true, keyHint: "••••cret", credentialStorage: "environment" });
expect(JSON.stringify(redacted)).not.toContain("environment-api-secret");
});
it("fails startup for partial or malformed environment defaults", async () => {
const partial = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai" });
await expect(partial.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
const malformed = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai", AGENT_MODEL: "model", AGENT_SUPPORTS_IMAGES: "yes" });
await expect(malformed.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
});
});
+122
View File
@@ -0,0 +1,122 @@
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);
});
});