Ensure agent titles persist synchronously during creation

This commit is contained in:
Mohamed Boudra
2025-12-02 10:13:59 +00:00
parent 6e259c37db
commit dfe4321051
10 changed files with 173 additions and 16 deletions
@@ -0,0 +1,112 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { AgentManager } from "./agent-manager.js";
import { AgentRegistry } from "./agent-registry.js";
import type {
AgentClient,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "./agent-sdk-types.js";
const TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TestAgentSession(config);
}
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
return new TestAgentSession({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
});
}
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
constructor(private readonly config: AgentSessionConfig) {}
async run(): Promise<AgentRunResult> {
return {
sessionId: this.id ?? this.config.provider,
finalText: "",
timeline: [],
};
}
async *stream(): AsyncGenerator<AgentStreamEvent> {}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return null;
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
describe("AgentManager", () => {
test("createAgent persists provided title before returning", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json");
const registry = new AgentRegistry(registryPath);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry,
idFactory: () => "agent-with-title",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Fix Login Bug",
});
expect(snapshot.id).toBe("agent-with-title");
expect(snapshot.lifecycle).toBe("idle");
const persisted = await registry.get("agent-with-title");
expect(persisted?.title).toBe("Fix Login Bug");
expect(persisted?.id).toBe("agent-with-title");
});
});
@@ -21,6 +21,7 @@ import type {
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { resolveAgentModel } from "./model-resolver.js"; import { resolveAgentModel } from "./model-resolver.js";
import type { AgentRegistry } from "./agent-registry.js";
export const AGENT_LIFECYCLE_STATUSES = [ export const AGENT_LIFECYCLE_STATUSES = [
"initializing", "initializing",
@@ -52,6 +53,7 @@ export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>; clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number; maxTimelineItems?: number;
idFactory?: () => string; idFactory?: () => string;
registry?: AgentRegistry;
}; };
export type WaitForAgentOptions = { export type WaitForAgentOptions = {
@@ -180,11 +182,13 @@ export class AgentManager {
private readonly subscribers = new Set<SubscriptionRecord>(); private readonly subscribers = new Set<SubscriptionRecord>();
private readonly maxTimelineItems: number; private readonly maxTimelineItems: number;
private readonly idFactory: () => string; private readonly idFactory: () => string;
private readonly registry?: AgentRegistry;
constructor(options?: AgentManagerOptions) { constructor(options?: AgentManagerOptions) {
this.maxTimelineItems = this.maxTimelineItems =
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
this.idFactory = options?.idFactory ?? (() => randomUUID()); this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
if (options?.clients) { if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) { for (const [provider, client] of Object.entries(options.clients)) {
if (client) { if (client) {
@@ -718,14 +722,28 @@ export class AgentManager {
} as ActiveManagedAgent; } as ActiveManagedAgent;
this.agents.set(agentId, managed); this.agents.set(agentId, managed);
await this.persistSnapshot(managed, {
title: config.title ?? null,
});
this.emitState(managed); this.emitState(managed);
await this.refreshSessionState(managed); await this.refreshSessionState(managed);
managed.lifecycle = "idle"; managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.emitState(managed); this.emitState(managed);
return this.createAgentView(managed); return this.createAgentView(managed);
} }
private async persistSnapshot(
agent: ManagedAgent,
options?: { title?: string | null }
): Promise<void> {
if (!this.registry) {
return;
}
await this.registry.applySnapshot(agent, options);
}
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> { private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
try { try {
const modes = await agent.session.getAvailableModes(); const modes = await agent.session.getAvailableModes();
@@ -168,6 +168,22 @@ describe("AgentRegistry", () => {
expect(persisted?.title).toBe("Fix Login Bug"); expect(persisted?.title).toBe("Fix Login Bug");
}); });
test("setTitle throws when the agent record does not exist", async () => {
await expect(registry.setTitle("missing-agent", "Impossible"))
.rejects.toThrow("Agent missing-agent not found");
});
test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override";
await registry.applySnapshot(
createManagedAgent({ id: agentId }),
{ title: "Provided Title" }
);
const record = await registry.get(agentId);
expect(record?.title).toBe("Provided Title");
});
test("applySnapshot preserves custom titles while updating metadata", async () => { test("applySnapshot preserves custom titles while updating metadata", async () => {
const agentId = "agent-3"; const agentId = "agent-3";
await registry.applySnapshot( await registry.applySnapshot(
@@ -102,11 +102,16 @@ export class AgentRegistry {
await this.flush(); await this.flush();
} }
async applySnapshot(agent: ManagedAgent): Promise<void> { async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null }
): Promise<void> {
await this.load(); await this.load();
const existing = this.cache.get(agent.id); const existing = this.cache.get(agent.id);
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const record = toStoredAgentRecord(agent, { const record = toStoredAgentRecord(agent, {
title: existing?.title ?? null, title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
createdAt: existing?.createdAt, createdAt: existing?.createdAt,
}); });
this.cache.set(agent.id, record); this.cache.set(agent.id, record);
@@ -117,7 +122,7 @@ export class AgentRegistry {
await this.load(); await this.load();
const record = this.cache.get(agentId); const record = this.cache.get(agentId);
if (!record) { if (!record) {
return; throw new Error(`Agent ${agentId} not found`);
} }
this.cache.set(agentId, { ...record, title }); this.cache.set(agentId, { ...record, title });
await this.flush(); await this.flush();
@@ -136,6 +136,7 @@ export type AgentSessionConfig = {
cwd: string; cwd: string;
modeId?: string; modeId?: string;
model?: string; model?: string;
title?: string | null;
approvalPolicy?: string; approvalPolicy?: string;
sandboxMode?: string; sandboxMode?: string;
networkAccess?: boolean; networkAccess?: boolean;
@@ -71,7 +71,7 @@ describe("create_agent MCP tool", () => {
expect(ok.success).toBe(true); expect(ok.success).toBe(true);
}); });
it("persists provided titles immediately after agent creation", async () => { it("passes caller-provided titles directly into createAgent", async () => {
const { agentManager, agentRegistry, spies } = createTestDeps(); const { agentManager, agentRegistry, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123", id: "agent-123",
@@ -88,9 +88,11 @@ describe("create_agent MCP tool", () => {
title: " Fix auth bug ", title: " Fix auth bug ",
}); });
expect(spies.agentRegistry.setTitle).toHaveBeenCalledWith( expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
"agent-123", expect.objectContaining({
"Fix auth bug" cwd: "/tmp/repo",
title: "Fix auth bug",
})
); );
}); });
}); });
@@ -193,21 +193,14 @@ export async function createAgentMcpServer(
} }
const provider: AgentProvider = agentType ?? "claude"; const provider: AgentProvider = agentType ?? "claude";
const normalizedTitle = title?.trim() ?? null;
const snapshot = await agentManager.createAgent({ const snapshot = await agentManager.createAgent({
provider, provider,
cwd: resolvedCwd, cwd: resolvedCwd,
modeId: initialMode, modeId: initialMode,
title: normalizedTitle ?? undefined,
}); });
try {
await agentRegistry.setTitle(snapshot.id, title);
} catch (error) {
console.error(
`[Agent MCP] Failed to persist title for ${snapshot.id}:`,
error
);
}
if (initialPrompt) { if (initialPrompt) {
try { try {
agentManager.recordUserMessage(snapshot.id, initialPrompt); agentManager.recordUserMessage(snapshot.id, initialPrompt);
+1
View File
@@ -103,6 +103,7 @@ async function main() {
}), }),
codex: new CodexAgentClient(), codex: new CodexAgentClient(),
}, },
registry: agentRegistry,
}); });
attachAgentRegistryPersistence(agentManager, agentRegistry); attachAgentRegistryPersistence(agentManager, agentRegistry);
+7
View File
@@ -58,6 +58,13 @@ const AgentSessionConfigSchema = z.object({
cwd: z.string(), cwd: z.string(),
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
title: z
.string()
.trim()
.min(1)
.max(40)
.optional()
.nullable(),
approvalPolicy: z.string().optional(), approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(), sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(), networkAccess: z.boolean().optional(),
@@ -42,6 +42,7 @@ export function buildConfigOverrides(
cwd: record.cwd, cwd: record.cwd,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined, modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined, model: record.config?.model ?? undefined,
title: record.title ?? undefined,
extra: record.config?.extra ?? undefined, extra: record.config?.extra ?? undefined,
}; };
} }
@@ -58,6 +59,7 @@ export function buildSessionConfig(
cwd: record.cwd, cwd: record.cwd,
modeId: overrides.modeId, modeId: overrides.modeId,
model: overrides.model, model: overrides.model,
title: overrides.title,
extra: overrides.extra, extra: overrides.extra,
}; };
} }