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,
} from "./agent-sdk-types.js";
import { resolveAgentModel } from "./model-resolver.js";
import type { AgentRegistry } from "./agent-registry.js";
export const AGENT_LIFECYCLE_STATUSES = [
"initializing",
@@ -52,6 +53,7 @@ export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number;
idFactory?: () => string;
registry?: AgentRegistry;
};
export type WaitForAgentOptions = {
@@ -180,11 +182,13 @@ export class AgentManager {
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly maxTimelineItems: number;
private readonly idFactory: () => string;
private readonly registry?: AgentRegistry;
constructor(options?: AgentManagerOptions) {
this.maxTimelineItems =
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) {
if (client) {
@@ -718,14 +722,28 @@ export class AgentManager {
} as ActiveManagedAgent;
this.agents.set(agentId, managed);
await this.persistSnapshot(managed, {
title: config.title ?? null,
});
this.emitState(managed);
await this.refreshSessionState(managed);
managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.emitState(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> {
try {
const modes = await agent.session.getAvailableModes();
@@ -168,6 +168,22 @@ describe("AgentRegistry", () => {
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 () => {
const agentId = "agent-3";
await registry.applySnapshot(
@@ -102,11 +102,16 @@ export class AgentRegistry {
await this.flush();
}
async applySnapshot(agent: ManagedAgent): Promise<void> {
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null }
): Promise<void> {
await this.load();
const existing = this.cache.get(agent.id);
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const record = toStoredAgentRecord(agent, {
title: existing?.title ?? null,
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
createdAt: existing?.createdAt,
});
this.cache.set(agent.id, record);
@@ -117,7 +122,7 @@ export class AgentRegistry {
await this.load();
const record = this.cache.get(agentId);
if (!record) {
return;
throw new Error(`Agent ${agentId} not found`);
}
this.cache.set(agentId, { ...record, title });
await this.flush();
@@ -136,6 +136,7 @@ export type AgentSessionConfig = {
cwd: string;
modeId?: string;
model?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
@@ -71,7 +71,7 @@ describe("create_agent MCP tool", () => {
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();
spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123",
@@ -88,9 +88,11 @@ describe("create_agent MCP tool", () => {
title: " Fix auth bug ",
});
expect(spies.agentRegistry.setTitle).toHaveBeenCalledWith(
"agent-123",
"Fix auth bug"
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/tmp/repo",
title: "Fix auth bug",
})
);
});
});
@@ -193,21 +193,14 @@ export async function createAgentMcpServer(
}
const provider: AgentProvider = agentType ?? "claude";
const normalizedTitle = title?.trim() ?? null;
const snapshot = await agentManager.createAgent({
provider,
cwd: resolvedCwd,
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) {
try {
agentManager.recordUserMessage(snapshot.id, initialPrompt);
+1
View File
@@ -103,6 +103,7 @@ async function main() {
}),
codex: new CodexAgentClient(),
},
registry: agentRegistry,
});
attachAgentRegistryPersistence(agentManager, agentRegistry);
+7
View File
@@ -58,6 +58,13 @@ const AgentSessionConfigSchema = z.object({
cwd: z.string(),
modeId: z.string().optional(),
model: z.string().optional(),
title: z
.string()
.trim()
.min(1)
.max(40)
.optional()
.nullable(),
approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(),
@@ -42,6 +42,7 @@ export function buildConfigOverrides(
cwd: record.cwd,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
title: record.title ?? undefined,
extra: record.config?.extra ?? undefined,
};
}
@@ -58,6 +59,7 @@ export function buildSessionConfig(
cwd: record.cwd,
modeId: overrides.modeId,
model: overrides.model,
title: overrides.title,
extra: overrides.extra,
};
}