Files
paseo/packages/server/src/server/agent/agent-storage.test.ts
T
Mohamed Boudra 6e80ed7989 feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
2026-11-04 04:24:46 +01:00

599 lines
20 KiB
TypeScript

import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
import path from "node:path";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "./agent-storage.js";
import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js";
import type { ManagedAgent } from "./agent-manager.js";
import type {
AgentPermissionRequest,
AgentProvider,
AgentSession,
AgentSessionConfig,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
runtimeInfo?: ManagedAgent["runtimeInfo"];
attention?: ManagedAgent["attention"];
};
function buildManagedAgentConfig(
provider: AgentProvider,
cwd: string,
configOverrides: Partial<AgentSessionConfig>,
): AgentSessionConfig {
const config: AgentSessionConfig = {
provider,
cwd,
title: configOverrides.title,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "gpt-5.1",
thinkingOptionId: configOverrides.thinkingOptionId,
providerOptions: configOverrides.providerOptions,
toolPolicy: configOverrides.toolPolicy,
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
return config;
}
function buildDefaultCapabilities() {
return {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
}
function buildDefaultRuntimeInfo(params: {
provider: AgentProvider;
config: AgentSessionConfig;
sessionId: string;
}) {
return {
provider: params.provider,
sessionId: params.sessionId,
model: params.config.model ?? null,
modeId: params.config.modeId ?? null,
};
}
interface ManagedAgentCore {
provider: AgentProvider;
cwd: string;
lifecycle: ManagedAgent["lifecycle"];
config: AgentSessionConfig;
session: AgentSession | null;
activeForegroundTurnId: string | null;
now: Date;
}
function resolveManagedAgentCore(overrides: ManagedAgentOverrides): ManagedAgentCore {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const config = buildManagedAgentConfig(provider, cwd, overrides.config ?? {});
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
return { provider, cwd, lifecycle, config, session, activeForegroundTurnId, now };
}
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const core = resolveManagedAgentCore(overrides);
return {
id: overrides.id ?? "agent-test",
provider: core.provider,
cwd: core.cwd,
workspaceId: overrides.workspaceId,
session: core.session,
capabilities: overrides.capabilities ?? buildDefaultCapabilities(),
config: core.config,
lifecycle: core.lifecycle,
createdAt: overrides.createdAt ?? core.now,
updatedAt: overrides.updatedAt ?? core.now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? core.config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId: core.activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
attention: overrides.attention ?? { requiresAttention: false },
runtimeInfo:
overrides.runtimeInfo ??
buildDefaultRuntimeInfo({
provider: core.provider,
config: core.config,
sessionId: overrides.sessionId ?? "session-123",
}),
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? core.now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
};
}
function findRecordFile(baseDir: string, agentId: string): string {
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
if (entry.isDirectory() && existsSync(candidate)) return candidate;
}
throw new Error(`No stored record for ${agentId}`);
}
describe("AgentStorage", () => {
let tmpDir: string;
let storagePath: string;
let storage: AgentStorage;
const logger = createTestLogger();
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agent-registry-"));
storagePath = path.join(tmpDir, "agents");
storage = new AgentStorage(storagePath, logger);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("applySnapshot persists configs and snapshot metadata", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-1",
cwd: "/tmp/project",
currentModeId: "coding",
lifecycle: "idle",
config: {
title: "Initial title",
modeId: "coding",
model: "gpt-5.1",
systemPrompt: "Be terse and explicit.",
providerOptions: { allowedTools: ["Read"] },
mcpServers: {
paseo: {
type: "stdio",
command: "node",
args: ["/tmp/mcp-stdio-socket-bridge-cli.mjs", "--socket", "/tmp/test.sock"],
},
},
},
}),
);
const records = await storage.list();
expect(records).toHaveLength(1);
const [record] = records;
expect(record.provider).toBe("claude");
expect(record.config?.modeId).toBe("coding");
expect(record.config?.model).toBe("gpt-5.1");
expect(record.config?.systemPrompt).toBe("Be terse and explicit.");
expect(record.config?.mcpServers).toEqual({
paseo: {
type: "stdio",
command: "node",
args: ["/tmp/mcp-stdio-socket-bridge-cli.mjs", "--socket", "/tmp/test.sock"],
},
});
expect(record.lastModeId).toBe("coding");
expect(record.lastStatus).toBe("idle");
const reloaded = new AgentStorage(storagePath, logger);
const [persisted] = await reloaded.list();
expect(persisted.cwd).toBe("/tmp/project");
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
});
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-provider-options",
config: { providerOptions: { fast_mode: true } },
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-provider-options");
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
expect(buildConfigOverrides(persisted!)).toMatchObject({
cwd: "/tmp/project",
providerOptions: { fast_mode: true },
});
});
test("a record stored with featureValues loads them in providerOptions", async () => {
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
const file = findRecordFile(storagePath, "agent-legacy");
const stored = JSON.parse(await fs.readFile(file, "utf8"));
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
delete stored.controls;
stored.features = [
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
type: "select",
id: "service_tier",
label: "Speed",
value: "fast",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
},
];
await fs.writeFile(file, JSON.stringify(stored));
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-legacy");
// An explicit providerOptions key wins over the same featureValues key.
expect(persisted?.config?.providerOptions).toEqual({
allowedTools: ["Read"],
fast_mode: false,
service_tier: "fast",
});
expect(persisted?.config).not.toHaveProperty("featureValues");
expect(persisted).not.toHaveProperty("features");
expect(persisted?.controls).toEqual([
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
],
},
]);
await reloaded.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
const rewritten = JSON.parse(await fs.readFile(file, "utf8"));
expect(rewritten.config).not.toHaveProperty("featureValues");
expect(rewritten).not.toHaveProperty("features");
});
test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
await storage.applySnapshot(createManagedAgent({ id: agentId, createdAt: firstTimestamp }));
const initialRecord = await storage.get(agentId);
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
await storage.applySnapshot(
createManagedAgent({
id: agentId,
createdAt: new Date("2025-02-01T00:00:00.000Z"),
updatedAt: new Date("2025-02-01T00:00:00.000Z"),
lifecycle: "running",
}),
);
const updatedRecord = await storage.get(agentId);
expect(updatedRecord?.createdAt).toBe(firstTimestamp.toISOString());
expect(updatedRecord?.lastStatus).toBe("running");
});
test("applySnapshot preserves archivedAt (soft-delete) status", async () => {
const agentId = "agent-archived";
await storage.applySnapshot(
createManagedAgent({
id: agentId,
lifecycle: "idle",
}),
);
const archivedAt = "2025-01-03T00:00:00.000Z";
const recordBeforeArchive = await storage.get(agentId);
expect(recordBeforeArchive).not.toBeNull();
await storage.upsert({ ...recordBeforeArchive!, archivedAt });
await storage.applySnapshot(
createManagedAgent({
id: agentId,
lifecycle: "running",
updatedAt: new Date("2025-01-04T00:00:00.000Z"),
}),
);
const recordAfterSnapshot = await storage.get(agentId);
expect(recordAfterSnapshot?.archivedAt).toBe(archivedAt);
});
test("stores titles independently of snapshots", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-2",
provider: "codex",
cwd: "/tmp/second",
}),
);
await storage.setTitle("agent-2", "Fix Login Bug");
const current = await storage.get("agent-2");
expect(current?.title).toBe("Fix Login Bug");
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-2");
expect(persisted?.title).toBe("Fix Login Bug");
});
test("setTitle throws when the agent record does not exist", async () => {
await expect(storage.setTitle("missing-agent", "Impossible")).rejects.toThrow(
"Agent missing-agent not found",
);
});
test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override";
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
const record = await storage.get(agentId);
expect(record?.title).toBe("Provided Title");
});
test("applySnapshot preserves custom titles while updating metadata", async () => {
const agentId = "agent-3";
await storage.applySnapshot(
createManagedAgent({
id: agentId,
lifecycle: "idle",
currentModeId: "plan",
}),
);
await storage.setTitle(agentId, "Important Bug Fix");
await storage.applySnapshot(
createManagedAgent({
id: agentId,
lifecycle: "running",
currentModeId: "build",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
);
const record = await storage.get(agentId);
expect(record?.title).toBe("Important Bug Fix");
expect(record?.lastModeId).toBe("build");
expect(record?.lastStatus).toBe("running");
});
test("applySnapshot projects metadata after in-flight archival writes", async () => {
const agentId = "agent-pending-write";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
const initialRecord = await storage.get(agentId);
expect(initialRecord).not.toBeNull();
let releasePendingWrite: (() => void) | null = null;
const pendingWrite = new Promise<void>((resolve) => {
releasePendingWrite = resolve;
});
const storageInternals = storage as unknown as {
pendingWrites: Map<string, Promise<void>>;
cache: Map<string, unknown>;
};
storageInternals.pendingWrites.set(agentId, pendingWrite);
const applySnapshotPromise = storage.applySnapshot(
createManagedAgent({
id: agentId,
lifecycle: "running",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
);
storageInternals.cache.set(agentId, {
...initialRecord!,
title: "Generated title",
archivedAt: "2025-01-03T00:00:00.000Z",
});
releasePendingWrite?.();
await applySnapshotPromise;
const record = await storage.get(agentId);
expect(record?.title).toBe("Generated title");
expect(record?.archivedAt).toBe("2025-01-03T00:00:00.000Z");
});
test("list returns all agents including internal ones", async () => {
// Create a normal agent
await storage.applySnapshot(
createManagedAgent({
id: "normal-agent",
cwd: "/tmp/project",
}),
);
// Create an internal agent
await storage.applySnapshot(
createManagedAgent({
id: "internal-agent",
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true },
);
// Registry should return all agents - filtering is done at the manager level
const records = await storage.list();
expect(records).toHaveLength(2);
});
test("get returns internal agents by ID", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "internal-agent",
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true },
);
const record = await storage.get("internal-agent");
expect(record).not.toBeNull();
expect(record?.internal).toBe(true);
});
test("queries agents by provider session and native handle", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "matching-session",
provider: "codex",
persistence: {
provider: "codex",
sessionId: "session-1",
nativeHandle: "thread-1",
},
}),
);
await storage.applySnapshot(
createManagedAgent({
id: "other-session",
provider: "codex",
persistence: { provider: "codex", sessionId: "session-2" },
}),
);
await expect(storage.listByProviderSession("codex", "session-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
await expect(storage.listByProviderSession("codex", "thread-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
});
test("queries agents by workspace", async () => {
await storage.applySnapshot(
createManagedAgent({ id: "workspace-agent", workspaceId: "workspace-1" }),
);
await storage.applySnapshot(
createManagedAgent({ id: "other-workspace-agent", workspaceId: "workspace-2" }),
);
await expect(storage.listByWorkspace("workspace-1")).resolves.toMatchObject([
{ id: "workspace-agent" },
]);
});
test("internal flag is persisted and reloaded", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "internal-agent",
cwd: "/tmp/project",
config: { internal: true },
}),
{ internal: true },
);
// Reload the registry from disk
const reloaded = new AgentStorage(storagePath, logger);
const record = await reloaded.get("internal-agent");
expect(record?.internal).toBe(true);
// Registry returns all agents - filtering happens at manager level
const records = await reloaded.list();
expect(records).toHaveLength(1);
expect(records[0]?.internal).toBe(true);
});
test("Windows drive-letter paths produce valid directory names", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "win-agent",
cwd: "D:\\Users\\dev\\MyProject",
}),
);
const record = await storage.get("win-agent");
expect(record).not.toBeNull();
// The persisted directory must not contain a colon (invalid on Windows)
const dirs = readdirSync(storagePath);
expect(dirs).toHaveLength(1);
expect(dirs[0]).not.toContain(":");
expect(dirs[0]).toBe("D-Users-dev-MyProject");
});
test("remove deletes all duplicate record files across project directories", async () => {
const agentId = "agent-duplicate";
// Create a valid record file in two different project directories to simulate
// storage migrations/duplication. Only one copy will be referenced in-memory,
// but deletion should remove *all* copies on disk.
const recordA = await (async () => {
await storage.applySnapshot(
createManagedAgent({
id: agentId,
cwd: "/tmp/project-a",
provider: "codex",
}),
);
const record = await storage.get(agentId);
expect(record).not.toBeNull();
return record!;
})();
const projectDirB = path.join(storagePath, "tmp-project-b");
await fs.mkdir(projectDirB, { recursive: true });
const duplicatePathB = path.join(projectDirB, `${agentId}.json`);
await fs.writeFile(
duplicatePathB,
JSON.stringify({ ...recordA, cwd: "/tmp/project-b" }, null, 2),
"utf8",
);
// Force a reload so the registry has to discover from disk (and may choose either copy).
const reloaded = new AgentStorage(storagePath, logger);
const before = await reloaded.list();
expect(before.map((r) => r.id)).toContain(agentId);
await reloaded.remove(agentId);
const hasAnyRecordFile = async () => {
const projects = await fs
.readdir(storagePath, { withFileTypes: true })
.catch(() => [] as Awaited<ReturnType<typeof fs.readdir>>);
const exists = await Promise.all(
projects
.filter((project) => project.isDirectory())
.map(async (project) => {
const candidate = path.join(storagePath, project.name, `${agentId}.json`);
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
}),
);
return exists.some((present) => present);
};
expect(await hasAnyRecordFile()).toBe(false);
const afterReload = new AgentStorage(storagePath, logger);
const after = await afterReload.list();
expect(after.some((r) => r.id === agentId)).toBe(false);
});
});