feat: add pure projection functions for ManagedAgent transformations

Introduce toStoredAgentRecord and toAgentPayload as deterministic
pure functions to project ManagedAgent state to persistence and
client payload formats. This eliminates the need for AgentSnapshot
as an intermediate representation.

- Add toStoredAgentRecord for persistence projection
- Add toAgentPayload for client communication projection
- Add comprehensive test suite covering all lifecycle states
- Handle optionality at boundaries with proper null/undefined semantics

Task 1 of 8 in agent architecture refactor.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-11-30 00:02:37 +00:00
parent 0dfa2f04bd
commit 2794a5b63c
2 changed files with 471 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "./agent-manager.js";
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
import type {
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
} from "./agent-sdk-types.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
};
function createManagedAgent(
overrides: ManagedAgentOverrides = {}
): ManagedAgent {
const now = new Date("2025-01-01T00:00:00.000Z");
const baseConfig: AgentSessionConfig = {
provider: "claude",
cwd: "/tmp/project",
modeId: "plan",
model: "claude-3.5-sonnet",
extra: {
claude: { tone: "friendly" },
},
};
const basePersistence: AgentPersistenceHandle = {
provider: "claude",
sessionId: "persist-1",
metadata: { branch: "feature/refactor" },
};
const configOverrides = overrides.config ?? {};
const {
config: _ignoredConfig,
pendingPermissions: pendingPermissionsOverride,
lifecycle = "idle",
...restOverrides
} = overrides;
const sessionValue =
lifecycle === "closed" ? null : restOverrides.session ?? ({} as any);
const pendingRunValue =
restOverrides.pendingRun ??
(lifecycle === "running" ? (async function* noop() {})() : null);
const lastErrorValue =
restOverrides.lastError ??
(lifecycle === "error" ? "encountered error" : undefined);
const agent: ManagedAgent = {
id: "agent-123",
provider: "claude",
cwd: "/tmp/project",
session: sessionValue,
sessionId: "session-123",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config: { ...baseConfig, ...configOverrides },
lifecycle,
createdAt: now,
updatedAt: now,
availableModes: [
{ id: "plan", label: "Planning" },
{ id: "build", label: "Building", description: "Detailed" },
],
currentModeId: "plan",
pendingPermissions:
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
timeline: [],
persistence: { ...basePersistence },
lastUsage: undefined,
lastError: lastErrorValue,
historyPrimed: true,
lastUserMessageAt: now,
};
return {
...agent,
...restOverrides,
lifecycle,
config: agent.config,
pendingPermissions: agent.pendingPermissions,
};
}
function createPermission(
overrides: Partial<AgentPermissionRequest> = {}
): AgentPermissionRequest {
const base: AgentPermissionRequest = {
id: "perm-1",
provider: "claude",
name: "execute_command",
kind: "tool",
title: "Run command",
description: "Execute shell command",
input: { command: "ls", args: undefined },
suggestions: [{ behavior: "allow" }],
metadata: { requestedAt: new Date("2025-02-01T12:00:00.000Z") },
};
return { ...base, ...overrides };
}
describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
currentModeId: "focus",
persistence: {
provider: "claude",
sessionId: "persist-2",
metadata: { resumedAt: new Date("2025-01-05T00:00:00.000Z"), note: "warm" },
},
});
const record = toStoredAgentRecord(agent, { title: "Refactor Agent" });
expect(record).toMatchObject({
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
title: "Refactor Agent",
lastStatus: agent.lifecycle,
lastModeId: "focus",
});
expect(record.createdAt).toBe(agent.createdAt.toISOString());
expect(record.updatedAt).toBe(agent.updatedAt.toISOString());
expect(record.lastActivityAt).toBe(agent.updatedAt.toISOString());
expect(record.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
expect(record.persistence).toEqual({
provider: "claude",
sessionId: "persist-2",
metadata: {
resumedAt: "2025-01-05T00:00:00.000Z",
note: "warm",
},
});
expect(record.config).toEqual({
modeId: agent.config.modeId,
model: agent.config.model,
extra: { claude: { tone: "friendly" } },
});
record.config!.extra!.claude!.tone = "serious";
expect(agent.config.extra!.claude!.tone).toBe("friendly");
record.persistence!.sessionId = "mutated";
expect(agent.persistence!.sessionId).toBe("persist-2");
});
it("falls back to config mode when current mode is null and handles null title", () => {
const agent = createManagedAgent({
currentModeId: null,
config: { modeId: "auto" },
lastUserMessageAt: null,
});
const record = toStoredAgentRecord(agent);
expect(record.title).toBeNull();
expect(record.lastModeId).toBe("auto");
expect(record.lastUserMessageAt).toBeNull();
});
it("omits config when no serializable fields exist", () => {
const agent = createManagedAgent({
config: {
modeId: undefined,
model: undefined,
extra: undefined,
},
});
const record = toStoredAgentRecord(agent);
expect(record.config).toBeNull();
});
it("propagates lifecycle status for all states", () => {
for (const status of AGENT_LIFECYCLE_STATUSES) {
const agent = createManagedAgent({ lifecycle: status as AgentLifecycleStatus });
const record = toStoredAgentRecord(agent);
expect(record.lastStatus).toBe(status);
}
});
});
describe("toAgentPayload", () => {
it("serializes dates, clones arrays, and hides session", () => {
const permissionA = createPermission({ id: "perm-a" });
const permissionB = createPermission({
id: "perm-b",
provider: "codex",
metadata: { requestedAt: new Date("2025-02-02T00:00:00.000Z"), extra: { flag: true } },
});
const pending = new Map([
[permissionA.id, permissionA],
[permissionB.id, permissionB],
]);
const agent = createManagedAgent({
pendingPermissions: pending,
lastUsage: { inputTokens: 10, outputTokens: 20 },
lastError: "boom",
});
const payload = toAgentPayload(agent, { title: "UI Payload" });
expect(payload.createdAt).toBe(agent.createdAt.toISOString());
expect(payload.updatedAt).toBe(agent.updatedAt.toISOString());
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
expect(payload.title).toBe("UI Payload");
expect(payload.model).toBe(agent.config.model);
expect(payload.sessionId).toBe(agent.sessionId);
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
"perm-a",
"perm-b",
]);
expect(payload.pendingPermissions[0]).not.toBe(permissionA);
expect(payload.pendingPermissions[0].input).toEqual({ command: "ls" });
expect(payload.pendingPermissions[1].metadata).toEqual({
requestedAt: "2025-02-02T00:00:00.000Z",
extra: { flag: true },
});
expect(payload.availableModes).not.toBe(agent.availableModes);
expect(payload.availableModes).toEqual(agent.availableModes);
expect(payload.capabilities).not.toBe(agent.capabilities);
expect(payload.capabilities).toEqual(agent.capabilities);
expect(payload.lastUsage).toEqual(agent.lastUsage);
expect(payload.lastUsage).not.toBe(agent.lastUsage);
expect(payload.lastError).toBe("boom");
expect((payload as any).session).toBeUndefined();
payload.availableModes[0].label = "Changed";
expect(agent.availableModes[0].label).toBe("Planning");
payload.capabilities.supportsStreaming = false;
expect(agent.capabilities.supportsStreaming).toBe(true);
payload.pendingPermissions[0].title = "Mutated title";
expect(permissionA.title).toBe("Run command");
});
it("produces null title and current mode even without overrides", () => {
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
const payload = toAgentPayload(agent);
expect(payload.title).toBeNull();
expect(payload.currentModeId).toBeNull();
expect(payload.lastUserMessageAt).toBeNull();
expect(payload.pendingPermissions).toEqual([]);
});
it("propagates lifecycle status for all states", () => {
for (const status of AGENT_LIFECYCLE_STATUSES) {
const agent = createManagedAgent({ lifecycle: status as AgentLifecycleStatus });
const payload = toAgentPayload(agent);
expect(payload.status).toBe(status);
}
});
it("keeps persistence handles sanitized and detached", () => {
const agent = createManagedAgent({
persistence: {
provider: "codex",
sessionId: "persist-99",
nativeHandle: { id: "native" } as any,
metadata: { restored: new Date("2025-03-01T00:00:00.000Z"), empty: {} },
},
});
const payload = toAgentPayload(agent);
expect(payload.persistence).toEqual({
provider: "codex",
sessionId: "persist-99",
nativeHandle: { id: "native" },
metadata: { restored: "2025-03-01T00:00:00.000Z" },
});
(payload.persistence as AgentPersistenceHandle).sessionId = "mutated";
expect(agent.persistence!.sessionId).toBe("persist-99");
});
it("omits lastUsage when not available", () => {
const agent = createManagedAgent({ lastUsage: undefined });
const payload = toAgentPayload(agent);
expect(payload).not.toHaveProperty("lastUsage");
});
});

