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: {
|
metadata: {
|
||||||
title: "توليد البيانات الوصفية",
|
title: "توليد البيانات الوصفية",
|
||||||
info: "تعليمات خاصة بالمشروع يتم إدخالها في الذكاء الاصطناعي الذي يستخدمه Paseo لإنشاء بيانات التعريف - استخدمها لفرض اصطلاحات فريقك مثل تسمية الفرع أو نمط الالتزام أو تنسيق PR",
|
info: "تعليمات خاصة بالمشروع يتم إدخالها في الذكاء الاصطناعي الذي يستخدمه Paseo لإنشاء بيانات التعريف - استخدمها لفرض اصطلاحات فريقك مثل تسمية الفرع أو نمط الالتزام أو تنسيق PR",
|
||||||
agentTitle: "عناوين Agent",
|
|
||||||
agentTitlePlaceholder: "اجعل العناوين ضرورية وأقل من 40 حرفًا",
|
|
||||||
branchName: "اسماء الفروع",
|
branchName: "اسماء الفروع",
|
||||||
branchNamePlaceholder: "بادئة الفروع بـ fet/ أو Fix/, mb/ للفروع الشخصية",
|
branchNamePlaceholder: "بادئة الفروع بـ fet/ أو Fix/, mb/ للفروع الشخصية",
|
||||||
commitMessage: "ارتكاب الرسائل",
|
commitMessage: "ارتكاب الرسائل",
|
||||||
|
|||||||
@@ -1854,8 +1854,6 @@ export const en = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "Metadata generation",
|
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",
|
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",
|
branchName: "Branch names",
|
||||||
branchNamePlaceholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
|
branchNamePlaceholder: "Prefix branches with feat/ or fix/, mb/ for personal branches",
|
||||||
commitMessage: "Commit messages",
|
commitMessage: "Commit messages",
|
||||||
|
|||||||
@@ -1887,8 +1887,6 @@ export const es: TranslationResources = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "Generación de metadatos",
|
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.",
|
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",
|
branchName: "Nombres de sucursales",
|
||||||
branchNamePlaceholder: "Prefijo ramas con feat/ o fix/, mb/ para ramas personales",
|
branchNamePlaceholder: "Prefijo ramas con feat/ o fix/, mb/ para ramas personales",
|
||||||
commitMessage: "Confirmar mensajes",
|
commitMessage: "Confirmar mensajes",
|
||||||
|
|||||||
@@ -1893,8 +1893,6 @@ export const fr: TranslationResources = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "Génération de métadonnées",
|
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.",
|
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",
|
branchName: "Noms des succursales",
|
||||||
branchNamePlaceholder:
|
branchNamePlaceholder:
|
||||||
"Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles",
|
"Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles",
|
||||||
|
|||||||
@@ -1879,8 +1879,6 @@ export const ru: TranslationResources = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "Генерация метаданных",
|
title: "Генерация метаданных",
|
||||||
info: "Инструкции для конкретного проекта, внедренные в подсказки ИИ, которые Paseo использует для генерации метаданных. Используйте их для обеспечения соблюдения соглашений вашей команды, таких как наименование ветвей, стиль фиксации или формат PR.",
|
info: "Инструкции для конкретного проекта, внедренные в подсказки ИИ, которые Paseo использует для генерации метаданных. Используйте их для обеспечения соблюдения соглашений вашей команды, таких как наименование ветвей, стиль фиксации или формат PR.",
|
||||||
agentTitle: "Agent заголовки",
|
|
||||||
agentTitlePlaceholder: "Сохраняйте заголовки обязательными и длиной не более 40 символов.",
|
|
||||||
branchName: "Названия ветвей",
|
branchName: "Названия ветвей",
|
||||||
branchNamePlaceholder: "Префиксные ветки с feat/ или fix/, mb/ для личных веток",
|
branchNamePlaceholder: "Префиксные ветки с feat/ или fix/, mb/ для личных веток",
|
||||||
commitMessage: "Фиксировать сообщения",
|
commitMessage: "Фиксировать сообщения",
|
||||||
|
|||||||
@@ -1823,8 +1823,6 @@ export const zhCN: TranslationResources = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
title: "元数据生成",
|
title: "元数据生成",
|
||||||
info: "注入到 Paseo 用来生成元数据的 AI prompts 中的 Project 专属指令,可用于强制执行团队约定,例如分支命名、提交风格或 PR 格式",
|
info: "注入到 Paseo 用来生成元数据的 AI prompts 中的 Project 专属指令,可用于强制执行团队约定,例如分支命名、提交风格或 PR 格式",
|
||||||
agentTitle: "Agent 标题",
|
|
||||||
agentTitlePlaceholder: "标题保持祈使句且不超过 40 个字符",
|
|
||||||
branchName: "分支名称",
|
branchName: "分支名称",
|
||||||
branchNamePlaceholder: "分支以 feat/ 或 fix/ 开头,个人分支使用 mb/",
|
branchNamePlaceholder: "分支以 feat/ 或 fix/ 开头,个人分支使用 mb/",
|
||||||
commitMessage: "提交消息",
|
commitMessage: "提交消息",
|
||||||
|
|||||||
@@ -58,12 +58,6 @@ interface MetadataPromptField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, 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: {
|
branchName: {
|
||||||
titleKey: "settings.project.metadata.branchName",
|
titleKey: "settings.project.metadata.branchName",
|
||||||
placeholderKey: "settings.project.metadata.branchNamePlaceholder",
|
placeholderKey: "settings.project.metadata.branchNamePlaceholder",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ function emptyDraft(): ProjectConfigDraft {
|
|||||||
teardownOriginalKind: "missing",
|
teardownOriginalKind: "missing",
|
||||||
scripts: [],
|
scripts: [],
|
||||||
metadataPrompts: {
|
metadataPrompts: {
|
||||||
agentTitle: "",
|
|
||||||
branchName: "",
|
branchName: "",
|
||||||
commitMessage: "",
|
commitMessage: "",
|
||||||
pullRequest: "",
|
pullRequest: "",
|
||||||
@@ -217,17 +216,15 @@ describe("applyDraftToConfig", () => {
|
|||||||
expect(tunnel.port).toBe("auto");
|
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({
|
const draft = configToDraft({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use mb/." },
|
|
||||||
branchName: { instructions: "feat/<slug>" },
|
branchName: { instructions: "feat/<slug>" },
|
||||||
commitMessage: { instructions: "Conventional commits." },
|
commitMessage: { instructions: "Conventional commits." },
|
||||||
pullRequest: { instructions: "Include risk notes." },
|
pullRequest: { instructions: "Include risk notes." },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(draft.metadataPrompts).toEqual({
|
expect(draft.metadataPrompts).toEqual({
|
||||||
agentTitle: "Use mb/.",
|
|
||||||
branchName: "feat/<slug>",
|
branchName: "feat/<slug>",
|
||||||
commitMessage: "Conventional commits.",
|
commitMessage: "Conventional commits.",
|
||||||
pullRequest: "Include risk notes.",
|
pullRequest: "Include risk notes.",
|
||||||
@@ -236,11 +233,27 @@ describe("applyDraftToConfig", () => {
|
|||||||
|
|
||||||
it("defaults metadata prompts to empty strings when not present", () => {
|
it("defaults metadata prompts to empty strings when not present", () => {
|
||||||
const draft = configToDraft({
|
const draft = configToDraft({
|
||||||
metadataGeneration: { agentTitle: { instructions: "Use mb/." } },
|
metadataGeneration: { branchName: { instructions: "feat/<slug>" } },
|
||||||
});
|
});
|
||||||
expect(draft.metadataPrompts).toEqual({
|
expect(draft.metadataPrompts).toEqual({
|
||||||
agentTitle: "Use mb/.",
|
branchName: "feat/<slug>",
|
||||||
branchName: "",
|
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: "",
|
commitMessage: "",
|
||||||
pullRequest: "",
|
pullRequest: "",
|
||||||
});
|
});
|
||||||
@@ -249,11 +262,11 @@ describe("applyDraftToConfig", () => {
|
|||||||
it("writes only metadata prompt entries with non-empty text", () => {
|
it("writes only metadata prompt entries with non-empty text", () => {
|
||||||
const base: PaseoConfigRaw = {};
|
const base: PaseoConfigRaw = {};
|
||||||
const draft = configToDraft(base);
|
const draft = configToDraft(base);
|
||||||
draft.metadataPrompts.agentTitle = "Use mb/.";
|
draft.metadataPrompts.branchName = "Use mb/.";
|
||||||
draft.metadataPrompts.commitMessage = "Conventional commits.";
|
draft.metadataPrompts.commitMessage = "Conventional commits.";
|
||||||
const next = applyDraftToConfig({ draft, base });
|
const next = applyDraftToConfig({ draft, base });
|
||||||
expect(next.metadataGeneration).toEqual({
|
expect(next.metadataGeneration).toEqual({
|
||||||
agentTitle: { instructions: "Use mb/." },
|
branchName: { instructions: "Use mb/." },
|
||||||
commitMessage: { instructions: "Conventional commits." },
|
commitMessage: { instructions: "Conventional commits." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -261,16 +274,16 @@ describe("applyDraftToConfig", () => {
|
|||||||
it("drops the metadataGeneration field when all prompts are empty", () => {
|
it("drops the metadataGeneration field when all prompts are empty", () => {
|
||||||
const base = PaseoConfigRawSchema.parse({
|
const base = PaseoConfigRawSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use mb/." },
|
branchName: { instructions: "Use mb/." },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const draft = configToDraft(base);
|
const draft = configToDraft(base);
|
||||||
draft.metadataPrompts.agentTitle = "";
|
draft.metadataPrompts.branchName = "";
|
||||||
const next = applyDraftToConfig({ draft, base });
|
const next = applyDraftToConfig({ draft, base });
|
||||||
expect(next.metadataGeneration).toBeUndefined();
|
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({
|
const base = PaseoConfigRawSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use mb/." },
|
agentTitle: { instructions: "Use mb/." },
|
||||||
@@ -278,37 +291,38 @@ describe("applyDraftToConfig", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const draft = configToDraft(base);
|
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 next = applyDraftToConfig({ draft, base });
|
||||||
const metadata = next.metadataGeneration as Record<string, unknown>;
|
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);
|
expect(metadata.futureField).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves unknown fields inside a metadata prompt entry on round-trip", () => {
|
it("preserves unknown fields inside a metadata prompt entry on round-trip", () => {
|
||||||
const base = PaseoConfigRawSchema.parse({
|
const base = PaseoConfigRawSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use mb/.", model: "haiku" },
|
branchName: { instructions: "Use mb/.", model: "haiku" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const draft = configToDraft(base);
|
const draft = configToDraft(base);
|
||||||
draft.metadataPrompts.agentTitle = "Updated.";
|
draft.metadataPrompts.branchName = "Updated.";
|
||||||
const next = applyDraftToConfig({ draft, base });
|
const next = applyDraftToConfig({ draft, base });
|
||||||
const metadata = next.metadataGeneration as Record<string, unknown>;
|
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", () => {
|
it("clears instructions but preserves unknown sibling fields when text becomes empty", () => {
|
||||||
const base = PaseoConfigRawSchema.parse({
|
const base = PaseoConfigRawSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use mb/.", model: "haiku" },
|
branchName: { instructions: "Use mb/.", model: "haiku" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const draft = configToDraft(base);
|
const draft = configToDraft(base);
|
||||||
draft.metadataPrompts.agentTitle = "";
|
draft.metadataPrompts.branchName = "";
|
||||||
const next = applyDraftToConfig({ draft, base });
|
const next = applyDraftToConfig({ draft, base });
|
||||||
const metadata = next.metadataGeneration as Record<string, unknown>;
|
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", () => {
|
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 type LifecycleOriginalKind = "string" | "array" | "missing";
|
||||||
|
|
||||||
export const METADATA_PROMPT_KEYS = [
|
export const METADATA_PROMPT_KEYS = ["branchName", "commitMessage", "pullRequest"] as const;
|
||||||
"agentTitle",
|
|
||||||
"branchName",
|
|
||||||
"commitMessage",
|
|
||||||
"pullRequest",
|
|
||||||
] as const;
|
|
||||||
export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number];
|
export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number];
|
||||||
|
|
||||||
export interface ProjectScriptDraft {
|
export interface ProjectScriptDraft {
|
||||||
@@ -105,7 +100,6 @@ function nextScriptDraftId(): string {
|
|||||||
|
|
||||||
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
|
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
|
||||||
return {
|
return {
|
||||||
agentTitle: "",
|
|
||||||
branchName: "",
|
branchName: "",
|
||||||
commitMessage: "",
|
commitMessage: "",
|
||||||
pullRequest: "",
|
pullRequest: "",
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export const MAX_EXPLICIT_AGENT_TITLE_CHARS = 200;
|
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(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use concise titles." },
|
|
||||||
branchName: { instructions: "Prefix branches with feat/." },
|
branchName: { instructions: "Prefix branches with feat/." },
|
||||||
commitMessage: { instructions: "Use imperative mood." },
|
commitMessage: { instructions: "Use imperative mood." },
|
||||||
pullRequest: { instructions: "Include risk notes." },
|
pullRequest: { instructions: "Include risk notes." },
|
||||||
@@ -45,7 +44,6 @@ describe("paseo config schema", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use concise titles." },
|
|
||||||
branchName: { instructions: "Prefix branches with feat/." },
|
branchName: { instructions: "Prefix branches with feat/." },
|
||||||
commitMessage: { instructions: "Use imperative mood." },
|
commitMessage: { instructions: "Use imperative mood." },
|
||||||
pullRequest: { instructions: "Include risk notes." },
|
pullRequest: { instructions: "Include risk notes." },
|
||||||
@@ -56,29 +54,40 @@ describe("paseo config schema", () => {
|
|||||||
it("parses partial metadata generation instructions with missing entries undefined", () => {
|
it("parses partial metadata generation instructions with missing entries undefined", () => {
|
||||||
const parsed = PaseoConfigSchema.parse({
|
const parsed = PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Keep it short." },
|
branchName: { instructions: "Keep it short." },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(parsed.metadataGeneration).toEqual({
|
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?.commitMessage).toBeUndefined();
|
||||||
expect(parsed.metadataGeneration?.pullRequest).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", () => {
|
it("passes through unknown metadata generation fields", () => {
|
||||||
expect(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use concise titles." },
|
|
||||||
futureField: 42,
|
futureField: 42,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use concise titles." },
|
|
||||||
futureField: 42,
|
futureField: 42,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -88,7 +97,7 @@ describe("paseo config schema", () => {
|
|||||||
expect(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: {
|
branchName: {
|
||||||
instructions: "Use concise titles.",
|
instructions: "Use concise titles.",
|
||||||
model: "haiku",
|
model: "haiku",
|
||||||
},
|
},
|
||||||
@@ -96,7 +105,7 @@ describe("paseo config schema", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: {
|
branchName: {
|
||||||
instructions: "Use concise titles.",
|
instructions: "Use concise titles.",
|
||||||
model: "haiku",
|
model: "haiku",
|
||||||
},
|
},
|
||||||
@@ -108,17 +117,17 @@ describe("paseo config schema", () => {
|
|||||||
expect(
|
expect(
|
||||||
PaseoConfigSchema.parse({
|
PaseoConfigSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: 42 },
|
branchName: { instructions: 42 },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
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 = {
|
const config = {
|
||||||
worktree: {
|
worktree: {
|
||||||
setup: "npm install",
|
setup: "npm install",
|
||||||
@@ -132,6 +141,7 @@ describe("paseo config schema", () => {
|
|||||||
},
|
},
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: "Use concise titles." },
|
agentTitle: { instructions: "Use concise titles." },
|
||||||
|
branchName: { instructions: "Use concise branches." },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,12 +152,12 @@ describe("paseo config schema", () => {
|
|||||||
expect(
|
expect(
|
||||||
PaseoConfigRawSchema.parse({
|
PaseoConfigRawSchema.parse({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: { instructions: 42 },
|
branchName: { instructions: 42 },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
metadataGeneration: {
|
metadataGeneration: {
|
||||||
agentTitle: {},
|
branchName: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ export const PaseoMetadataGenerationEntrySchema = z
|
|||||||
|
|
||||||
export const PaseoMetadataGenerationSchema = z
|
export const PaseoMetadataGenerationSchema = z
|
||||||
.object({
|
.object({
|
||||||
agentTitle: PaseoMetadataGenerationEntrySchema.optional(),
|
|
||||||
branchName: PaseoMetadataGenerationEntrySchema.optional(),
|
branchName: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
|
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
|
||||||
pullRequest: 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()
|
.passthrough()
|
||||||
.catch({});
|
.catch({});
|
||||||
|
|
||||||
|
|||||||
@@ -1862,86 +1862,6 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
|
|||||||
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
|
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 () => {
|
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -1331,20 +1331,6 @@ export class AgentManager {
|
|||||||
this.emitState(agent, { persist: false });
|
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> {
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
agent.labels = { ...agent.labels, ...labels };
|
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 () => {
|
test("applySnapshot accepts explicit title overrides", async () => {
|
||||||
const agentId = "agent-override";
|
const agentId = "agent-override";
|
||||||
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
||||||
|
|||||||
@@ -226,22 +226,6 @@ export class AgentStorage {
|
|||||||
await this.upsert({ ...record, title });
|
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> {
|
async flush(): Promise<void> {
|
||||||
await this.load().catch(() => undefined);
|
await this.load().catch(() => undefined);
|
||||||
const writes = Array.from(this.pendingWrites.values());
|
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",
|
clientMessageId: "msg-create-1",
|
||||||
labels: {},
|
labels: {},
|
||||||
provisionalTitle: null,
|
provisionalTitle: null,
|
||||||
explicitTitle: "Explicit title",
|
|
||||||
firstAgentContext: { attachments: [] },
|
firstAgentContext: { attachments: [] },
|
||||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||||
});
|
});
|
||||||
@@ -99,7 +98,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
|||||||
workspaceId: "ws-source",
|
workspaceId: "ws-source",
|
||||||
labels: {},
|
labels: {},
|
||||||
provisionalTitle: null,
|
provisionalTitle: null,
|
||||||
explicitTitle: null,
|
|
||||||
firstAgentContext: { attachments: [] },
|
firstAgentContext: { attachments: [] },
|
||||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
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",
|
workspaceId: "ws-source",
|
||||||
labels: {},
|
labels: {},
|
||||||
provisionalTitle: null,
|
provisionalTitle: null,
|
||||||
explicitTitle: null,
|
|
||||||
firstAgentContext: { attachments: [] },
|
firstAgentContext: { attachments: [] },
|
||||||
buildSessionConfig: async (config) => ({
|
buildSessionConfig: async (config) => ({
|
||||||
sessionConfig: config,
|
sessionConfig: config,
|
||||||
@@ -163,7 +160,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
|||||||
workspaceId: "ws-parent",
|
workspaceId: "ws-parent",
|
||||||
labels: {},
|
labels: {},
|
||||||
provisionalTitle: null,
|
provisionalTitle: null,
|
||||||
explicitTitle: null,
|
|
||||||
firstAgentContext: { attachments: [] },
|
firstAgentContext: { attachments: [] },
|
||||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
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 });
|
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 type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
||||||
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
||||||
import { toWorktreeRequestError } from "../../worktree-errors.js";
|
import { toWorktreeRequestError } from "../../worktree-errors.js";
|
||||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
|
||||||
import type {
|
import type {
|
||||||
AgentWorktreeSetupContinuation,
|
AgentWorktreeSetupContinuation,
|
||||||
CreatePaseoWorktreeSetupContinuationInput,
|
CreatePaseoWorktreeSetupContinuationInput,
|
||||||
@@ -14,8 +13,6 @@ import type {
|
|||||||
} from "../../worktree-session.js";
|
} from "../../worktree-session.js";
|
||||||
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
||||||
import type { AgentManager, ManagedAgent } from "../agent-manager.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 {
|
import type {
|
||||||
AgentPromptContentBlock,
|
AgentPromptContentBlock,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
@@ -46,13 +43,8 @@ interface CreateAgentCommandDependencies {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
worktreesRoot?: string;
|
worktreesRoot?: string;
|
||||||
workspaceGitService?: Pick<
|
|
||||||
WorkspaceGitService,
|
|
||||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
|
||||||
>;
|
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
providerSnapshotManager: ProviderSnapshotManager;
|
providerSnapshotManager: ProviderSnapshotManager;
|
||||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
|
||||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||||
// Mints a fresh workspace for a cwd and returns its id. Used when an agent is
|
// 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
|
// 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>;
|
labels: Record<string, string>;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
provisionalTitle: string | null;
|
provisionalTitle: string | null;
|
||||||
explicitTitle: string | null;
|
|
||||||
firstAgentContext: FirstAgentContext;
|
firstAgentContext: FirstAgentContext;
|
||||||
buildSessionConfig: (
|
buildSessionConfig: (
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
@@ -124,10 +115,8 @@ export interface CreateAgentCommandResult {
|
|||||||
interface ResolvedCreateAgent {
|
interface ResolvedCreateAgent {
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
createOptions?: AgentCreateOptions;
|
createOptions?: AgentCreateOptions;
|
||||||
metadataInitialPrompt?: string;
|
|
||||||
prompt?: AgentPromptInput;
|
prompt?: AgentPromptInput;
|
||||||
runOptions?: AgentRunOptions;
|
runOptions?: AgentRunOptions;
|
||||||
explicitTitle: string | null;
|
|
||||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||||
background: boolean;
|
background: boolean;
|
||||||
promptFailure: "throw" | "log";
|
promptFailure: "throw" | "log";
|
||||||
@@ -221,10 +210,8 @@ async function resolveSessionCreateAgent(
|
|||||||
// path). createdWorkspaceId is the freshly created worktree's workspace.
|
// path). createdWorkspaceId is the freshly created worktree's workspace.
|
||||||
workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||||
},
|
},
|
||||||
metadataInitialPrompt: trimmedPrompt,
|
|
||||||
prompt: hasPromptContent ? prompt : undefined,
|
prompt: hasPromptContent ? prompt : undefined,
|
||||||
runOptions,
|
runOptions,
|
||||||
explicitTitle: input.explicitTitle,
|
|
||||||
setupContinuation,
|
setupContinuation,
|
||||||
background: true,
|
background: true,
|
||||||
promptFailure: "throw",
|
promptFailure: "throw",
|
||||||
@@ -303,9 +290,7 @@ async function resolveMcpCreateAgent(
|
|||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
metadataInitialPrompt: trimmedPrompt,
|
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
explicitTitle: input.title.trim(),
|
|
||||||
setupContinuation,
|
setupContinuation,
|
||||||
background: input.background,
|
background: input.background,
|
||||||
promptFailure: "log",
|
promptFailure: "log",
|
||||||
@@ -327,25 +312,6 @@ async function sendInitialPrompt(
|
|||||||
resolved: ResolvedCreateAgent,
|
resolved: ResolvedCreateAgent,
|
||||||
snapshot: ManagedAgent,
|
snapshot: ManagedAgent,
|
||||||
): Promise<{ started: boolean; liveSnapshot: 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 {
|
try {
|
||||||
const prompt = resolved.prompt;
|
const prompt = resolved.prompt;
|
||||||
if (prompt === undefined) {
|
if (prompt === undefined) {
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ test("importProviderSession imports a selected provider session without listing"
|
|||||||
list: vi.fn().mockResolvedValue([]),
|
list: vi.fn().mockResolvedValue([]),
|
||||||
get: vi.fn().mockResolvedValue(null),
|
get: vi.fn().mockResolvedValue(null),
|
||||||
} as unknown as AgentStorage;
|
} as unknown as AgentStorage;
|
||||||
const scheduleAgentMetadataGeneration = vi.fn();
|
|
||||||
|
|
||||||
const result = await importProviderSession({
|
const result = await importProviderSession({
|
||||||
request: {
|
request: {
|
||||||
@@ -392,7 +391,6 @@ test("importProviderSession imports a selected provider session without listing"
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
logger: { warn: vi.fn(), error: vi.fn() } as never,
|
||||||
deps: { scheduleAgentMetadataGeneration },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
expect(agentManager.importProviderSession).toHaveBeenCalledWith({
|
||||||
@@ -402,15 +400,6 @@ test("importProviderSession imports a selected provider session without listing"
|
|||||||
workspaceId: "ws-imported",
|
workspaceId: "ws-imported",
|
||||||
labels: undefined,
|
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 });
|
expect(result).toEqual({ snapshot, timelineSize: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import type {
|
|||||||
ManagedImportableProviderSession,
|
ManagedImportableProviderSession,
|
||||||
} from "./agent-manager.js";
|
} from "./agent-manager.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type {
|
import type { AgentPersistenceHandle, AgentProvider } from "./agent-sdk-types.js";
|
||||||
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 { unarchiveAgentState } from "./agent-prompt.js";
|
import { unarchiveAgentState } from "./agent-prompt.js";
|
||||||
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
|
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,7 +15,6 @@ import type {
|
|||||||
ImportAgentRequestMessageSchema,
|
ImportAgentRequestMessageSchema,
|
||||||
RecentProviderSessionDescriptorPayload,
|
RecentProviderSessionDescriptorPayload,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
|
||||||
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
|
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
|
||||||
|
|
||||||
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
||||||
@@ -65,14 +57,7 @@ export interface ImportProviderSessionInput {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentStorage;
|
agentStorage: AgentStorage;
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
|
||||||
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
|
|
||||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
|
||||||
paseoHome?: string;
|
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
deps?: {
|
|
||||||
scheduleAgentMetadataGeneration?: typeof scheduleAgentMetadataGeneration;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportProviderSessionResult {
|
export interface ImportProviderSessionResult {
|
||||||
@@ -168,17 +153,6 @@ export async function importProviderSession(
|
|||||||
labels,
|
labels,
|
||||||
});
|
});
|
||||||
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
|
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 {
|
return {
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -204,48 +178,6 @@ async function unarchiveAgentByHandle(
|
|||||||
await unarchiveAgentState(agentStorage, agentManager, matched.id);
|
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 {
|
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
|
||||||
if (!since) {
|
if (!since) {
|
||||||
return null;
|
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(
|
async function collectImportedProviderSessionHandles(
|
||||||
agentManager: Pick<AgentManager, "listAgents">,
|
agentManager: Pick<AgentManager, "listAgents">,
|
||||||
agentStorage: Pick<AgentStorage, "list">,
|
agentStorage: Pick<AgentStorage, "list">,
|
||||||
|
|||||||
@@ -944,7 +944,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
paseoHome: options.paseoHome,
|
paseoHome: options.paseoHome,
|
||||||
worktreesRoot: options.worktreesRoot,
|
worktreesRoot: options.worktreesRoot,
|
||||||
workspaceGitService: options.workspaceGitService,
|
|
||||||
terminalManager,
|
terminalManager,
|
||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
createPaseoWorktree: options.createPaseoWorktree,
|
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 () => {
|
test("emits sub-word tokens, reasoning, and sequential tool calls during a foreground turn", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = new MockLoadTestAgentClient();
|
const client = new MockLoadTestAgentClient();
|
||||||
|
|||||||
@@ -311,37 +311,6 @@ function parseStructuredBranchNamePrompt(
|
|||||||
return { title: title || "Mock task", branch };
|
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 {
|
function buildRepeatedPayload(bytes: number, prefix: string): string {
|
||||||
const line = `${prefix} ${"x".repeat(96)}\n`;
|
const line = `${prefix} ${"x".repeat(96)}\n`;
|
||||||
let output = "";
|
let output = "";
|
||||||
@@ -678,11 +647,8 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
const stress = parseAgentStreamStressPrompt(prompt);
|
const stress = parseAgentStreamStressPrompt(prompt);
|
||||||
const questionPrompt = parseMockQuestionPrompt(prompt);
|
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||||
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
const structuredBranchName = parseStructuredBranchNamePrompt(prompt);
|
||||||
const structuredAgentTitle = parseStructuredAgentTitlePrompt(prompt);
|
|
||||||
if (structuredBranchName) {
|
if (structuredBranchName) {
|
||||||
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
|
this.scheduleStructuredJsonTurn(turn, structuredBranchName);
|
||||||
} else if (structuredAgentTitle) {
|
|
||||||
this.scheduleStructuredJsonTurn(turn, structuredAgentTitle);
|
|
||||||
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
} else if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||||
this.schedulePlanApprovalTurn(turn);
|
this.schedulePlanApprovalTurn(turn);
|
||||||
} else if (questionPrompt) {
|
} else if (questionPrompt) {
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ const agentResponseMocks = vi.hoisted(() => ({
|
|||||||
generateStructuredAgentResponseWithFallback: vi.fn(),
|
generateStructuredAgentResponseWithFallback: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const agentMetadataMocks = vi.hoisted(() => ({
|
|
||||||
scheduleAgentMetadataGeneration: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const spawnMocks = vi.hoisted(() => ({
|
const spawnMocks = vi.hoisted(() => ({
|
||||||
execCommand: vi.fn(),
|
execCommand: vi.fn(),
|
||||||
spawnWorkspaceScript: 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) => {
|
vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("./worktree-bootstrap.js")>();
|
const actual = await importOriginal<typeof import("./worktree-bootstrap.js")>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3225,7 +3225,7 @@ export class Session {
|
|||||||
let createdAgentId: string | null = null;
|
let createdAgentId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const trimmedPrompt = initialPrompt?.trim();
|
const trimmedPrompt = initialPrompt?.trim();
|
||||||
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||||
configTitle: config.title,
|
configTitle: config.title,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
});
|
});
|
||||||
@@ -3259,9 +3259,7 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
worktreesRoot: this.worktreesRoot,
|
worktreesRoot: this.worktreesRoot,
|
||||||
workspaceGitService: this.workspaceGitService,
|
|
||||||
providerSnapshotManager: this.providerSnapshotManager,
|
providerSnapshotManager: this.providerSnapshotManager,
|
||||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "session",
|
kind: "session",
|
||||||
@@ -3277,7 +3275,6 @@ export class Session {
|
|||||||
labels,
|
labels,
|
||||||
env,
|
env,
|
||||||
provisionalTitle,
|
provisionalTitle,
|
||||||
explicitTitle,
|
|
||||||
firstAgentContext,
|
firstAgentContext,
|
||||||
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
|
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
|
||||||
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
|
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
|
||||||
@@ -3455,10 +3452,6 @@ export class Session {
|
|||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
agentManager: this.agentManager,
|
agentManager: this.agentManager,
|
||||||
agentStorage: this.agentStorage,
|
agentStorage: this.agentStorage,
|
||||||
workspaceGitService: this.workspaceGitService,
|
|
||||||
providerSnapshotManager: this.providerSnapshotManager,
|
|
||||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
|
||||||
paseoHome: this.paseoHome,
|
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
await this.registerWorkspaceForImportedAgent(workspace);
|
await this.registerWorkspaceForImportedAgent(workspace);
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
|||||||
["paseo.json valid but missing metadataGeneration", {}],
|
["paseo.json valid but missing metadataGeneration", {}],
|
||||||
[
|
[
|
||||||
"metadataGeneration exists but missing branchName",
|
"metadataGeneration exists but missing branchName",
|
||||||
{ metadataGeneration: { agentTitle: { instructions: "Use mb/." } } },
|
{ metadataGeneration: { commitMessage: { instructions: "Use Conventional Commits." } } },
|
||||||
],
|
],
|
||||||
["branchName exists but instructions is undefined", { metadataGeneration: { branchName: {} } }],
|
["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 { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
|
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
|
||||||
|
|
||||||
export type MetadataConfigKey = "agentTitle" | "branchName" | "commitMessage" | "pullRequest";
|
export type MetadataConfigKey = "branchName" | "commitMessage" | "pullRequest";
|
||||||
|
|
||||||
export interface RepoRootResolver {
|
export interface RepoRootResolver {
|
||||||
resolveRepoRoot: (cwd: string) => Promise<string>;
|
resolveRepoRoot: (cwd: string) => Promise<string>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Metadata generation
|
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
|
nav: Metadata generation
|
||||||
order: 42
|
order: 42
|
||||||
category: Configuration
|
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 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.
|
- **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.
|
- **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.
|
- **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
|
```json
|
||||||
{
|
{
|
||||||
"metadataGeneration": {
|
"metadataGeneration": {
|
||||||
"agentTitle": { "instructions": "Prefix titles with the area of the codebase." },
|
|
||||||
"branchName": { "instructions": "Use the format <type>/<scope>-<short-desc>." },
|
"branchName": { "instructions": "Use the format <type>/<scope>-<short-desc>." },
|
||||||
"commitMessage": { "instructions": "Follow Conventional Commits." },
|
"commitMessage": { "instructions": "Follow Conventional Commits." },
|
||||||
"pullRequest": { "instructions": "Include a Testing section in the body." }
|
"pullRequest": { "instructions": "Include a Testing section in the body." }
|
||||||
|
|||||||
Reference in New Issue
Block a user