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:
@@ -30,6 +30,10 @@ export interface TerminalPerfDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
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<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
|
||||
@@ -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<void> {
|
||||
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<WorkspaceTabProbeRecord[]> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<string, unknown>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user