View File

@@ -0,0 +1,177 @@
import type { AgentSnapshotPayload } from "../messages.js";
import type {
SerializableAgentConfig,
StoredAgentRecord,
} from "./agent-registry.js";
import type {
AgentCapabilityFlags,
AgentMode,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
AgentUsage,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
export type { ManagedAgent };
type ProjectionOptions = {
title?: string | null;
};
export function toStoredAgentRecord(
agent: ManagedAgent,
options?: ProjectionOptions
): StoredAgentRecord {
const config = buildSerializableConfig(agent.config);
const persistence = sanitizePersistenceHandle(agent.persistence);
return {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt
? agent.lastUserMessageAt.toISOString()
: null,
title: options?.title ?? null,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
persistence,
} satisfies StoredAgentRecord;
}
export function toAgentPayload(
agent: ManagedAgent,
options?: ProjectionOptions
): AgentSnapshotPayload {
const payload: AgentSnapshotPayload = {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
model: agent.config.model ?? null,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt
? agent.lastUserMessageAt.toISOString()
: null,
status: agent.lifecycle,
sessionId: agent.sessionId,
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: sanitizePersistenceHandle(agent.persistence),
title: options?.title ?? null,
};
const usage = sanitizeOptionalJsonValue<AgentUsage>(agent.lastUsage);
if (usage !== undefined) {
payload.lastUsage = usage;
}
if (agent.lastError !== undefined) {
payload.lastError = agent.lastError;
}
return payload;
}
function buildSerializableConfig(
config: AgentSessionConfig
): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (config.modeId) {
serializable.modeId = config.modeId;
}
if (config.model) {
serializable.model = config.model;
}
const extra = sanitizeOptionalJsonValue(config.extra);
if (extra !== undefined) {
serializable.extra = extra;
}
return Object.keys(serializable).length ? serializable : null;
}
function sanitizePendingPermissions(
pending: Map<string, AgentPermissionRequest>
): AgentPermissionRequest[] {
return Array.from(pending.values()).map((request) => (
{
...request,
input: sanitizeOptionalJsonValue(request.input),
suggestions: sanitizeOptionalJsonValue(request.suggestions),
metadata: sanitizeOptionalJsonValue(request.metadata),
}
));
}
function sanitizePersistenceHandle(
handle: AgentPersistenceHandle | null
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const sanitized: AgentPersistenceHandle = {
provider: handle.provider,
sessionId: handle.sessionId,
};
if (handle.nativeHandle !== undefined) {
sanitized.nativeHandle = handle.nativeHandle;
}
const metadata = sanitizeOptionalJsonValue(handle.metadata);
if (metadata !== undefined) {
sanitized.metadata = metadata;
}
return sanitized;
}
function cloneCapabilities(
capabilities: AgentCapabilityFlags
): AgentCapabilityFlags {
return { ...capabilities };
}
function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode }));
}
function sanitizeOptionalJson(value: unknown): unknown {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (Array.isArray(value)) {
const sanitized = value
.map((item) => sanitizeOptionalJson(item))
.filter((item) => item !== undefined);
return sanitized;
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const sanitized = sanitizeOptionalJson(val);
if (sanitized !== undefined) {
result[key] = sanitized;
}
}
return Object.keys(result).length ? result : undefined;
}
return value;
}
function sanitizeOptionalJsonValue<T>(
value: T | null | undefined
): T | undefined {
const sanitized = sanitizeOptionalJson(value);
return sanitized == null ? undefined : (sanitized as T);
}