Name agents by their first prompt line instead of an LLM summary (#1563)

* Name agents by their first prompt line instead of an LLM summary

The workspace title already captures the task; the LLM-generated agent
title just restated the same prompt. Drop the generator and keep the
provisional title (first prompt line), so the agent label stays
deterministic and explicit titles still win.

* Remove the no-op agent-title setting and leftover generator plumbing

Deleting the agent-title generator left its whole surface dangling: a
configurable "Agent titles" project setting that no longer did anything,
dead create-path plumbing, generator-only import/MCP deps, and the mock
provider's agent-title parser. Remove all of it, plus a dead test mock
and timing sleeps that guarded the deleted background job. Replace them
with a no-mock lifecycle test asserting the title stays the first prompt
line across create and settle.

Old paseo.json files with metadataGeneration.agentTitle still parse via
schema passthrough — see COMPAT(projectMetadataAgentTitle).
This commit is contained in:
Mohamed Boudra
2026-06-17 01:12:23 +08:00
committed by GitHub
parent e202ca5036
commit fcb7f8e215
31 changed files with 141 additions and 1035 deletions

View File

@@ -1846,8 +1846,6 @@ export const ar: TranslationResources = {
metadata: {
title: "توليد البيانات الوصفية",
info: "تعليمات خاصة بالمشروع يتم إدخالها في الذكاء الاصطناعي الذي يستخدمه Paseo لإنشاء بيانات التعريف - استخدمها لفرض اصطلاحات فريقك مثل تسمية الفرع أو نمط الالتزام أو تنسيق PR",
agentTitle: "عناوين Agent",
agentTitlePlaceholder: "اجعل العناوين ضرورية وأقل من 40 حرفًا",
branchName: "اسماء الفروع",
branchNamePlaceholder: "بادئة الفروع بـ fet/ أو Fix/, mb/ للفروع الشخصية",
commitMessage: "ارتكاب الرسائل",

View File

@@ -1854,8 +1854,6 @@ export const en = {
metadata: {
title: "Metadata generation",
info: "Project-specific instructions injected into the AI prompts Paseo uses to generate metadata - use them to enforce your team's conventions like branch naming, commit style, or PR format",
agentTitle: "Agent titles",
agentTitlePlaceholder: "Keep titles imperative and under 40 characters",
branchName: "Branch names",
branchNamePlaceholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
commitMessage: "Commit messages",

View File

@@ -1887,8 +1887,6 @@ export const es: TranslationResources = {
metadata: {
title: "Generación de metadatos",
info: "Instrucciones específicas del proyecto inyectadas en los mensajes de IA quePaseoutiliza para generar metadatos; úselas para hacer cumplir las convenciones de su equipo, como la denominación de ramas, el estilo de confirmación o el formatoPR.",
agentTitle: "TítulosAgent",
agentTitlePlaceholder: "Mantenga los títulos imperativos y de menos de 40 caracteres",
branchName: "Nombres de sucursales",
branchNamePlaceholder: "Prefijo ramas con feat/ o fix/, mb/ para ramas personales",
commitMessage: "Confirmar mensajes",

View File

@@ -1893,8 +1893,6 @@ export const fr: TranslationResources = {
metadata: {
title: "Génération de métadonnées",
info: "Instructions spécifiques au projet injectées dans les invites de l'IA quePaseoutilise pour générer des métadonnées: utilisez-les pour appliquer les conventions de votre équipe telles que la dénomination des branches, le style de validation ou le formatPR.",
agentTitle: "TitresAgent",
agentTitlePlaceholder: "Gardez les titres impératifs et inférieurs à 40 caractères",
branchName: "Noms des succursales",
branchNamePlaceholder:
"Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles",

View File

@@ -1879,8 +1879,6 @@ export const ru: TranslationResources = {
metadata: {
title: "Генерация метаданных",
info: "Инструкции для конкретного проекта, внедренные в подсказки ИИ, которые Paseo использует для генерации метаданных. Используйте их для обеспечения соблюдения соглашений вашей команды, таких как наименование ветвей, стиль фиксации или формат PR.",
agentTitle: "Agent заголовки",
agentTitlePlaceholder: "Сохраняйте заголовки обязательными и длиной не более 40 символов.",
branchName: "Названия ветвей",
branchNamePlaceholder: "Префиксные ветки с feat/ или fix/, mb/ для личных веток",
commitMessage: "Фиксировать сообщения",

View File

@@ -1823,8 +1823,6 @@ export const zhCN: TranslationResources = {
metadata: {
title: "元数据生成",
info: "注入到 Paseo 用来生成元数据的 AI prompts 中的 Project 专属指令,可用于强制执行团队约定,例如分支命名、提交风格或 PR 格式",
agentTitle: "Agent 标题",
agentTitlePlaceholder: "标题保持祈使句且不超过 40 个字符",
branchName: "分支名称",
branchNamePlaceholder: "分支以 feat/ 或 fix/ 开头,个人分支使用 mb/",
commitMessage: "提交消息",

View File

@@ -58,12 +58,6 @@ interface MetadataPromptField {
}
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
agentTitle: {
titleKey: "settings.project.metadata.agentTitle",
placeholderKey: "settings.project.metadata.agentTitlePlaceholder",
sectionTestID: "metadata-prompt-agent-title-section",
inputTestID: "metadata-prompt-agent-title-input",
},
branchName: {
titleKey: "settings.project.metadata.branchName",
placeholderKey: "settings.project.metadata.branchNamePlaceholder",

View File

@@ -11,7 +11,6 @@ function emptyDraft(): ProjectConfigDraft {
teardownOriginalKind: "missing",
scripts: [],
metadataPrompts: {
agentTitle: "",
branchName: "",
commitMessage: "",
pullRequest: "",
@@ -217,17 +216,15 @@ describe("applyDraftToConfig", () => {
expect(tunnel.port).toBe("auto");
});
it("reads metadata prompt instructions for all four keys", () => {
it("reads metadata prompt instructions for visible keys", () => {
const draft = configToDraft({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
branchName: { instructions: "feat/<slug>" },
commitMessage: { instructions: "Conventional commits." },
pullRequest: { instructions: "Include risk notes." },
},
});
expect(draft.metadataPrompts).toEqual({
agentTitle: "Use mb/.",
branchName: "feat/<slug>",
commitMessage: "Conventional commits.",
pullRequest: "Include risk notes.",
@@ -236,11 +233,27 @@ describe("applyDraftToConfig", () => {
it("defaults metadata prompts to empty strings when not present", () => {
const draft = configToDraft({
metadataGeneration: { agentTitle: { instructions: "Use mb/." } },
metadataGeneration: { branchName: { instructions: "feat/<slug>" } },
});
expect(draft.metadataPrompts).toEqual({
agentTitle: "Use mb/.",
branchName: "",
branchName: "feat/<slug>",
commitMessage: "",
pullRequest: "",
});
});
it("does not expose legacy agentTitle as a metadata prompt", () => {
const draft = configToDraft(
PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
branchName: { instructions: "feat/<slug>" },
},
}),
);
expect(draft.metadataPrompts).toEqual({
branchName: "feat/<slug>",
commitMessage: "",
pullRequest: "",
});
@@ -249,11 +262,11 @@ describe("applyDraftToConfig", () => {
it("writes only metadata prompt entries with non-empty text", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Use mb/.";
draft.metadataPrompts.branchName = "Use mb/.";
draft.metadataPrompts.commitMessage = "Conventional commits.";
const next = applyDraftToConfig({ draft, base });
expect(next.metadataGeneration).toEqual({
agentTitle: { instructions: "Use mb/." },
branchName: { instructions: "Use mb/." },
commitMessage: { instructions: "Conventional commits." },
});
});
@@ -261,16 +274,16 @@ describe("applyDraftToConfig", () => {
it("drops the metadataGeneration field when all prompts are empty", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
branchName: { instructions: "Use mb/." },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "";
draft.metadataPrompts.branchName = "";
const next = applyDraftToConfig({ draft, base });
expect(next.metadataGeneration).toBeUndefined();
});
it("preserves unknown sibling fields inside metadataGeneration on round-trip", () => {
it("preserves legacy and unknown sibling fields inside metadataGeneration on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
@@ -278,37 +291,38 @@ describe("applyDraftToConfig", () => {
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Use prefix mb/ on titles.";
draft.metadataPrompts.branchName = "Use prefix mb/ on branches.";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).toEqual({ instructions: "Use prefix mb/ on titles." });
expect(metadata.agentTitle).toEqual({ instructions: "Use mb/." });
expect(metadata.branchName).toEqual({ instructions: "Use prefix mb/ on branches." });
expect(metadata.futureField).toBe(42);
});
it("preserves unknown fields inside a metadata prompt entry on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/.", model: "haiku" },
branchName: { instructions: "Use mb/.", model: "haiku" },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Updated.";
draft.metadataPrompts.branchName = "Updated.";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).toEqual({ instructions: "Updated.", model: "haiku" });
expect(metadata.branchName).toEqual({ instructions: "Updated.", model: "haiku" });
});
it("clears instructions but preserves unknown sibling fields when text becomes empty", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/.", model: "haiku" },
branchName: { instructions: "Use mb/.", model: "haiku" },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "";
draft.metadataPrompts.branchName = "";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).toEqual({ model: "haiku" });
expect(metadata.branchName).toEqual({ model: "haiku" });
});
it("drops scripts with an empty name and removes scripts no longer present in the draft", () => {

View File

@@ -7,12 +7,7 @@ import type {
export type LifecycleOriginalKind = "string" | "array" | "missing";
export const METADATA_PROMPT_KEYS = [
"agentTitle",
"branchName",
"commitMessage",
"pullRequest",
] as const;
export const METADATA_PROMPT_KEYS = ["branchName", "commitMessage", "pullRequest"] as const;
export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number];
export interface ProjectScriptDraft {
@@ -105,7 +100,6 @@ function nextScriptDraftId(): string {
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
return {
agentTitle: "",
branchName: "",
commitMessage: "",
pullRequest: "",

View File

@@ -1,2 +1 @@
export const MAX_EXPLICIT_AGENT_TITLE_CHARS = 200;
export const MAX_AUTO_AGENT_TITLE_CHARS = 40;

View File

@@ -37,7 +37,6 @@ describe("paseo config schema", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
@@ -45,7 +44,6 @@ describe("paseo config schema", () => {
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
@@ -56,29 +54,40 @@ describe("paseo config schema", () => {
it("parses partial metadata generation instructions with missing entries undefined", () => {
const parsed = PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Keep it short." },
branchName: { instructions: "Keep it short." },
},
});
expect(parsed.metadataGeneration).toEqual({
agentTitle: { instructions: "Keep it short." },
branchName: { instructions: "Keep it short." },
});
expect(parsed.metadataGeneration?.branchName).toBeUndefined();
expect(parsed.metadataGeneration?.commitMessage).toBeUndefined();
expect(parsed.metadataGeneration?.pullRequest).toBeUndefined();
});
it("preserves legacy agentTitle metadata instructions as passthrough", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
},
});
});
it("passes through unknown metadata generation fields", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
});
@@ -88,7 +97,7 @@ describe("paseo config schema", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: {
branchName: {
instructions: "Use concise titles.",
model: "haiku",
},
@@ -96,7 +105,7 @@ describe("paseo config schema", () => {
}),
).toEqual({
metadataGeneration: {
agentTitle: {
branchName: {
instructions: "Use concise titles.",
model: "haiku",
},
@@ -108,17 +117,17 @@ describe("paseo config schema", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
branchName: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
branchName: {},
},
});
});
it("raw schema preserves old-style config while accepting metadata generation", () => {
it("raw schema preserves old-style config while accepting legacy agentTitle", () => {
const config = {
worktree: {
setup: "npm install",
@@ -132,6 +141,7 @@ describe("paseo config schema", () => {
},
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Use concise branches." },
},
};
@@ -142,12 +152,12 @@ describe("paseo config schema", () => {
expect(
PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
branchName: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
branchName: {},
},
});
});

View File

@@ -39,11 +39,12 @@ export const PaseoMetadataGenerationEntrySchema = z
export const PaseoMetadataGenerationSchema = z
.object({
agentTitle: PaseoMetadataGenerationEntrySchema.optional(),
branchName: PaseoMetadataGenerationEntrySchema.optional(),
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
})
// COMPAT(projectMetadataAgentTitle): `agentTitle` project metadata prompts were removed
// in v0.1.96; keep legacy paseo.json parseable until 2026-12-16.
.passthrough()
.catch({});

View File

@@ -1862,86 +1862,6 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
});
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);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000129",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.setGeneratedTitle(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Generated title");
});
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);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000130",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
const stateEvents: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state") {
stateEvents.push(event.agent);
}
},
{ agentId: snapshot.id, replayState: false },
);
await manager.setGeneratedTitle(snapshot.id, " ");
const after = await storage.get(snapshot.id);
expect(after?.title).toBeNull();
expect(after?.updatedAt).toBe(before?.updatedAt);
expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt);
expect(stateEvents).toEqual([]);
});
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);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
await expect(
manager.setGeneratedTitle("00000000-0000-4000-8000-000000000999", "Generated title"),
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
});
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
const storagePath = join(workdir, "agents");

View File

@@ -1331,20 +1331,6 @@ export class AgentManager {
this.emitState(agent, { persist: false });
}
async setGeneratedTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
const registry = this.requireRegistry();
const persisted = await registry.setGeneratedTitle(agent.id, normalizedTitle);
agent.updatedAt = new Date(persisted.updatedAt);
this.emitState(agent, { persist: false });
}
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };

View File

@@ -1,234 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, readdirSync, rmSync, statSync, realpathSync } from "fs";
import { homedir, tmpdir } from "os";
import path from "path";
import pino from "pino";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { shutdownProviders } from "./provider-registry.js";
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import {
canRunRealProvider,
createRealProviderClients,
getRealProviderConfig,
} from "../daemon-e2e/real-provider-test-config.js";
const CODEX_TEST_MODEL = getRealProviderConfig("codex").model;
const CODEX_TEST_THINKING_OPTION_ID = getRealProviderConfig("codex").thinkingOptionId;
const CLAUDE_TEST_MODEL = getRealProviderConfig("claude").model;
const OPENCODE_TEST_MODEL = getRealProviderConfig("opencode").model;
function collectFilesRecursively(root: string, filter: (name: string) => boolean): Set<string> {
const results = new Set<string>();
try {
statSync(root);
} catch {
return results;
}
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop()!;
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile() && filter(entry.name)) {
results.add(full);
}
}
}
return results;
}
function collectCodexRolloutFiles(): Set<string> {
const codexHome = process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
return collectFilesRecursively(path.join(codexHome, "sessions"), (name) =>
name.startsWith("rollout-"),
);
}
function encodeClaudeProjectDir(cwd: string): string {
return cwd.replaceAll("/", "-");
}
function collectClaudeProjectFiles(cwd: string): Set<string> {
const dir = path.join(homedir(), ".claude", "projects", encodeClaudeProjectDir(cwd));
return collectFilesRecursively(dir, (name) => name.endsWith(".jsonl"));
}
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
}
describe("agent metadata generation (real agents)", () => {
const logger = pino({ level: "silent" });
let cwd: string;
let paseoHome: string;
let manager: AgentManager;
let storage: AgentStorage;
let codexAvailable = false;
let claudeAvailable = false;
let opencodeAvailable = false;
beforeAll(async () => {
[codexAvailable, claudeAvailable, opencodeAvailable] = await Promise.all([
canRunRealProvider("codex"),
canRunRealProvider("claude"),
canRunRealProvider("opencode"),
]);
});
beforeEach(() => {
cwd = tmpCwd("metadata-cwd-");
paseoHome = tmpCwd("metadata-paseo-home-");
storage = new AgentStorage(path.join(paseoHome, "agents"), logger);
manager = new AgentManager({
clients: createRealProviderClients(["codex", "claude", "opencode"], logger),
registry: storage,
logger,
});
});
afterEach(async () => {
await shutdownProviders(logger);
rmSync(cwd, { recursive: true, force: true });
rmSync(paseoHome, { recursive: true, force: true });
}, 60000);
test("generates a title using a real Codex agent without persisting a rollout", async (ctx) => {
if (!codexAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "codex",
model: CODEX_TEST_MODEL,
...(CODEX_TEST_THINKING_OPTION_ID
? { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID }
: {}),
modeId: "auto",
cwd: cwd,
title: "Main Agent",
},
"4e0a4508-e522-4fe9-8384-cf3bf889f16d",
);
const rolloutsBefore = collectCodexRolloutFiles();
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Metadata Title E2E");
const rolloutsAfter = collectCodexRolloutFiles();
const newRollouts = [...rolloutsAfter].filter((file) => !rolloutsBefore.has(file));
expect(newRollouts).toEqual([]);
await manager.closeAgent(agent.id);
}, 180000);
test("generates a title using a real Claude agent without persisting a session", async (ctx) => {
if (!claudeAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "claude",
model: CLAUDE_TEST_MODEL,
thinkingOptionId: "on",
cwd: cwd,
title: "Main Claude Agent",
},
"5e1b5619-f633-5fea-9495-d04bf990f27e",
);
const sessionsBefore = collectClaudeProjectFiles(cwd);
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'Claude Metadata Title'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("Claude Metadata Title");
const sessionsAfter = collectClaudeProjectFiles(cwd);
const newSessions = [...sessionsAfter].filter((file) => !sessionsBefore.has(file));
expect(newSessions).toEqual([]);
await manager.closeAgent(agent.id);
}, 180000);
test("generates a title using a real OpenCode agent and deletes the ephemeral session", async (ctx) => {
if (!opencodeAvailable) {
ctx.skip();
}
const agent = await manager.createAgent(
{
provider: "opencode",
model: OPENCODE_TEST_MODEL,
modeId: "build",
cwd: cwd,
title: "Main OpenCode Agent",
},
"6e2c6720-e744-6fdb-a5a6-e15cf0a1f380",
);
const acquisition = await OpenCodeServerManager.getInstance(logger).acquire({ force: false });
const inspectClient = createOpencodeClient({
baseUrl: acquisition.server.url,
directory: cwd,
});
try {
const sessionsBeforeRes = await inspectClient.session.list({ directory: cwd });
const sessionIdsBefore = new Set((sessionsBeforeRes.data ?? []).map((session) => session.id));
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: agent.id,
cwd: cwd,
initialPrompt: "Use the exact title 'OpenCode Metadata Title'.",
explicitTitle: null,
paseoHome,
logger,
});
await storage.flush();
const record = await storage.get(agent.id);
expect(record?.title).toBe("OpenCode Metadata Title");
const sessionsAfterRes = await inspectClient.session.list({ directory: cwd });
const newSessions = (sessionsAfterRes.data ?? []).filter(
(session) => !sessionIdsBefore.has(session.id),
);
expect(newSessions).toEqual([]);
} finally {
acquisition.release();
await manager.closeAgent(agent.id);
}
}, 180000);
});

View File

@@ -1,188 +0,0 @@
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentManager } from "./agent-manager.js";
import {
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent-response-loop.js";
import {
resolveStructuredGenerationProviders,
type StructuredGenerationDaemonConfig,
} from "./structured-generation-providers.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
export interface AgentMetadataGeneratorDeps {
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
}
export interface AgentMetadataGenerationOptions {
agentManager: AgentManager;
agentId: string;
cwd: string;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
daemonConfig?: StructuredGenerationDaemonConfig | null;
currentSelection?: {
provider?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
};
initialPrompt?: string | null;
explicitTitle?: string | null;
paseoHome?: string;
logger: Logger;
deps?: AgentMetadataGeneratorDeps;
}
interface AgentMetadataNeeds {
prompt: string | null;
needsTitle: boolean;
}
function hasExplicitTitle(title?: string | null): boolean {
return Boolean(title && title.trim().length > 0);
}
function normalizeAutoTitle(title: string): string | null {
const normalized = title.trim();
if (!normalized) {
return null;
}
return normalized.slice(0, MAX_AUTO_AGENT_TITLE_CHARS).trim() || null;
}
export async function determineAgentMetadataNeeds(
options: Pick<
AgentMetadataGenerationOptions,
"initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps"
>,
): Promise<AgentMetadataNeeds> {
const prompt = options.initialPrompt?.trim();
if (!prompt) {
return { prompt: null, needsTitle: false };
}
const needsTitle = !hasExplicitTitle(options.explicitTitle);
return {
prompt,
needsTitle,
};
}
function buildMetadataSchema(
needs: AgentMetadataNeeds,
): z.ZodObject<Record<string, z.ZodTypeAny>> | null {
if (!needs.needsTitle) {
return null;
}
const shape: Record<string, z.ZodTypeAny> = {};
if (needs.needsTitle) {
shape.title = z.string().min(1).max(MAX_AUTO_AGENT_TITLE_CHARS);
}
return z.object(shape);
}
async function buildPrompt(
needs: AgentMetadataNeeds,
options: {
cwd: string;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
},
): Promise<string> {
const beforeLines: string[] = ["Generate metadata for a coding agent based on the user prompt."];
if (needs.needsTitle) {
beforeLines.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
}
return buildMetadataPrompt({
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
configKey: "agentTitle",
before: beforeLines.join("\n"),
after: "Return JSON only with a single field 'title'.",
trailing: `User prompt:\n${needs.prompt ?? ""}`,
});
}
export async function generateAndApplyAgentMetadata(
options: AgentMetadataGenerationOptions,
): Promise<void> {
const needs = await determineAgentMetadataNeeds(options);
if (!needs.prompt) {
return;
}
const schema = buildMetadataSchema(needs);
if (!schema) {
return;
}
const generator =
options.deps?.generateStructuredAgentResponseWithFallback ??
generateStructuredAgentResponseWithFallback;
let result: { title?: string };
try {
const providers = options.providerSnapshotManager
? await resolveStructuredGenerationProviders({
cwd: options.cwd,
providerSnapshotManager: options.providerSnapshotManager,
daemonConfig: options.daemonConfig,
currentSelection: options.currentSelection,
})
: [];
result = await generator({
manager: options.agentManager,
cwd: options.cwd,
prompt: await buildPrompt(needs, {
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
}),
schema,
schemaName: "AgentMetadata",
maxRetries: 2,
providers,
persistSession: false,
logger: options.logger,
agentConfigOverrides: {
title: "Agent metadata generator",
internal: true,
},
});
} catch (error) {
const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
options.logger.error(
{ err: error, agentId: options.agentId, attempts },
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
? "Structured metadata generation failed"
: "Agent metadata generation failed",
);
return;
}
if (needs.needsTitle && typeof result.title === "string") {
const normalizedTitle = normalizeAutoTitle(result.title);
if (normalizedTitle) {
await options.agentManager.setGeneratedTitle(options.agentId, normalizedTitle);
}
}
}
export function scheduleAgentMetadataGeneration(options: AgentMetadataGenerationOptions): void {
queueMicrotask(() => {
void generateAndApplyAgentMetadata(options).catch((error) => {
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation crashed",
);
});
});
}

