From 2794a5b63c43fc862a416e830990625333240313 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 30 Nov 2025 00:02:37 +0000 Subject: [PATCH] feat: add pure projection functions for ManagedAgent transformations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../server/agent/agent-projections.test.ts | 294 ++++++++++++++++++ .../src/server/agent/agent-projections.ts | 177 +++++++++++ 2 files changed, 471 insertions(+) create mode 100644 packages/server/src/server/agent/agent-projections.test.ts create mode 100644 packages/server/src/server/agent/agent-projections.ts diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts new file mode 100644 index 000000000..d7cd05fa8 --- /dev/null +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -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, + "config" | "pendingPermissions" +> & { + config?: Partial; + pendingPermissions?: Map; +}; + +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(), + 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 { + 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"); + }); +}); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts new file mode 100644 index 000000000..69f38ebde --- /dev/null +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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(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 +): 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 = {}; + for (const [key, val] of Object.entries(value as Record)) { + const sanitized = sanitizeOptionalJson(val); + if (sanitized !== undefined) { + result[key] = sanitized; + } + } + return Object.keys(result).length ? result : undefined; + } + return value; +} + +function sanitizeOptionalJsonValue( + value: T | null | undefined +): T | undefined { + const sanitized = sanitizeOptionalJson(value); + return sanitized == null ? undefined : (sanitized as T); +}