Stop persisting agent prompt as the backend title

config.title is now strictly the caller's creation intent and is never
mutated. The prompt-derived placeholder lives only in record.title at
creation, so the metadata generator's upfront gate (hasExplicitTitle) is
the only one and writes unconditionally when it runs. Drops the two
projection fallbacks to record.config.title that were the second
hidden path for the prompt to surface as a title.
This commit is contained in:
Mohamed Boudra
2026-05-26 10:42:36 +07:00
parent 22e014aaff
commit bf69ebeddf
13 changed files with 292 additions and 115 deletions
@@ -1593,32 +1593,7 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
});
test("setGeneratedTitleIfUnset preserves an existing user title", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000128",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.setTitle(snapshot.id, "User title");
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("User title");
});
test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => {
test("setGeneratedTitle persists generated title when no title exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -1636,13 +1611,13 @@ test("setGeneratedTitleIfUnset persists generated title when no title exists", a
cwd: workdir,
});
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
await manager.setGeneratedTitle(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Generated title");
});
test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
test("setGeneratedTitle ignores blank generated titles", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -1672,7 +1647,7 @@ test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
{ agentId: snapshot.id, replayState: false },
);
await manager.setGeneratedTitleIfUnset(snapshot.id, " ");
await manager.setGeneratedTitle(snapshot.id, " ");
const after = await storage.get(snapshot.id);
expect(after?.title).toBeNull();
@@ -1681,7 +1656,7 @@ test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
expect(stateEvents).toEqual([]);
});
test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
test("setGeneratedTitle throws for an unknown agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -1694,7 +1669,7 @@ test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
});
await expect(
manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"),
manager.setGeneratedTitle("00000000-0000-4000-8000-000000000999", "Generated title"),
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
});
@@ -91,7 +91,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (!record.config) {
return config;
}
if (record.config.title != null) config.title = record.config.title;
if (record.config.modeId != null) config.modeId = record.config.modeId;
if (record.config.model != null) config.model = record.config.model;
if (record.config.thinkingOptionId != null) {
@@ -793,6 +792,7 @@ export class AgentManager {
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
initialTitle?: string | null;
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
@@ -822,6 +822,7 @@ export class AgentManager {
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
labels: options?.labels,
workspaceId: options?.workspaceId,
initialTitle: options?.initialTitle,
});
}
@@ -1234,7 +1235,7 @@ export class AgentManager {
this.emitState(agent, { persist: false });
}
async setGeneratedTitleIfUnset(agentId: string, title: string): Promise<void> {
async setGeneratedTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
if (!normalizedTitle) {
@@ -1242,10 +1243,7 @@ export class AgentManager {
}
const registry = this.requireRegistry();
const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle);
if (!persisted) {
return;
}
const persisted = await registry.setGeneratedTitle(agent.id, normalizedTitle);
agent.updatedAt = new Date(persisted.updatedAt);
this.emitState(agent, { persist: false });
@@ -2243,13 +2241,18 @@ export class AgentManager {
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
initialTitle?: string | null;
},
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config,
options?.initialTitle ?? null,
);
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
@@ -2520,15 +2523,17 @@ export class AgentManager {
private async resolveInitialPersistedTitle(
agentId: string,
config: AgentSessionConfig,
fallbackTitle: string | null,
): Promise<string | null> {
const existing = await this.registry?.get(agentId);
if (existing) {
return existing.title ?? null;
}
if (Object.prototype.hasOwnProperty.call(config, "title")) {
return config.title ?? null;
}
return null;
const explicitTitle =
typeof config.title === "string" && config.title.trim().length > 0
? config.title.trim()
: null;
return explicitTitle ?? fallbackTitle;
}
private async persistSnapshot(
@@ -152,7 +152,7 @@ export async function generateAndApplyAgentMetadata(
if (needs.needsTitle && typeof result.title === "string") {
const normalizedTitle = normalizeAutoTitle(result.title);
if (normalizedTitle) {
await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle);
await options.agentManager.setGeneratedTitle(options.agentId, normalizedTitle);
}
}
}
@@ -39,8 +39,8 @@ function createDeps(
describe("agent metadata generator auto-title", () => {
it("caps generated auto titles at 40 characters before persisting", async () => {
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitle } as unknown as AgentManager;
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
@@ -56,16 +56,16 @@ describe("agent metadata generator auto-title", () => {
deps: createDeps(generateStructured),
});
expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1);
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
expect(setGeneratedTitle).toHaveBeenCalledTimes(1);
expect(setGeneratedTitle).toHaveBeenCalledWith(
"agent-1",
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
);
});
it("does not generate an auto title when an explicit title is provided", async () => {
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitle } as unknown as AgentManager;
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
@@ -81,12 +81,12 @@ describe("agent metadata generator auto-title", () => {
});
expect(generateStructured).not.toHaveBeenCalled();
expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled();
expect(setGeneratedTitle).not.toHaveBeenCalled();
});
it("generates titles independently from workspace branch naming", async () => {
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitle } as unknown as AgentManager;
const generateStructured = vi
.fn()
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
@@ -111,10 +111,7 @@ describe("agent metadata generator auto-title", () => {
persistSession: false,
}),
);
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
"agent-suppressed-branch",
"Generated title",
);
expect(setGeneratedTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
});
it.each([
@@ -176,8 +173,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt:
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitle } as unknown as AgentManager;
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
@@ -229,7 +229,7 @@ export function buildStoredAgentPayload(
availableModes: [],
pendingPermissions: [],
persistence,
title: record.title ?? record.config?.title ?? null,
title: record.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
@@ -319,9 +319,6 @@ function normalizePromptPreview(text: string): string | null {
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (Object.prototype.hasOwnProperty.call(config, "title")) {
serializable.title = config.title ?? null;
}
if (config.modeId) {
serializable.modeId = config.modeId;
}
@@ -205,7 +205,8 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
fired = true;
unsubscribe?.();
const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId;
const record = await agentStorage.get(childAgentId);
const title = record?.title ?? childAgentId;
const body = `Agent ${childAgentId} (${title}) ${reason}.`;
await sendPromptToAgent({
@@ -173,7 +173,6 @@ describe("AgentStorage", () => {
expect(records).toHaveLength(1);
const [record] = records;
expect(record.provider).toBe("claude");
expect(record.config?.title).toBe("Initial title");
expect(record.config?.modeId).toBe("coding");
expect(record.config?.model).toBe("gpt-5.1");
expect(record.config?.systemPrompt).toBe("Be terse and explicit.");
@@ -321,38 +320,26 @@ describe("AgentStorage", () => {
);
});
test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => {
const agentId = "agent-generated-title-race";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
await storage.setTitle(agentId, "User title");
const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
expect(result).toBeNull();
const record = await storage.get(agentId);
expect(record?.title).toBe("User title");
});
test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => {
test("setGeneratedTitle with concurrent writes does not corrupt state", async () => {
const agentId = "agent-generated-title-concurrent";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
await Promise.all([
storage.setGeneratedTitleIfUnset(agentId, "Title A"),
storage.setGeneratedTitleIfUnset(agentId, "Title B"),
storage.setGeneratedTitle(agentId, "Title A"),
storage.setGeneratedTitle(agentId, "Title B"),
]);
const record = await storage.get(agentId);
expect(["Title A", "Title B"]).toContain(record?.title);
});
test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => {
test("setGeneratedTitle writes the generated title", async () => {
const agentId = "agent-generated-title-empty";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
const written = await storage.setGeneratedTitle(agentId, "Generated title");
expect(written?.title).toBe("Generated title");
expect(written.title).toBe("Generated title");
const record = await storage.get(agentId);
expect(record?.title).toBe("Generated title");
});
@@ -11,7 +11,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
title: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
@@ -68,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick<
AgentSessionConfig,
| "title"
| "modeId"
| "model"
| "thinkingOptionId"
@@ -232,33 +230,15 @@ export class AgentStorage {
await this.upsert({ ...record, title });
}
async setGeneratedTitleIfUnset(
agentId: string,
title: string,
): Promise<StoredAgentRecord | null> {
async setGeneratedTitle(agentId: string, title: string): Promise<StoredAgentRecord> {
await this.load();
await this.waitForPendingWrite(agentId);
const record = this.cache.get(agentId) ?? null;
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
if (record.title) {
return null;
}
// Re-drain pending writes: a concurrent setTitle may have queued between the
// first drain and here. After waiting, re-read the cache before writing.
await this.waitForPendingWrite(agentId);
const latestRecord = this.cache.get(agentId) ?? null;
if (!latestRecord) {
throw new Error(`Agent ${agentId} not found`);
}
if (latestRecord.title) {
return null;
}
const nextRecord = {
...latestRecord,
...record,
title,
updatedAt: new Date().toISOString(),
};
@@ -31,7 +31,6 @@ describe("persistence hooks", () => {
const record = createRecord({
title: "Voice agent (current)",
config: {
title: "Voice agent (created)",
modeId: "default",
model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
@@ -51,7 +50,6 @@ describe("persistence hooks", () => {
modeId: "plan",
model: "gpt-5.4-mini",
thinkingOptionId: "minimal",
title: "Voice agent (created)",
systemPrompt: "Use speak first.",
mcpServers: {
paseo: {
@@ -68,7 +66,6 @@ describe("persistence hooks", () => {
provider: "codex",
title: "Renamed title",
config: {
title: "Creation title",
modeId: "default",
model: "gpt-5.4-mini",
systemPrompt: "Confirm and speak first.",
@@ -87,7 +84,6 @@ describe("persistence hooks", () => {
cwd: "/tmp/project",
modeId: "plan",
model: "gpt-5.4-mini",
title: "Creation title",
systemPrompt: "Confirm and speak first.",
mcpServers: {
paseo: {
@@ -68,7 +68,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
title: record.config?.title ?? undefined,
extra: record.config?.extra ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
mcpServers: record.config?.mcpServers ?? undefined,
@@ -92,7 +91,6 @@ export function buildSessionConfig(
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
title: overrides.title,
extra: overrides.extra,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
+3 -5
View File
@@ -1417,7 +1417,7 @@ export class Session {
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
const storedRecord = await this.agentStorage.get(agent.id);
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;
const title = storedRecord?.title ?? null;
const payload = toAgentPayload(agent, { title });
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
if (storedUpdatedAt) {
@@ -3145,10 +3145,7 @@ export class Session {
configTitle: config.title,
initialPrompt: trimmedPrompt,
});
const resolvedConfig: AgentSessionConfig = {
...config,
...(provisionalTitle ? { title: provisionalTitle } : {}),
};
const resolvedConfig: AgentSessionConfig = config;
const firstAgentContext: FirstAgentContext = {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
@@ -3182,6 +3179,7 @@ export class Session {
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
env,
initialTitle: provisionalTitle,
});
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);