View File

@@ -1,198 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
import {
generateAndApplyAgentMetadata,
type AgentMetadataGeneratorDeps,
} from "./agent-metadata-generator.js";
import type { AgentManager } from "./agent-manager.js";
const logger = createTestLogger();
const cleanupPaths: string[] = [];
const PRE_CHANGE_TITLE_PROMPT = `Generate metadata for a coding agent based on the user prompt.
Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).
Return JSON only with a single field 'title'.
User prompt:
Implement this feature`;
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
function createDeps(
generateStructuredAgentResponseWithFallback: NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>,
): AgentMetadataGeneratorDeps {
return {
generateStructuredAgentResponseWithFallback,
};
}
describe("agent metadata generator auto-title", () => {
it("caps generated auto titles at 40 characters before persisting", async () => {
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"]
>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-1",
cwd: "/tmp/repo",
initialPrompt: "Implement this feature",
explicitTitle: null,
logger,
deps: createDeps(generateStructured),
});
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 setGeneratedTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitle } as unknown as AgentManager;
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-2",
cwd: "/tmp/repo",
initialPrompt: "Implement this feature",
explicitTitle: "Keep this title",
logger,
deps: createDeps(generateStructured),
});
expect(generateStructured).not.toHaveBeenCalled();
expect(setGeneratedTitle).not.toHaveBeenCalled();
});
it("generates titles independently from workspace branch naming", async () => {
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"]
>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-suppressed-branch",
cwd: "/tmp/repo/metadata-worktree",
initialPrompt: "Implement this feature",
explicitTitle: null,
logger,
deps: {
generateStructuredAgentResponseWithFallback: generateStructured,
},
});
expect(generateStructured).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/tmp/repo/metadata-worktree",
persistSession: false,
}),
);
expect(setGeneratedTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
});
it.each([
["paseo.json missing", undefined],
["paseo.json exists but invalid JSON", "{ nope"],
["paseo.json valid but missing metadataGeneration", {}],
[
"metadataGeneration exists but missing agentTitle",
{ metadataGeneration: { branchName: { instructions: "Use mb/." } } },
],
["agentTitle exists but instructions is undefined", { metadataGeneration: { agentTitle: {} } }],
[
"agentTitle exists but instructions is empty",
{ metadataGeneration: { agentTitle: { instructions: "" } } },
],
[
"agentTitle exists but instructions is whitespace-only",
{ metadataGeneration: { agentTitle: { instructions: " \n\t " } } },
],
])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => {
const { prompt } = await generateTitlePromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_TITLE_PROMPT);
});
it("injects project instructions between the default rules and JSON contract", async () => {
const { prompt } = await generateTitlePromptWithConfig({
metadataGeneration: {
agentTitle: {
instructions: "Use the prefix mb/.",
},
},
});
const defaultRuleIndex = prompt.indexOf("Title: short descriptive label");
const noticeIndex = prompt.indexOf("override the guidelines above");
const openTagIndex = prompt.indexOf("<user-instructions>");
const userInstructionIndex = prompt.indexOf("Use the prefix mb/.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
const jsonContractIndex = prompt.indexOf("Return JSON only");
const payloadIndex = prompt.indexOf("User prompt:");
expect(defaultRuleIndex).toBeGreaterThanOrEqual(0);
expect(defaultRuleIndex).toBeLessThan(openTagIndex);
expect(openTagIndex).toBeLessThan(noticeIndex);
expect(noticeIndex).toBeLessThan(userInstructionIndex);
expect(userInstructionIndex).toBeLessThan(closeTagIndex);
expect(closeTagIndex).toBeLessThan(jsonContractIndex);
expect(jsonContractIndex).toBeLessThan(payloadIndex);
});
});
async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt: string }> {
const repoRoot = mkdtempSync(path.join(tmpdir(), "paseo-title-config-"));
cleanupPaths.push(repoRoot);
if (typeof config === "string") {
writeFileSync(path.join(repoRoot, "paseo.json"), config);
} else if (config !== undefined) {
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
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"]
>;
await generateAndApplyAgentMetadata({
agentManager: manager,
agentId: "agent-config",
cwd: path.join(repoRoot, "nested"),
workspaceGitService: createNoopWorkspaceGitService({
resolveRepoRoot: async () => repoRoot,
}),
initialPrompt: "Implement this feature",
explicitTitle: null,
logger,
deps: createDeps(generateStructured),
});
return {
prompt: String(generateStructured.mock.calls[0]?.[0].prompt),
};
}

View File

@@ -320,30 +320,6 @@ describe("AgentStorage", () => {
);
});
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.setGeneratedTitle(agentId, "Title A"),
storage.setGeneratedTitle(agentId, "Title B"),
]);
const record = await storage.get(agentId);
expect(["Title A", "Title B"]).toContain(record?.title);
});
test("setGeneratedTitle writes the generated title", async () => {
const agentId = "agent-generated-title-empty";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
const written = await storage.setGeneratedTitle(agentId, "Generated title");
expect(written.title).toBe("Generated title");
const record = await storage.get(agentId);
expect(record?.title).toBe("Generated title");
});
test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override";
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });

