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,
|
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);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
codex: new CodexAgentClient(),
|
codex: new CodexAgentClient(),
|
||||||
},
|
},
|
||||||
|
registry: agentRegistry,
|
||||||
});
|
});
|
||||||
|
|
||||||
attachAgentRegistryPersistence(agentManager, agentRegistry);
|
attachAgentRegistryPersistence(agentManager, agentRegistry);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user