Ensure agent titles persist synchronously during creation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -103,6 +103,7 @@ async function main() {
|
||||
}),
|
||||
codex: new CodexAgentClient(),
|
||||
},
|
||||
registry: agentRegistry,
|
||||
});
|
||||
|
||||
attachAgentRegistryPersistence(agentManager, agentRegistry);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user