View File

@@ -226,22 +226,6 @@ export class AgentStorage {
await this.upsert({ ...record, title });
}
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`);
}
const nextRecord = {
...record,
title,
updatedAt: new Date().toISOString(),
};
await this.queueRecordWrite(nextRecord);
return nextRecord;
}
async flush(): Promise<void> {
await this.load().catch(() => undefined);
const writes = Array.from(this.pendingWrites.values());

View File

@@ -70,7 +70,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
clientMessageId: "msg-create-1",
labels: {},
provisionalTitle: null,
explicitTitle: "Explicit title",
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
});
@@ -99,7 +98,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
workspaceId: "ws-source",
labels: {},
provisionalTitle: null,
explicitTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
@@ -131,7 +129,6 @@ test("session create stamps the new worktree's workspaceId when a setup continua
workspaceId: "ws-source",
labels: {},
provisionalTitle: null,
explicitTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({
sessionConfig: config,
@@ -163,7 +160,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
workspaceId: "ws-parent",
labels: {},
provisionalTitle: null,
explicitTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
@@ -198,3 +194,77 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
rmSync(workdir, { recursive: true, force: true });
}
});
test("session create keeps the prompt title after the initial prompt settles", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-title-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = createRealAgentManager(storage);
const title = "Implement auth retries with backoff";
try {
const { snapshot } = await createAgentCommand(
{
agentManager,
agentStorage: storage,
logger,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
},
{
kind: "session",
config: { provider: "codex", cwd: workdir },
initialPrompt: `${title}\n\ninclude tests`,
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
const created = await storage.get(snapshot.id);
expect(created?.title).toBe(title);
await agentManager.waitForAgentEvent(snapshot.id, { waitForActive: true });
const settled = await storage.get(snapshot.id);
expect(settled?.title).toBe(title);
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("session create keeps an explicit title after the initial prompt settles", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-explicit-title-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = createRealAgentManager(storage);
const title = "Explicit override";
try {
const { snapshot } = await createAgentCommand(
{
agentManager,
agentStorage: storage,
logger,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
},
{
kind: "session",
config: { provider: "codex", cwd: workdir, title },
initialPrompt: "Implement auth retries with backoff",
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
const created = await storage.get(snapshot.id);
expect(created?.title).toBe(title);
await agentManager.waitForAgentEvent(snapshot.id, { waitForActive: true });
const settled = await storage.get(snapshot.id);
expect(settled?.title).toBe(title);
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});

View File

@@ -5,7 +5,6 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
import { toWorktreeRequestError } from "../../worktree-errors.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
@@ -14,8 +13,6 @@ import type {
} from "../../worktree-session.js";
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
import type { AgentManager, ManagedAgent } from "../agent-manager.js";
import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js";
import type { StructuredGenerationDaemonConfig } from "../structured-generation-providers.js";
import type {
AgentPromptContentBlock,
AgentPromptInput,
@@ -46,13 +43,8 @@ interface CreateAgentCommandDependencies {
logger: Logger;
paseoHome?: string;
worktreesRoot?: string;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
terminalManager?: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
daemonConfig?: StructuredGenerationDaemonConfig | null;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
// created with no parent and no worktree: it owns a brand-new workspace rather
@@ -74,7 +66,6 @@ export interface CreateAgentFromSessionInput {
labels: Record<string, string>;
env?: Record<string, string>;
provisionalTitle: string | null;
explicitTitle: string | null;
firstAgentContext: FirstAgentContext;
buildSessionConfig: (
config: AgentSessionConfig,
@@ -124,10 +115,8 @@ export interface CreateAgentCommandResult {
interface ResolvedCreateAgent {
config: AgentSessionConfig;
createOptions?: AgentCreateOptions;
metadataInitialPrompt?: string;
prompt?: AgentPromptInput;
runOptions?: AgentRunOptions;
explicitTitle: string | null;
setupContinuation?: AgentWorktreeSetupContinuation;
background: boolean;
promptFailure: "throw" | "log";
@@ -221,10 +210,8 @@ async function resolveSessionCreateAgent(
// path). createdWorkspaceId is the freshly created worktree's workspace.
workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
},
metadataInitialPrompt: trimmedPrompt,
prompt: hasPromptContent ? prompt : undefined,
runOptions,
explicitTitle: input.explicitTitle,
setupContinuation,
background: true,
promptFailure: "throw",
@@ -303,9 +290,7 @@ async function resolveMcpCreateAgent(
...(workspaceId ? { workspaceId } : {}),
}
: undefined,
metadataInitialPrompt: trimmedPrompt,
prompt: trimmedPrompt,
explicitTitle: input.title.trim(),
setupContinuation,
background: input.background,
promptFailure: "log",
@@ -327,25 +312,6 @@ async function sendInitialPrompt(
resolved: ResolvedCreateAgent,
snapshot: ManagedAgent,
): Promise<{ started: boolean; liveSnapshot: ManagedAgent }> {
scheduleAgentMetadataGeneration({
agentManager: dependencies.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
workspaceGitService: dependencies.workspaceGitService,
providerSnapshotManager: dependencies.providerSnapshotManager,
daemonConfig: dependencies.daemonConfig,
currentSelection: {
provider: snapshot.provider,
model: snapshot.runtimeInfo?.model ?? resolved.config.model,
thinkingOptionId:
snapshot.runtimeInfo?.thinkingOptionId ?? resolved.config.thinkingOptionId ?? null,
},
initialPrompt: resolved.metadataInitialPrompt,
explicitTitle: resolved.explicitTitle,
paseoHome: dependencies.paseoHome,
logger: dependencies.logger,
});
try {
const prompt = resolved.prompt;
if (prompt === undefined) {

View File

@@ -379,7 +379,6 @@ test("importProviderSession imports a selected provider session without listing"
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
} as unknown as AgentStorage;
const scheduleAgentMetadataGeneration = vi.fn();
const result = await importProviderSession({
request: {
@@ -392,7 +391,6 @@ test("importProviderSession imports a selected provider session without listing"
agentManager,
agentStorage,
logger: { warn: vi.fn(), error: vi.fn() } as never,
deps: { scheduleAgentMetadataGeneration },
});
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
@@ -402,15 +400,6 @@ test("importProviderSession imports a selected provider session without listing"
workspaceId: "ws-imported",
labels: undefined,
});
expect(scheduleAgentMetadataGeneration).toHaveBeenCalledWith(
expect.objectContaining({
agentManager,
agentId: snapshot.id,
cwd,
initialPrompt: "Trace recent provider sessions\n\nkeep it tight",
explicitTitle: null,
}),
);
expect(result).toEqual({ snapshot, timelineSize: 2 });
});

View File

@@ -7,14 +7,7 @@ import type {
ManagedImportableProviderSession,
} from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentPersistenceHandle,
AgentProvider,
AgentTimelineItem,
} from "./agent-sdk-types.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { AgentPersistenceHandle, AgentProvider } from "./agent-sdk-types.js";
import { unarchiveAgentState } from "./agent-prompt.js";
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
import type {
@@ -22,7 +15,6 @@ import type {
ImportAgentRequestMessageSchema,
RecentProviderSessionDescriptorPayload,
} from "@getpaseo/protocol/messages";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
@@ -65,14 +57,7 @@ export interface ImportProviderSessionInput {
workspaceId: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
daemonConfig?: StructuredGenerationDaemonConfig | null;
paseoHome?: string;
logger: Logger;
deps?: {
scheduleAgentMetadataGeneration?: typeof scheduleAgentMetadataGeneration;
};
}
export interface ImportProviderSessionResult {
@@ -168,17 +153,6 @@ export async function importProviderSession(
labels,
});
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
scheduleImportedAgentMetadata({
snapshot,
agentManager: input.agentManager,
workspaceGitService: input.workspaceGitService,
providerSnapshotManager: input.providerSnapshotManager,
daemonConfig: input.daemonConfig,
paseoHome: input.paseoHome,
logger: input.logger,
scheduleAgentMetadataGeneration:
input.deps?.scheduleAgentMetadataGeneration ?? scheduleAgentMetadataGeneration,
});
return {
snapshot,
@@ -204,48 +178,6 @@ async function unarchiveAgentByHandle(
await unarchiveAgentState(agentStorage, agentManager, matched.id);
}
function scheduleImportedAgentMetadata(input: {
snapshot: ManagedAgent;
agentManager: AgentManager;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
daemonConfig?: StructuredGenerationDaemonConfig | null;
paseoHome?: string;
logger: Logger;
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
}): void {
const initialPrompt = getFirstUserMessageText(input.agentManager.getTimeline(input.snapshot.id));
if (!initialPrompt) {
return;
}
const { explicitTitle } = resolveCreateAgentTitles({
configTitle: input.snapshot.config.title,
initialPrompt,
});
input.scheduleAgentMetadataGeneration({
agentManager: input.agentManager,
agentId: input.snapshot.id,
cwd: input.snapshot.cwd,
workspaceGitService: input.workspaceGitService,
providerSnapshotManager: input.providerSnapshotManager,
daemonConfig: input.daemonConfig,
currentSelection: {
provider: input.snapshot.provider,
model: input.snapshot.runtimeInfo?.model ?? input.snapshot.config.model,
thinkingOptionId:
input.snapshot.runtimeInfo?.thinkingOptionId ??
input.snapshot.config.thinkingOptionId ??
null,
},
initialPrompt,
explicitTitle,
paseoHome: input.paseoHome,
logger: input.logger,
});
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
@@ -273,19 +205,6 @@ function buildImportPersistenceHandle(input: {
};
}
function getFirstUserMessageText(timeline: readonly AgentTimelineItem[]): string | null {
for (const item of timeline) {
if (item.type !== "user_message") {
continue;
}
const text = item.text.trim();
if (text) {
return text;
}
}
return null;
}
async function collectImportedProviderSessionHandles(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,

View File

@@ -944,7 +944,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
logger: childLogger,
paseoHome: options.paseoHome,
worktreesRoot: options.worktreesRoot,
workspaceGitService: options.workspaceGitService,
terminalManager,
providerSnapshotManager,
createPaseoWorktree: options.createPaseoWorktree,

View File

@@ -74,34 +74,6 @@ describe("MockLoadTestAgentClient", () => {
});
});
test("returns schema-shaped JSON for structured agent-title generation", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
});
const resultPromise = session.run(
[
"Generate metadata for a coding agent based on the user prompt.",
"Title: short descriptive label (<= 80 chars).",
"Return JSON only with a single field 'title'.",
"",
"User prompt:",
"Fix login bug",
].join("\n"),
);
await vi.advanceTimersByTimeAsync(0);
await expect(resultPromise).resolves.toMatchObject({
sessionId: session.id,
finalText: JSON.stringify({ title: "Fix login bug" }),
canceled: false,
});
});
test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();

View File

@@ -311,37 +311,6 @@ function parseStructuredBranchNamePrompt(
return { title: title || "Mock task", branch };
}
function parseStructuredAgentTitlePrompt(prompt: AgentPromptInput): { title: string } | null {
const text = promptToText(prompt);
const hasAgentTitlePrompt =
text.includes("Generate metadata for a coding agent based on the user prompt.") &&
(text.includes("Return JSON only with a single field 'title'.") || text.includes('"title"'));
if (
!hasAgentTitlePrompt &&
!(
text.includes("You must respond with JSON only that matches this JSON Schema") &&
text.includes('"title"') &&
text.includes("User prompt:")
)
) {
return null;
}
const seed = text.split("User prompt:\n").at(-1)?.trim() ?? "";
const firstLine =
seed
.split("\n")
.find((line) => line.trim().length > 0)
?.trim() ?? "Mock task";
const title = firstLine
.replace(/^["'`]+|["'`]+$/g, "")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return { title: title || "Mock task" };
}
function buildRepeatedPayload(bytes: number, prefix: string): string {
const line = `${prefix} ${"x".repeat(96)}\n`;
let output = "";
@@ -678,11 +647,8 @@ export class MockLoadTestAgentSession implements AgentSession {
const stress = parseAgentStreamStressPrompt(prompt);
const questionPrompt = parseMockQuestionPrompt(prompt);
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
const structuredAgentTitle = parseStructuredAgentTitlePrompt(prompt);
if (structuredBranchName) {
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
} else if (structuredAgentTitle) {
this.scheduleStructuredJsonTurn(turn, structuredAgentTitle);
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
this.schedulePlanApprovalTurn(turn);
} else if (questionPrompt) {

View File

@@ -120,10 +120,6 @@ const agentResponseMocks = vi.hoisted(() => ({
generateStructuredAgentResponseWithFallback: vi.fn(),
}));
const agentMetadataMocks = vi.hoisted(() => ({
scheduleAgentMetadataGeneration: vi.fn(),
}));
const spawnMocks = vi.hoisted(() => ({
execCommand: vi.fn(),
spawnWorkspaceScript: vi.fn(),
@@ -194,14 +190,6 @@ vi.mock("./agent/agent-response-loop.js", async (importOriginal) => {
};
});
vi.mock("./agent/agent-metadata-generator.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./agent/agent-metadata-generator.js")>();
return {
...actual,
scheduleAgentMetadataGeneration: agentMetadataMocks.scheduleAgentMetadataGeneration,
};
});
vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./worktree-bootstrap.js")>();
return {

View File

@@ -3225,7 +3225,7 @@ export class Session {
let createdAgentId: string | null = null;
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
initialPrompt: trimmedPrompt,
});
@@ -3259,9 +3259,7 @@ export class Session {
logger: this.sessionLogger,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
},
{
kind: "session",
@@ -3277,7 +3275,6 @@ export class Session {
labels,
env,
provisionalTitle,
explicitTitle,
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
@@ -3455,10 +3452,6 @@ export class Session {
workspaceId: workspace.workspaceId,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(workspace);

View File

@@ -193,7 +193,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
["paseo.json valid but missing metadataGeneration", {}],
[
"metadataGeneration exists but missing branchName",
{ metadataGeneration: { agentTitle: { instructions: "Use mb/." } } },
{ metadataGeneration: { commitMessage: { instructions: "Use Conventional Commits." } } },
],
["branchName exists but instructions is undefined", { metadataGeneration: { branchName: {} } }],
[

View File

@@ -2,7 +2,7 @@ import { readPaseoConfigJson } from "./paseo-config-file.js";
import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
export type MetadataConfigKey = "agentTitle" | "branchName" | "commitMessage" | "pullRequest";
export type MetadataConfigKey = "branchName" | "commitMessage" | "pullRequest";
export interface RepoRootResolver {
resolveRepoRoot: (cwd: string) => Promise<string>;

View File

@@ -1,6 +1,6 @@
---
title: Metadata generation
description: How Paseo uses providers to generate agent titles, branch names, commit messages, and pull request text, and how to configure them.
description: How Paseo uses providers to generate branch names, commit messages, and pull request text, and how to configure them.
nav: Metadata generation
order: 42
category: Configuration
@@ -10,9 +10,8 @@ category: Configuration
Paseo asks a language model to write short pieces of text for you so you don't have to. This is separate from the agent you're talking to: it's a small, one-shot call made in the background.
Paseo generates four kinds of metadata:
Paseo generates three kinds of metadata:
- **Agent titles** — a short title for a new agent, derived from your first prompt. Only generated when you didn't type one yourself.
- **Worktree branch names** — a slug for the branch a new worktree agent runs on.
- **Commit messages** — a concise message for the changes you're committing.
- **Pull request title and body** — drafted from the diff when you open a PR.
@@ -67,7 +66,6 @@ You can steer the wording of each kind of metadata per repository with a `paseo.
```json
{
"metadataGeneration": {
"agentTitle": { "instructions": "Prefix titles with the area of the codebase." },
"branchName": { "instructions": "Use the format <type>/<scope>-<short-desc>." },
"commitMessage": { "instructions": "Follow Conventional Commits." },
"pullRequest": { "instructions": "Include a Testing section in the body." }