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>;
|
featureValues?: Record<string, unknown>;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
}): Promise<{ id: string; status: 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(
|
waitForAgentUpsert(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
predicate: (snapshot: { status: string }) => boolean,
|
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));
|
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setGeneratedTitleIfUnset preserves an existing user title", async () => {
|
test("setGeneratedTitle persists generated title when no title exists", 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 () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -1636,13 +1611,13 @@ test("setGeneratedTitleIfUnset persists generated title when no title exists", a
|
|||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
|
await manager.setGeneratedTitle(snapshot.id, "Generated title");
|
||||||
|
|
||||||
const after = await storage.get(snapshot.id);
|
const after = await storage.get(snapshot.id);
|
||||||
expect(after?.title).toBe("Generated title");
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -1672,7 +1647,7 @@ test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
|
|||||||
{ agentId: snapshot.id, replayState: false },
|
{ agentId: snapshot.id, replayState: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
await manager.setGeneratedTitleIfUnset(snapshot.id, " ");
|
await manager.setGeneratedTitle(snapshot.id, " ");
|
||||||
|
|
||||||
const after = await storage.get(snapshot.id);
|
const after = await storage.get(snapshot.id);
|
||||||
expect(after?.title).toBeNull();
|
expect(after?.title).toBeNull();
|
||||||
@@ -1681,7 +1656,7 @@ test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
|
|||||||
expect(stateEvents).toEqual([]);
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -1694,7 +1669,7 @@ test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
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'");
|
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
|||||||
if (!record.config) {
|
if (!record.config) {
|
||||||
return 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.modeId != null) config.modeId = record.config.modeId;
|
||||||
if (record.config.model != null) config.model = record.config.model;
|
if (record.config.model != null) config.model = record.config.model;
|
||||||
if (record.config.thinkingOptionId != null) {
|
if (record.config.thinkingOptionId != null) {
|
||||||
@@ -793,6 +792,7 @@ export class AgentManager {
|
|||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
persistSession?: boolean;
|
persistSession?: boolean;
|
||||||
|
initialTitle?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
@@ -822,6 +822,7 @@ export class AgentManager {
|
|||||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||||
labels: options?.labels,
|
labels: options?.labels,
|
||||||
workspaceId: options?.workspaceId,
|
workspaceId: options?.workspaceId,
|
||||||
|
initialTitle: options?.initialTitle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,7 +1235,7 @@ export class AgentManager {
|
|||||||
this.emitState(agent, { persist: false });
|
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 agent = this.requireAgent(agentId);
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
if (!normalizedTitle) {
|
if (!normalizedTitle) {
|
||||||
@@ -1242,10 +1243,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registry = this.requireRegistry();
|
const registry = this.requireRegistry();
|
||||||
const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle);
|
const persisted = await registry.setGeneratedTitle(agent.id, normalizedTitle);
|
||||||
if (!persisted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.updatedAt = new Date(persisted.updatedAt);
|
agent.updatedAt = new Date(persisted.updatedAt);
|
||||||
this.emitState(agent, { persist: false });
|
this.emitState(agent, { persist: false });
|
||||||
@@ -2243,13 +2241,18 @@ export class AgentManager {
|
|||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
attention?: AttentionState;
|
attention?: AttentionState;
|
||||||
|
initialTitle?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||||
if (this.agents.has(resolvedAgentId)) {
|
if (this.agents.has(resolvedAgentId)) {
|
||||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
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 now = new Date();
|
||||||
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
||||||
@@ -2520,15 +2523,17 @@ export class AgentManager {
|
|||||||
private async resolveInitialPersistedTitle(
|
private async resolveInitialPersistedTitle(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
|
fallbackTitle: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const existing = await this.registry?.get(agentId);
|
const existing = await this.registry?.get(agentId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing.title ?? null;
|
return existing.title ?? null;
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
const explicitTitle =
|
||||||
return config.title ?? null;
|
typeof config.title === "string" && config.title.trim().length > 0
|
||||||
}
|
? config.title.trim()
|
||||||
return null;
|
: null;
|
||||||
|
return explicitTitle ?? fallbackTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSnapshot(
|
private async persistSnapshot(
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
if (needs.needsTitle && typeof result.title === "string") {
|
if (needs.needsTitle && typeof result.title === "string") {
|
||||||
const normalizedTitle = normalizeAutoTitle(result.title);
|
const normalizedTitle = normalizeAutoTitle(result.title);
|
||||||
if (normalizedTitle) {
|
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", () => {
|
describe("agent metadata generator auto-title", () => {
|
||||||
it("caps generated auto titles at 40 characters before persisting", async () => {
|
it("caps generated auto titles at 40 characters before persisting", async () => {
|
||||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
const manager = { setGeneratedTitle } as unknown as AgentManager;
|
||||||
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
|
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
@@ -56,16 +56,16 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
deps: createDeps(generateStructured),
|
deps: createDeps(generateStructured),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1);
|
expect(setGeneratedTitle).toHaveBeenCalledTimes(1);
|
||||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
expect(setGeneratedTitle).toHaveBeenCalledWith(
|
||||||
"agent-1",
|
"agent-1",
|
||||||
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
|
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not generate an auto title when an explicit title is provided", async () => {
|
it("does not generate an auto title when an explicit title is provided", async () => {
|
||||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
const manager = { setGeneratedTitle } as unknown as AgentManager;
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
>;
|
>;
|
||||||
@@ -81,12 +81,12 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(generateStructured).not.toHaveBeenCalled();
|
expect(generateStructured).not.toHaveBeenCalled();
|
||||||
expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled();
|
expect(setGeneratedTitle).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates titles independently from workspace branch naming", async () => {
|
it("generates titles independently from workspace branch naming", async () => {
|
||||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
const manager = { setGeneratedTitle } as unknown as AgentManager;
|
||||||
const generateStructured = vi
|
const generateStructured = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||||
@@ -111,10 +111,7 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
persistSession: false,
|
persistSession: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
expect(setGeneratedTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
|
||||||
"agent-suppressed-branch",
|
|
||||||
"Generated title",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -176,8 +173,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt:
|
|||||||
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
const manager = { setGeneratedTitle } as unknown as AgentManager;
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export function buildStoredAgentPayload(
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
persistence,
|
persistence,
|
||||||
title: record.title ?? record.config?.title ?? null,
|
title: record.title ?? null,
|
||||||
requiresAttention: record.requiresAttention ?? false,
|
requiresAttention: record.requiresAttention ?? false,
|
||||||
attentionReason: record.attentionReason ?? null,
|
attentionReason: record.attentionReason ?? null,
|
||||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||||
@@ -319,9 +319,6 @@ function normalizePromptPreview(text: string): string | null {
|
|||||||
|
|
||||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||||
const serializable: SerializableAgentConfig = {};
|
const serializable: SerializableAgentConfig = {};
|
||||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
|
||||||
serializable.title = config.title ?? null;
|
|
||||||
}
|
|
||||||
if (config.modeId) {
|
if (config.modeId) {
|
||||||
serializable.modeId = config.modeId;
|
serializable.modeId = config.modeId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
fired = true;
|
fired = true;
|
||||||
unsubscribe?.();
|
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}.`;
|
const body = `Agent ${childAgentId} (${title}) ${reason}.`;
|
||||||
|
|
||||||
await sendPromptToAgent({
|
await sendPromptToAgent({
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ describe("AgentStorage", () => {
|
|||||||
expect(records).toHaveLength(1);
|
expect(records).toHaveLength(1);
|
||||||
const [record] = records;
|
const [record] = records;
|
||||||
expect(record.provider).toBe("claude");
|
expect(record.provider).toBe("claude");
|
||||||
expect(record.config?.title).toBe("Initial title");
|
|
||||||
expect(record.config?.modeId).toBe("coding");
|
expect(record.config?.modeId).toBe("coding");
|
||||||
expect(record.config?.model).toBe("gpt-5.1");
|
expect(record.config?.model).toBe("gpt-5.1");
|
||||||
expect(record.config?.systemPrompt).toBe("Be terse and explicit.");
|
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 () => {
|
test("setGeneratedTitle with concurrent writes does not corrupt state", 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 () => {
|
|
||||||
const agentId = "agent-generated-title-concurrent";
|
const agentId = "agent-generated-title-concurrent";
|
||||||
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
storage.setGeneratedTitleIfUnset(agentId, "Title A"),
|
storage.setGeneratedTitle(agentId, "Title A"),
|
||||||
storage.setGeneratedTitleIfUnset(agentId, "Title B"),
|
storage.setGeneratedTitle(agentId, "Title B"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const record = await storage.get(agentId);
|
const record = await storage.get(agentId);
|
||||||
expect(["Title A", "Title B"]).toContain(record?.title);
|
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";
|
const agentId = "agent-generated-title-empty";
|
||||||
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
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);
|
const record = await storage.get(agentId);
|
||||||
expect(record?.title).toBe("Generated title");
|
expect(record?.title).toBe("Generated title");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
|||||||
|
|
||||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().nullable().optional(),
|
|
||||||
modeId: z.string().nullable().optional(),
|
modeId: z.string().nullable().optional(),
|
||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
@@ -68,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
|
|
||||||
export type SerializableAgentConfig = Pick<
|
export type SerializableAgentConfig = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
| "title"
|
|
||||||
| "modeId"
|
| "modeId"
|
||||||
| "model"
|
| "model"
|
||||||
| "thinkingOptionId"
|
| "thinkingOptionId"
|
||||||
@@ -232,33 +230,15 @@ export class AgentStorage {
|
|||||||
await this.upsert({ ...record, title });
|
await this.upsert({ ...record, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setGeneratedTitleIfUnset(
|
async setGeneratedTitle(agentId: string, title: string): Promise<StoredAgentRecord> {
|
||||||
agentId: string,
|
|
||||||
title: string,
|
|
||||||
): Promise<StoredAgentRecord | null> {
|
|
||||||
await this.load();
|
await this.load();
|
||||||
await this.waitForPendingWrite(agentId);
|
await this.waitForPendingWrite(agentId);
|
||||||
const record = this.cache.get(agentId) ?? null;
|
const record = this.cache.get(agentId) ?? null;
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw new Error(`Agent ${agentId} not found`);
|
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 = {
|
const nextRecord = {
|
||||||
...latestRecord,
|
...record,
|
||||||
title,
|
title,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ describe("persistence hooks", () => {
|
|||||||
const record = createRecord({
|
const record = createRecord({
|
||||||
title: "Voice agent (current)",
|
title: "Voice agent (current)",
|
||||||
config: {
|
config: {
|
||||||
title: "Voice agent (created)",
|
|
||||||
modeId: "default",
|
modeId: "default",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
thinkingOptionId: "minimal",
|
thinkingOptionId: "minimal",
|
||||||
@@ -51,7 +50,6 @@ describe("persistence hooks", () => {
|
|||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
thinkingOptionId: "minimal",
|
thinkingOptionId: "minimal",
|
||||||
title: "Voice agent (created)",
|
|
||||||
systemPrompt: "Use speak first.",
|
systemPrompt: "Use speak first.",
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
paseo: {
|
paseo: {
|
||||||
@@ -68,7 +66,6 @@ describe("persistence hooks", () => {
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
title: "Renamed title",
|
title: "Renamed title",
|
||||||
config: {
|
config: {
|
||||||
title: "Creation title",
|
|
||||||
modeId: "default",
|
modeId: "default",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
systemPrompt: "Confirm and speak first.",
|
systemPrompt: "Confirm and speak first.",
|
||||||
@@ -87,7 +84,6 @@ describe("persistence hooks", () => {
|
|||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
model: "gpt-5.4-mini",
|
model: "gpt-5.4-mini",
|
||||||
title: "Creation title",
|
|
||||||
systemPrompt: "Confirm and speak first.",
|
systemPrompt: "Confirm and speak first.",
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
paseo: {
|
paseo: {
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
|||||||
model: record.config?.model ?? undefined,
|
model: record.config?.model ?? undefined,
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||||
featureValues: record.config?.featureValues ?? undefined,
|
featureValues: record.config?.featureValues ?? undefined,
|
||||||
title: record.config?.title ?? undefined,
|
|
||||||
extra: record.config?.extra ?? undefined,
|
extra: record.config?.extra ?? undefined,
|
||||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||||
mcpServers: record.config?.mcpServers ?? undefined,
|
mcpServers: record.config?.mcpServers ?? undefined,
|
||||||
@@ -92,7 +91,6 @@ export function buildSessionConfig(
|
|||||||
model: overrides.model,
|
model: overrides.model,
|
||||||
thinkingOptionId: overrides.thinkingOptionId,
|
thinkingOptionId: overrides.thinkingOptionId,
|
||||||
featureValues: overrides.featureValues,
|
featureValues: overrides.featureValues,
|
||||||
title: overrides.title,
|
|
||||||
extra: overrides.extra,
|
extra: overrides.extra,
|
||||||
systemPrompt: overrides.systemPrompt,
|
systemPrompt: overrides.systemPrompt,
|
||||||
mcpServers: overrides.mcpServers,
|
mcpServers: overrides.mcpServers,
|
||||||
|
|||||||
@@ -1417,7 +1417,7 @@ export class Session {
|
|||||||
|
|
||||||
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||||||
const storedRecord = await this.agentStorage.get(agent.id);
|
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 payload = toAgentPayload(agent, { title });
|
||||||
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
|
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
|
||||||
if (storedUpdatedAt) {
|
if (storedUpdatedAt) {
|
||||||
@@ -3145,10 +3145,7 @@ export class Session {
|
|||||||
configTitle: config.title,
|
configTitle: config.title,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
});
|
});
|
||||||
const resolvedConfig: AgentSessionConfig = {
|
const resolvedConfig: AgentSessionConfig = config;
|
||||||
...config,
|
|
||||||
...(provisionalTitle ? { title: provisionalTitle } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstAgentContext: FirstAgentContext = {
|
const firstAgentContext: FirstAgentContext = {
|
||||||
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
||||||
@@ -3182,6 +3179,7 @@ export class Session {
|
|||||||
workspaceId: resolvedWorkspace.workspaceId,
|
workspaceId: resolvedWorkspace.workspaceId,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
env,
|
env,
|
||||||
|
initialTitle: provisionalTitle,
|
||||||
});
|
});
|
||||||
createdAgentId = snapshot.id;
|
createdAgentId = snapshot.id;
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
|
|||||||
Reference in New Issue
Block a user