From bf69ebeddff961aaf23473fd4fbf7116b333b52b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 26 May 2026 10:42:36 +0700 Subject: [PATCH] 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. --- packages/app/e2e/helpers/terminal-perf.ts | 4 + .../e2e/workspace-agent-title-handoff.spec.ts | 239 ++++++++++++++++++ .../src/server/agent/agent-manager.test.ts | 37 +-- .../server/src/server/agent/agent-manager.ts | 27 +- .../server/agent/agent-metadata-generator.ts | 2 +- .../agent-metadata-generator.unit.test.ts | 27 +- .../src/server/agent/agent-projections.ts | 5 +- .../server/src/server/agent/agent-prompt.ts | 3 +- .../src/server/agent/agent-storage.test.ts | 25 +- .../server/src/server/agent/agent-storage.ts | 24 +- .../src/server/persistence-hooks.test.ts | 4 - .../server/src/server/persistence-hooks.ts | 2 - packages/server/src/server/session.ts | 8 +- 13 files changed, 292 insertions(+), 115 deletions(-) create mode 100644 packages/app/e2e/workspace-agent-title-handoff.spec.ts diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index ca0d91436..9e191fcc5 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -30,6 +30,10 @@ export interface TerminalPerfDaemonClient { featureValues?: Record; initialPrompt?: string; }): Promise<{ id: string; status: string }>; + fetchAgents(options?: { scope?: "active" }): Promise<{ + entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>; + }>; + updateAgent(agentId: string, updates: { name?: string }): Promise; waitForAgentUpsert( agentId: string, predicate: (snapshot: { status: string }) => boolean, diff --git a/packages/app/e2e/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/workspace-agent-title-handoff.spec.ts new file mode 100644 index 000000000..5aa2352df --- /dev/null +++ b/packages/app/e2e/workspace-agent-title-handoff.spec.ts @@ -0,0 +1,239 @@ +import { test, expect, type Page } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { expectComposerVisible, submitMessage } from "./helpers/composer"; +import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { captureWsSessionFrames } from "./helpers/rename"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +interface WorkspaceTabProbeRecord { + at: number; + tabs: Array<{ + testId: string; + text: string; + ariaLabel: string; + }>; +} + +interface CapturedCreateAgentFrame { + initialPrompt: string | null; + configTitle: string | null; +} + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +async function installWorkspaceTabProbe(page: Page): Promise { + await page.evaluate(() => { + type ProbeRecord = WorkspaceTabProbeRecord; + type ProbeWindow = Window & { + __workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void }; + }; + + const win = window as ProbeWindow; + win.__workspaceTabTitleProbe?.stop(); + + const records: ProbeRecord[] = []; + const isVisible = (element: Element): element is HTMLElement => { + if (!(element instanceof HTMLElement)) { + return false; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" + ); + }; + const snapshot = () => { + records.push({ + at: performance.now(), + tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]')) + .filter(isVisible) + .map((element) => ({ + testId: element.getAttribute("data-testid") ?? "", + text: (element.textContent ?? "").replace(/\s+/g, " ").trim(), + ariaLabel: element.getAttribute("aria-label") ?? "", + })), + }); + }; + + snapshot(); + const observer = new MutationObserver(snapshot); + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["aria-label", "class", "data-testid", "style"], + }); + const interval = window.setInterval(snapshot, 20); + win.__workspaceTabTitleProbe = { + records, + stop: () => { + observer.disconnect(); + window.clearInterval(interval); + }, + }; + }); +} + +async function readWorkspaceTabProbe(page: Page): Promise { + return page.evaluate(() => { + type ProbeWindow = Window & { + __workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void }; + }; + const probe = (window as ProbeWindow).__workspaceTabTitleProbe; + probe?.stop(); + return probe?.records ?? []; + }); +} + +function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean { + return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label)); +} + +function createFrameStartsWithPrompt( + frame: CapturedCreateAgentFrame, + promptTitle: string, +): boolean { + return frame.initialPrompt?.startsWith(promptTitle) ?? false; +} + +function countCreateFramesForPrompt( + frames: CapturedCreateAgentFrame[], + promptTitle: string, +): number { + return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length; +} + +function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean { + return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`); +} + +function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean { + return record.tabs.some(tabHasLoadingTitle); +} + +async function waitForCreatedAgentId( + client: TerminalPerfDaemonClient, + cwd: string, +): Promise { + await expect + .poll( + async () => { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries + .filter((entry) => entry.agent.cwd === cwd) + .map((entry) => entry.agent.id); + }, + { timeout: 30_000 }, + ) + .toHaveLength(1); + const result = await client.fetchAgents({ scope: "active" }); + const agent = result.entries.find((entry) => entry.agent.cwd === cwd); + if (!agent) { + throw new Error(`Expected one created agent in ${cwd}`); + } + return agent.agent.id; +} + +async function fetchActiveAgentTitle( + client: TerminalPerfDaemonClient, + agentId: string, +): Promise { + const result = await client.fetchAgents({ scope: "active" }); + return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; +} + +async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise { + const promptTab = page.getByRole("button", { name: promptTitle }).first(); + await expect(promptTab).toBeVisible({ timeout: 15_000 }); + + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + await promptTab.click({ button: "right" }); + const renameAction = page.getByText("Rename", { exact: true }).first(); + if (await renameAction.isVisible().catch(() => false)) { + await page.keyboard.press("Escape"); + return; + } + await page.keyboard.press("Escape").catch(() => undefined); + await page.waitForTimeout(100); + } + + throw new Error("Prompt tab did not expose agent tab actions after create handoff"); +} + +test.describe("Workspace agent title handoff", () => { + test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({ + page, + }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 1440, height: 900 }); + + const client = await connectTerminalClient(); + const repo = await createTempGitRepo("workspace-title-handoff-"); + + try { + const opened = await client.openProject(repo.path); + if (!opened.workspace) { + throw new Error(opened.error ?? "Failed to open test workspace"); + } + + const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => { + const config = (inner.config ?? {}) as Record; + return { + initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null, + configTitle: typeof config.title === "string" ? config.title : null, + }; + }); + + await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id)); + await waitForWorkspaceTabsVisible(page); + await page.getByTestId("workspace-new-agent-tab").click(); + await expectComposerVisible(page); + + const promptTitle = "Investigate optimistic tab title handoff"; + const generatedTitle = "Generated Handoff Title"; + await installWorkspaceTabProbe(page); + await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`); + + const agentId = await waitForCreatedAgentId(client, repo.path); + await expect + .poll(() => countCreateFramesForPrompt(createFrames, promptTitle), { + timeout: 10_000, + }) + .toBe(1); + expect(createFrames.at(-1)).toEqual({ + initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`, + configTitle: null, + }); + + await waitForPromptTabAgentActions(page, promptTitle); + + await client.updateAgent(agentId, { name: generatedTitle }); + await expect + .poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 }) + .toBe(generatedTitle); + await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({ + timeout: 15_000, + }); + + const records = await readWorkspaceTabProbe(page); + expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true); + expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true); + expect(records.filter(recordHasLoadingTitle)).toEqual([]); + } finally { + await client.close().catch(() => undefined); + await repo.cleanup(); + } + }); +}); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 0bad9d636..1b1f7a793 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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'"); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c7fb4c633..6cf589f2a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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; persistSession?: boolean; + initialTitle?: string | null; }, ): Promise { 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 { + async setGeneratedTitle(agentId: string, title: string): Promise { 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 { 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 { 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( diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index ae687ab8f..a4d7c216f 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -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); } } } diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 2e075f675..d05053b62 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -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"] >; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 67b278e97..28eb35378 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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; } diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 82c85797a..dc07948e2 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -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({ diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 61e56e435..a70f4b3b2 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -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"); }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index c747395e5..8bb673890 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -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 { + async setGeneratedTitle(agentId: string, title: string): Promise { 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(), }; diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 129584b48..fe57d9082 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -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: { diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index ca10186de..fb679ab2b 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -68,7 +68,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial { 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);