mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -1846,8 +1846,6 @@ export const ar: TranslationResources = {
|
||||
metadata: {
|
||||
title: "توليد البيانات الوصفية",
|
||||
info: "تعليمات خاصة بالمشروع يتم إدخالها في الذكاء الاصطناعي الذي يستخدمه Paseo لإنشاء بيانات التعريف - استخدمها لفرض اصطلاحات فريقك مثل تسمية الفرع أو نمط الالتزام أو تنسيق PR",
|
||||
agentTitle: "عناوين Agent",
|
||||
agentTitlePlaceholder: "اجعل العناوين ضرورية وأقل من 40 حرفًا",
|
||||
branchName: "اسماء الفروع",
|
||||
branchNamePlaceholder: "بادئة الفروع بـ fet/ أو Fix/, mb/ للفروع الشخصية",
|
||||
commitMessage: "ارتكاب الرسائل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1879,8 +1879,6 @@ export const ru: TranslationResources = {
|
||||
metadata: {
|
||||
title: "Генерация метаданных",
|
||||
info: "Инструкции для конкретного проекта, внедренные в подсказки ИИ, которые Paseo использует для генерации метаданных. Используйте их для обеспечения соблюдения соглашений вашей команды, таких как наименование ветвей, стиль фиксации или формат PR.",
|
||||
agentTitle: "Agent заголовки",
|
||||
agentTitlePlaceholder: "Сохраняйте заголовки обязательными и длиной не более 40 символов.",
|
||||
branchName: "Названия ветвей",
|
||||
branchNamePlaceholder: "Префиксные ветки с feat/ или fix/, mb/ для личных веток",
|
||||
commitMessage: "Фиксировать сообщения",
|
||||
|
||||
@@ -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: "提交消息",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const MAX_EXPLICIT_AGENT_TITLE_CHARS = 200;
|
||||
export const MAX_AUTO_AGENT_TITLE_CHARS = 40;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {} } }],
|
||||
[
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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." }
|
||||
|
||||
Reference in New Issue
Block a user