feat: project-level prompts for metadata generation (#836)

* feat(server): project-level prompts for metadata generation

Add `metadataGeneration` config to `paseo.json` so projects can customize the
four LLM-generated metadata strings — agent title, worktree branch name, commit
message, and pull request title+body. Each entry takes an `instructions` string
that gets injected as a `<user-instructions>` block between the default rules
and the load-bearing JSON format contract, with wrapper text saying user
instructions override defaults. Missing/empty/invalid config keeps every prompt
byte-identical to today.

* refactor(server): centralize project metadata prompt building

Three near-identical readers and four gate-then-wrap blocks collapsed
into one buildMetadataPrompt helper. wrapWithUserInstructions tightened
to require a non-empty string; its misleading empty fallback is gone.

* feat(app): edit metadata generation prompts in project settings

Adds four textareas (agent title, branch name, commit message, pull
request) under project settings, modeled on the existing setup/teardown
pattern. Round-trips through paseo.json preserving unknown sibling
fields at both the metadataGeneration and entry level.
This commit is contained in:
Mohamed Boudra
2026-05-09 18:36:29 +08:00
committed by GitHub
parent 8b09846f98
commit 030e163fb8
16 changed files with 1240 additions and 39 deletions
@@ -31,7 +31,9 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { import {
applyDraftToConfig, applyDraftToConfig,
configToDraft, configToDraft,
METADATA_PROMPT_KEYS,
type LifecycleOriginalKind, type LifecycleOriginalKind,
type MetadataPromptKey,
type ProjectConfigDraft, type ProjectConfigDraft,
type ProjectScriptDraft, type ProjectScriptDraft,
} from "@/utils/project-config-form"; } from "@/utils/project-config-form";
@@ -42,6 +44,40 @@ const SCRIPT_SERVICE_TYPE = "service";
const ICON_SIZE = 14; const ICON_SIZE = 14;
interface MetadataPromptField {
title: string;
placeholder: string;
sectionTestID: string;
inputTestID: string;
}
const METADATA_PROMPT_FIELDS: Record<MetadataPromptKey, MetadataPromptField> = {
agentTitle: {
title: "Agent title prompt",
placeholder: "Keep titles short and action-oriented.",
sectionTestID: "metadata-prompt-agent-title-section",
inputTestID: "metadata-prompt-agent-title-input",
},
branchName: {
title: "Branch name prompt",
placeholder: "Prefix branches with feat/ or fix/.",
sectionTestID: "metadata-prompt-branch-name-section",
inputTestID: "metadata-prompt-branch-name-input",
},
commitMessage: {
title: "Commit message prompt",
placeholder: "Use Conventional Commits.",
sectionTestID: "metadata-prompt-commit-message-section",
inputTestID: "metadata-prompt-commit-message-input",
},
pullRequest: {
title: "Pull request prompt",
placeholder: "Include risk notes and a test plan.",
sectionTestID: "metadata-prompt-pull-request-section",
inputTestID: "metadata-prompt-pull-request-input",
},
};
const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host."; const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host.";
const HOST_SWITCHER_LABEL = "Switch host"; const HOST_SWITCHER_LABEL = "Switch host";
@@ -432,6 +468,15 @@ function ProjectConfigForm({
[updateDraft], [updateDraft],
); );
const handleMetadataPromptChange = useCallback(
(key: MetadataPromptKey, text: string) =>
updateDraft((d) => ({
...d,
metadataPrompts: { ...d.metadataPrompts, [key]: text },
})),
[updateDraft],
);
const handleRemoveScript = useCallback( const handleRemoveScript = useCallback(
async (script: ProjectScriptDraft) => { async (script: ProjectScriptDraft) => {
const ok = await confirmDialog({ const ok = await confirmDialog({
@@ -585,6 +630,15 @@ function ProjectConfigForm({
</View> </View>
</SettingsSection> </SettingsSection>
{METADATA_PROMPT_KEYS.map((key) => (
<MetadataPromptSection
key={key}
promptKey={key}
value={draft.metadataPrompts[key]}
onChange={handleMetadataPromptChange}
/>
))}
{isStale ? ( {isStale ? (
<View style={styles.calloutWrap}> <View style={styles.calloutWrap}>
<Alert <Alert
@@ -767,6 +821,36 @@ function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps)
); );
} }
interface MetadataPromptSectionProps {
promptKey: MetadataPromptKey;
value: string;
onChange: (key: MetadataPromptKey, text: string) => void;
}
function MetadataPromptSection({ promptKey, value, onChange }: MetadataPromptSectionProps) {
const meta = METADATA_PROMPT_FIELDS[promptKey];
const handleChange = useCallback(
(text: string) => onChange(promptKey, text),
[onChange, promptKey],
);
return (
<SettingsSection title={meta.title} testID={meta.sectionTestID}>
<View style={settingsStyles.card}>
<TextInput
testID={meta.inputTestID}
accessibilityLabel={meta.title}
multiline
value={value}
onChangeText={handleChange}
placeholder={meta.placeholder}
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
</SettingsSection>
);
}
interface ScriptRowProps { interface ScriptRowProps {
script: ProjectScriptDraft; script: ProjectScriptDraft;
isFirst: boolean; isFirst: boolean;
@@ -10,6 +10,13 @@ function emptyDraft(): ProjectConfigDraft {
teardownText: "", teardownText: "",
teardownOriginalKind: "missing", teardownOriginalKind: "missing",
scripts: [], scripts: [],
metadataPrompts: {
agentTitle: "",
branchName: "",
commitMessage: "",
pullRequest: "",
},
metadataGenerationBase: undefined,
}; };
} }
@@ -210,6 +217,100 @@ describe("applyDraftToConfig", () => {
expect(tunnel.port).toBe("auto"); expect(tunnel.port).toBe("auto");
}); });
it("reads metadata prompt instructions for all four keys", () => {
const draft = configToDraft({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
branchName: { instructions: "feat/<slug>" },
commitMessage: { instructions: "Conventional commits." },
pullRequest: { instructions: "Include risk notes." },
},
});
expect(draft.metadataPrompts).toEqual({
agentTitle: "Use mb/.",
branchName: "feat/<slug>",
commitMessage: "Conventional commits.",
pullRequest: "Include risk notes.",
});
});
it("defaults metadata prompts to empty strings when not present", () => {
const draft = configToDraft({
metadataGeneration: { agentTitle: { instructions: "Use mb/." } },
});
expect(draft.metadataPrompts).toEqual({
agentTitle: "Use mb/.",
branchName: "",
commitMessage: "",
pullRequest: "",
});
});
it("writes only metadata prompt entries with non-empty text", () => {
const base: PaseoConfigRaw = {};
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Use mb/.";
draft.metadataPrompts.commitMessage = "Conventional commits.";
const next = applyDraftToConfig({ draft, base });
expect(next.metadataGeneration).toEqual({
agentTitle: { instructions: "Use mb/." },
commitMessage: { instructions: "Conventional commits." },
});
});
it("drops the metadataGeneration field when all prompts are empty", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "";
const next = applyDraftToConfig({ draft, base });
expect(next.metadataGeneration).toBeUndefined();
});
it("preserves unknown sibling fields inside metadataGeneration on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/." },
futureField: 42,
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Use prefix mb/ on titles.";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).toEqual({ instructions: "Use prefix mb/ on titles." });
expect(metadata.futureField).toBe(42);
});
it("preserves unknown fields inside a metadata prompt entry on round-trip", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/.", model: "haiku" },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "Updated.";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).toEqual({ instructions: "Updated.", model: "haiku" });
});
it("clears instructions but preserves unknown sibling fields when text becomes empty", () => {
const base = PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use mb/.", model: "haiku" },
},
});
const draft = configToDraft(base);
draft.metadataPrompts.agentTitle = "";
const next = applyDraftToConfig({ draft, base });
const metadata = next.metadataGeneration as Record<string, unknown>;
expect(metadata.agentTitle).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", () => {
const base = PaseoConfigRawSchema.parse({ const base = PaseoConfigRawSchema.parse({
scripts: { scripts: {
+66 -1
View File
@@ -1,7 +1,20 @@
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@server/shared/messages"; import type {
PaseoConfigRaw,
PaseoMetadataGeneration,
PaseoMetadataGenerationEntry,
PaseoScriptEntryRaw,
} from "@server/shared/messages";
export type LifecycleOriginalKind = "string" | "array" | "missing"; export type LifecycleOriginalKind = "string" | "array" | "missing";
export const METADATA_PROMPT_KEYS = [
"agentTitle",
"branchName",
"commitMessage",
"pullRequest",
] as const;
export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number];
export interface ProjectScriptDraft { export interface ProjectScriptDraft {
id: string; id: string;
name: string; name: string;
@@ -18,6 +31,8 @@ export interface ProjectConfigDraft {
teardownText: string; teardownText: string;
teardownOriginalKind: LifecycleOriginalKind; teardownOriginalKind: LifecycleOriginalKind;
scripts: ProjectScriptDraft[]; scripts: ProjectScriptDraft[];
metadataPrompts: Record<MetadataPromptKey, string>;
metadataGenerationBase: PaseoMetadataGeneration | undefined;
} }
interface LifecycleProjection { interface LifecycleProjection {
@@ -88,6 +103,15 @@ function nextScriptDraftId(): string {
return `script-draft-${scriptDraftIdCounter}`; return `script-draft-${scriptDraftIdCounter}`;
} }
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
return {
agentTitle: "",
branchName: "",
commitMessage: "",
pullRequest: "",
};
}
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft { export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
const worktree = config?.worktree ?? {}; const worktree = config?.worktree ?? {};
const setup = projectLifecycle(worktree.setup); const setup = projectLifecycle(worktree.setup);
@@ -108,12 +132,23 @@ export function configToDraft(config: PaseoConfigRaw | null | undefined): Projec
}); });
} }
const metadataGeneration = config?.metadataGeneration;
const metadataPrompts = emptyMetadataPrompts();
for (const key of METADATA_PROMPT_KEYS) {
const instructions = metadataGeneration?.[key]?.instructions;
if (typeof instructions === "string") {
metadataPrompts[key] = instructions;
}
}
return { return {
setupText: setup.text, setupText: setup.text,
setupOriginalKind: setup.kind, setupOriginalKind: setup.kind,
teardownText: teardown.text, teardownText: teardown.text,
teardownOriginalKind: teardown.kind, teardownOriginalKind: teardown.kind,
scripts, scripts,
metadataPrompts,
metadataGenerationBase: metadataGeneration,
}; };
} }
@@ -172,6 +207,31 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw; nextScripts[trimmedName] = nextEntry as PaseoScriptEntryRaw;
} }
const nextMetadataGeneration: Record<string, unknown> = {
...input.draft.metadataGenerationBase,
};
for (const key of METADATA_PROMPT_KEYS) {
const text = input.draft.metadataPrompts[key];
const baseEntry = input.draft.metadataGenerationBase?.[key] as
| PaseoMetadataGenerationEntry
| undefined;
if (text.trim().length === 0) {
if (baseEntry) {
const nextEntry: Record<string, unknown> = { ...baseEntry };
delete nextEntry.instructions;
if (Object.keys(nextEntry).length === 0) {
delete nextMetadataGeneration[key];
} else {
nextMetadataGeneration[key] = nextEntry;
}
} else {
delete nextMetadataGeneration[key];
}
} else {
nextMetadataGeneration[key] = { ...baseEntry, instructions: text };
}
}
const result: Record<string, unknown> = { ...baseConfig }; const result: Record<string, unknown> = { ...baseConfig };
if (Object.keys(nextWorktree).length === 0) { if (Object.keys(nextWorktree).length === 0) {
delete result.worktree; delete result.worktree;
@@ -183,5 +243,10 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
} else { } else {
result.scripts = nextScripts; result.scripts = nextScripts;
} }
if (Object.keys(nextMetadataGeneration).length === 0) {
delete result.metadataGeneration;
} else {
result.metadataGeneration = nextMetadataGeneration;
}
return result as PaseoConfigRaw; return result as PaseoConfigRaw;
} }
@@ -9,6 +9,8 @@ import {
generateStructuredAgentResponseWithFallback, generateStructuredAgentResponseWithFallback,
} from "./agent-response-loop.js"; } from "./agent-response-loop.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
export interface AgentMetadataGeneratorDeps { export interface AgentMetadataGeneratorDeps {
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
@@ -18,6 +20,7 @@ export interface AgentMetadataGenerationOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentId: string; agentId: string;
cwd: string; cwd: string;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
initialPrompt?: string | null; initialPrompt?: string | null;
explicitTitle?: string | null; explicitTitle?: string | null;
paseoHome?: string; paseoHome?: string;
@@ -75,16 +78,26 @@ function buildMetadataSchema(
return z.object(shape); return z.object(shape);
} }
function buildPrompt(needs: AgentMetadataNeeds): string { async function buildPrompt(
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."]; 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) { if (needs.needsTitle) {
instructions.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`); beforeLines.push(`Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).`);
} }
instructions.push("Return JSON only with a single field 'title'.");
instructions.push("", "User prompt:", needs.prompt ?? ""); return buildMetadataPrompt({
return instructions.join("\n"); 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( export async function generateAndApplyAgentMetadata(
@@ -110,7 +123,10 @@ export async function generateAndApplyAgentMetadata(
result = await generator({ result = await generator({
manager: options.agentManager, manager: options.agentManager,
cwd: options.cwd, cwd: options.cwd,
prompt: buildPrompt(needs), prompt: await buildPrompt(needs, {
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
}),
schema, schema,
schemaName: "AgentMetadata", schemaName: "AgentMetadata",
maxRetries: 2, maxRetries: 2,
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest"; 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 { createTestLogger } from "../../test-utils/test-logger.js";
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import { import {
generateAndApplyAgentMetadata, generateAndApplyAgentMetadata,
@@ -9,6 +13,19 @@ import {
import type { AgentManager } from "./agent-manager.js"; import type { AgentManager } from "./agent-manager.js";
const logger = createTestLogger(); 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( function createDeps(
generateStructuredAgentResponseWithFallback: NonNullable< generateStructuredAgentResponseWithFallback: NonNullable<
@@ -93,4 +110,86 @@ describe("agent metadata generator auto-title", () => {
); );
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); expect(setTitle).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 setTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setTitle } 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),
};
}
@@ -79,7 +79,10 @@ export interface AgentMcpServerOptions {
scheduleService?: ScheduleService | null; scheduleService?: ScheduleService | null;
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null; providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
github?: GitHubService; github?: GitHubService;
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">; workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
@@ -847,6 +850,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
agentManager, agentManager,
agentId: snapshot.id, agentId: snapshot.id,
cwd: snapshot.cwd, cwd: snapshot.cwd,
workspaceGitService: options.workspaceGitService,
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title, explicitTitle: snapshot.config.title,
paseoHome: options.paseoHome, paseoHome: options.paseoHome,
+375
View File
@@ -10,6 +10,7 @@ import { CheckoutPrStatusSchema } from "../shared/messages.js";
import type { WorkspaceDescriptorPayload } from "../shared/messages.js"; import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js"; import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js"; import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { import type {
AgentClient, AgentClient,
AgentMode, AgentMode,
@@ -269,6 +270,7 @@ interface SessionForTestOptions {
validateBranchRef?: ReturnType<typeof vi.fn>; validateBranchRef?: ReturnType<typeof vi.fn>;
hasLocalBranch?: ReturnType<typeof vi.fn>; hasLocalBranch?: ReturnType<typeof vi.fn>;
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>; resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
resolveRepoRoot?: ReturnType<typeof vi.fn>;
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>; getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
}; };
workspaceRegistry?: { get: ReturnType<typeof vi.fn> }; workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
@@ -305,6 +307,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
validateBranchRef: vi.fn(), validateBranchRef: vi.fn(),
hasLocalBranch: vi.fn(), hasLocalBranch: vi.fn(),
resolveRepoRemoteUrl: vi.fn(), resolveRepoRemoteUrl: vi.fn(),
resolveRepoRoot: vi.fn(),
getWorkspaceGitMetadata: vi.fn(), getWorkspaceGitMetadata: vi.fn(),
}; };
const messages = options.messages ?? []; const messages = options.messages ?? [];
@@ -1638,6 +1641,78 @@ describe("session checkout merge handling", () => {
}); });
describe("session checkout commit handling", () => { describe("session checkout commit handling", () => {
const tempDirs: string[] = [];
const PRE_CHANGE_COMMIT_PROMPT = `Write a concise git commit message for the changes below.
Return JSON only with a single field 'message'.
Files changed:
M\tfile.txt\t(+1 -0)
diff --git a/file.txt b/file.txt
+hello
`;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "commit-metadata-session-test-")));
tempDirs.push(root);
return root;
}
function writeConfig(repoRoot: string, config: unknown): void {
writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
async function generateCommitPromptWithConfig(config: unknown): Promise<string> {
const repoRoot = makeRoot();
if (typeof config === "string") {
writeFileSync(join(repoRoot, "paseo.json"), config);
} else if (config !== undefined) {
writeConfig(repoRoot, config);
}
const workspaceGitService = {
getCheckoutDiff: vi.fn().mockResolvedValue({
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
structured: [
{
path: "file.txt",
additions: 1,
deletions: 0,
isNew: false,
isDeleted: false,
hunks: [],
status: "ok",
},
],
}),
getSnapshot: vi.fn().mockResolvedValue({}),
resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot),
};
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
message: "Update file",
});
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
const session = createSessionForTest({ workspaceGitService });
await asSessionInternals(session).handleCheckoutCommitRequest({
type: "checkout_commit_request",
cwd: join(repoRoot, "nested"),
message: "",
addAll: true,
requestId: "request-generated-commit",
});
return String(
agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0].prompt,
);
}
test("forces a workspace git snapshot refresh after committing", async () => { test("forces a workspace git snapshot refresh after committing", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() };
@@ -1737,6 +1812,100 @@ describe("session checkout commit handling", () => {
}); });
}); });
test.each([
["paseo.json missing", undefined],
["paseo.json exists but invalid JSON", "{ nope"],
["paseo.json valid but missing metadataGeneration", {}],
["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }],
[
"metadataGeneration exists but missing commitMessage",
{ metadataGeneration: { pullRequest: { instructions: "Write a punchy PR." } } },
],
[
"commitMessage exists but instructions is undefined",
{ metadataGeneration: { commitMessage: {} } },
],
[
"commitMessage exists but instructions is empty",
{ metadataGeneration: { commitMessage: { instructions: "" } } },
],
[
"commitMessage exists but instructions is whitespace-only",
{ metadataGeneration: { commitMessage: { instructions: " \n\t " } } },
],
])("keeps the pre-change commit prompt byte-identical when %s", async (_name, config) => {
const prompt = await generateCommitPromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_COMMIT_PROMPT);
});
test("injects commit instructions between the default rules and JSON contract", async () => {
const prompt = await generateCommitPromptWithConfig({
metadataGeneration: {
commitMessage: {
instructions: "Use conventional commits.\nAccept XML-ish <scope> text.",
},
},
});
const defaultRuleIndex = prompt.indexOf("Write a concise git commit message");
const openTagIndex = prompt.indexOf("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use conventional commits.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
const jsonContractIndex = prompt.indexOf("Return JSON only");
const fileListIndex = prompt.indexOf("Files changed:");
const patchIndex = prompt.indexOf("diff --git");
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(fileListIndex);
expect(fileListIndex).toBeLessThan(patchIndex);
});
test("keeps the commit fallback when structured generation fails", async () => {
const messages: unknown[] = [];
const workspaceGitService = {
getCheckoutDiff: vi.fn().mockResolvedValue({
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
structured: [],
}),
getSnapshot: vi.fn().mockResolvedValue({}),
resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()),
};
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue(
new StructuredAgentFallbackError([]),
);
checkoutGitMocks.commitChanges.mockResolvedValue(undefined);
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutCommitRequest({
type: "checkout_commit_request",
cwd: "/tmp/request-worktree",
message: "",
addAll: true,
requestId: "request-generated-commit-fallback",
});
expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", {
message: "Update files",
addAll: true,
});
expect(messages).toContainEqual({
type: "checkout_commit_response",
payload: {
cwd: "/tmp/request-worktree",
success: true,
error: null,
requestId: "request-generated-commit-fallback",
},
});
});
test("does not force a workspace git snapshot refresh when commit fails", async () => { test("does not force a workspace git snapshot refresh when commit fails", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
@@ -1768,6 +1937,85 @@ describe("session checkout commit handling", () => {
}); });
describe("session checkout pull request creation", () => { describe("session checkout pull request creation", () => {
const tempDirs: string[] = [];
const PRE_CHANGE_PULL_REQUEST_PROMPT = `Write a pull request title and body for the changes below.
Return JSON only with fields 'title' and 'body'.
Files changed:
M\tfile.txt\t(+1 -0)
diff --git a/file.txt b/file.txt
+hello
`;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRoot(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "pr-metadata-session-test-")));
tempDirs.push(root);
return root;
}
function writeConfig(repoRoot: string, config: unknown): void {
writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
async function generatePullRequestCallWithConfig(config: unknown): Promise<unknown> {
const repoRoot = makeRoot();
if (typeof config === "string") {
writeFileSync(join(repoRoot, "paseo.json"), config);
} else if (config !== undefined) {
writeConfig(repoRoot, config);
}
const workspaceGitService = {
getCheckoutDiff: vi.fn().mockResolvedValue({
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
structured: [
{
path: "file.txt",
additions: 1,
deletions: 0,
isNew: false,
isDeleted: false,
hunks: [],
status: "ok",
},
],
}),
resolveRepoRoot: vi.fn().mockResolvedValue(repoRoot),
};
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockResolvedValue({
title: "Update file",
body: "Updates file.",
});
checkoutGitMocks.createPullRequest.mockResolvedValue({
url: "https://github.com/getpaseo/paseo/pull/1",
number: 1,
});
const session = createSessionForTest({ workspaceGitService });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
type: "checkout_pr_create_request",
cwd: join(repoRoot, "nested"),
baseRef: "main",
title: "",
body: "",
requestId: "request-generated-pr",
});
return agentResponseMocks.generateStructuredAgentResponseWithFallback.mock.calls[0]?.[0];
}
async function generatePullRequestPromptWithConfig(config: unknown): Promise<string> {
const call = await generatePullRequestCallWithConfig(config);
return String((call as { prompt?: unknown } | undefined)?.prompt);
}
test("generates PR text from checkout diffs read through the workspace git service", async () => { test("generates PR text from checkout diffs read through the workspace git service", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const workspaceGitService = { const workspaceGitService = {
@@ -1841,6 +2089,133 @@ describe("session checkout pull request creation", () => {
}); });
}); });
test.each([
["paseo.json missing", undefined],
["paseo.json exists but invalid JSON", "{ nope"],
["paseo.json valid but missing metadataGeneration", {}],
["metadataGeneration is schema-invalid", { metadataGeneration: "not an object" }],
[
"metadataGeneration exists but missing pullRequest",
{ metadataGeneration: { commitMessage: { instructions: "Use conventional commits." } } },
],
[
"pullRequest exists but instructions is undefined",
{ metadataGeneration: { pullRequest: {} } },
],
[
"pullRequest exists but instructions is empty",
{ metadataGeneration: { pullRequest: { instructions: "" } } },
],
[
"pullRequest exists but instructions is whitespace-only",
{ metadataGeneration: { pullRequest: { instructions: " \n\t " } } },
],
])("keeps the pre-change PR prompt byte-identical when %s", async (_name, config) => {
const prompt = await generatePullRequestPromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_PULL_REQUEST_PROMPT);
});
test("injects PR instructions between the default rules and JSON contract", async () => {
const prompt = await generatePullRequestPromptWithConfig({
metadataGeneration: {
pullRequest: {
instructions: "Use a terse title.\nKeep literal <ticket> text.",
},
},
});
const defaultRuleIndex = prompt.indexOf("Write a pull request title and body");
const openTagIndex = prompt.indexOf("<user-instructions>");
const noticeIndex = prompt.indexOf("override the guidelines above");
const userInstructionIndex = prompt.indexOf("Use a terse title.");
const closeTagIndex = prompt.indexOf("</user-instructions>");
const jsonContractIndex = prompt.indexOf("Return JSON only");
const fileListIndex = prompt.indexOf("Files changed:");
const patchIndex = prompt.indexOf("diff --git");
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(fileListIndex);
expect(fileListIndex).toBeLessThan(patchIndex);
});
test("keeps PR generation as one structured call with title and body schema", async () => {
const call = await generatePullRequestCallWithConfig({
metadataGeneration: {
pullRequest: {
instructions: "Use release-note style.",
},
},
});
const schema = (call as { schema?: { safeParse?: (value: unknown) => { success: boolean } } })
.schema;
expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledTimes(1);
expect(call).toMatchObject({
schemaName: "PullRequest",
persistSession: false,
agentConfigOverrides: {
title: "PR generator",
internal: true,
},
});
expect(schema?.safeParse?.({ title: "Update file", body: "Updates file." }).success).toBe(true);
expect(schema?.safeParse?.({ title: "Update file" }).success).toBe(false);
});
test("keeps the PR fallback when structured generation fails", async () => {
const messages: unknown[] = [];
const workspaceGitService = {
getCheckoutDiff: vi.fn().mockResolvedValue({
diff: "diff --git a/file.txt b/file.txt\n+hello\n",
structured: [],
}),
resolveRepoRoot: vi.fn().mockResolvedValue(makeRoot()),
};
agentResponseMocks.generateStructuredAgentResponseWithFallback.mockRejectedValue(
new StructuredAgentFallbackError([]),
);
checkoutGitMocks.createPullRequest.mockResolvedValue({
url: "https://github.com/getpaseo/paseo/pull/9",
number: 9,
});
const session = createSessionForTest({ workspaceGitService, messages });
await asSessionInternals(session).handleCheckoutPrCreateRequest({
type: "checkout_pr_create_request",
cwd: "/tmp/request-worktree",
baseRef: "main",
title: "",
body: "",
requestId: "request-generated-pr-fallback",
});
expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith(
"/tmp/request-worktree",
{
title: "Update changes",
body: "Automated PR generated by Paseo.",
base: "main",
},
expect.anything(),
);
expect(messages).toContainEqual({
type: "checkout_pr_create_response",
payload: {
cwd: "/tmp/request-worktree",
url: "https://github.com/getpaseo/paseo/pull/9",
number: 9,
error: null,
requestId: "request-generated-pr-fallback",
},
});
});
test("forces workspace git and GitHub refresh after creating a pull request", async () => { test("forces workspace git and GitHub refresh after creating a pull request", async () => {
const messages: unknown[] = []; const messages: unknown[] = [];
const github = { invalidate: vi.fn() }; const github = { invalidate: vi.fn() };
+20 -6
View File
@@ -173,6 +173,7 @@ import {
writePaseoConfigForEdit, writePaseoConfigForEdit,
type ProjectConfigRpcError, type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js"; } from "../utils/paseo-config-file.js";
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js"; import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js"; import type { ScriptRouteStore } from "./script-proxy.js";
@@ -3110,6 +3111,7 @@ export class Session {
agentManager: this.agentManager, agentManager: this.agentManager,
agentId: snapshot.id, agentId: snapshot.id,
cwd: snapshot.cwd, cwd: snapshot.cwd,
workspaceGitService: this.workspaceGitService,
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
explicitTitle: params.explicitTitle, explicitTitle: params.explicitTitle,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
@@ -3284,6 +3286,7 @@ export class Session {
agentManager: this.agentManager, agentManager: this.agentManager,
agentId: snapshot.id, agentId: snapshot.id,
cwd: snapshot.cwd, cwd: snapshot.cwd,
workspaceGitService: this.workspaceGitService,
initialPrompt, initialPrompt,
explicitTitle, explicitTitle,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
@@ -3445,6 +3448,7 @@ export class Session {
return generateBranchNameFromFirstAgentContext({ return generateBranchNameFromFirstAgentContext({
agentManager: this.agentManager, agentManager: this.agentManager,
cwd, cwd,
workspaceGitService: this.workspaceGitService,
firstAgentContext, firstAgentContext,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
@@ -3906,14 +3910,19 @@ export class Session {
diff.diff.length > maxPatchChars diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff; : diff.diff;
const prompt = [ const prompt = await buildMetadataPrompt({
"Write a concise git commit message for the changes below.", cwd,
workspaceGitService: this.workspaceGitService,
configKey: "commitMessage",
before: "Write a concise git commit message for the changes below.",
after: [
"Return JSON only with a single field 'message'.", "Return JSON only with a single field 'message'.",
"", "",
fileList, fileList,
"", "",
patch.length > 0 ? patch : "(No diff available)", patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n"),
});
try { try {
const result = await generateStructuredAgentResponseWithFallback({ const result = await generateStructuredAgentResponseWithFallback({
manager: this.agentManager, manager: this.agentManager,
@@ -3973,14 +3982,19 @@ export class Session {
diff.diff.length > maxPatchChars diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n` ? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff; : diff.diff;
const prompt = [ const prompt = await buildMetadataPrompt({
"Write a pull request title and body for the changes below.", cwd,
workspaceGitService: this.workspaceGitService,
configKey: "pullRequest",
before: "Write a pull request title and body for the changes below.",
after: [
"Return JSON only with fields 'title' and 'body'.", "Return JSON only with fields 'title' and 'body'.",
"", "",
fileList, fileList,
"", "",
patch.length > 0 ? patch : "(No diff available)", patch.length > 0 ? patch : "(No diff available)",
].join("\n"); ].join("\n"),
});
try { try {
return await generateStructuredAgentResponseWithFallback({ return await generateStructuredAgentResponseWithFallback({
manager: this.agentManager, manager: this.agentManager,
@@ -1,7 +1,34 @@
import { describe, expect, test, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentManager } from "./agent/agent-manager.js";
import {
attemptFirstAgentBranchAutoName,
type AttemptFirstAgentBranchAutoNameResult,
} from "./paseo-worktree-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js"; import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
import {
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
const cleanupPaths: string[] = [];
const PRE_CHANGE_BRANCH_PROMPT = `Generate a git branch name for a coding agent based on the user prompt and attachments.
Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.
No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.
Return JSON only with a single field 'branch'.
User context:
Fix the login flow`;
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
function createLogger() { function createLogger() {
return { return {
@@ -60,4 +87,129 @@ describe("generateBranchNameFromFirstAgentContext", () => {
expect(branch).toBe("review-flaky-checkout"); expect(branch).toBe("review-flaky-checkout");
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout"); expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout");
}); });
test.each([
["paseo.json missing", undefined],
["paseo.json exists but invalid JSON", "{ nope"],
["paseo.json valid but missing metadataGeneration", {}],
[
"metadataGeneration exists but missing branchName",
{ metadataGeneration: { agentTitle: { instructions: "Use mb/." } } },
],
["branchName exists but instructions is undefined", { metadataGeneration: { branchName: {} } }],
[
"branchName exists but instructions is empty",
{ metadataGeneration: { branchName: { instructions: "" } } },
],
[
"branchName exists but instructions is whitespace-only",
{ metadataGeneration: { branchName: { instructions: " \n\t " } } },
],
])("keeps the pre-change prompt byte-identical when %s", async (_name, config) => {
const { prompt } = await generateBranchPromptWithConfig(config);
expect(prompt).toBe(PRE_CHANGE_BRANCH_PROMPT);
}); });
test("injects project instructions between the default rules and JSON contract", async () => {
const { prompt } = await generateBranchPromptWithConfig({
metadataGeneration: {
branchName: {
instructions: "Use the prefix mb/.",
},
},
});
const defaultRuleIndex = prompt.indexOf("No spaces, no uppercase");
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 context:");
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);
});
test("keeps the branch slug validator fallback when instructions are present", async () => {
const repoRoot = createTempDir("paseo-branch-config-");
const worktreeRoot = createTempDir("paseo-branch-worktree-");
mkdirSync(path.join(worktreeRoot, ".git"));
writePaseoWorktreeMetadata(worktreeRoot, { baseRefName: "main" });
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(worktreeRoot, {
placeholderBranchName: "dazzling-yak",
});
writeConfig(repoRoot, {
metadataGeneration: {
branchName: {
instructions: "Use the prefix mb/.",
},
},
});
const generateStructured = vi.fn(async () => ({ branch: "Invalid Branch Name" }));
const renameCurrentBranch = vi.fn(async () => ({ currentBranch: "Invalid Branch Name" }));
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
cwd: worktreeRoot,
firstAgentContext: { prompt: "Fix the login flow" },
generateBranchNameFromContext: ({ cwd, firstAgentContext }) =>
generateBranchNameFromFirstAgentContext({
agentManager: {} as AgentManager,
cwd,
workspaceGitService: createNoopWorkspaceGitService({
resolveRepoRoot: async () => repoRoot,
}),
firstAgentContext,
logger: createLogger(),
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
}),
getCurrentBranch: async () => "dazzling-yak",
renameCurrentBranch,
});
expect(result).toEqual({ attempted: true, renamed: false, branchName: null });
expect(renameCurrentBranch).not.toHaveBeenCalled();
});
});
async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt: string }> {
const repoRoot = createTempDir("paseo-branch-config-");
if (typeof config === "string") {
writeFileSync(path.join(repoRoot, "paseo.json"), config);
} else if (config !== undefined) {
writeConfig(repoRoot, config);
}
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
await generateBranchNameFromFirstAgentContext({
agentManager: {} as AgentManager,
cwd: path.join(repoRoot, "nested"),
workspaceGitService: createNoopWorkspaceGitService({
resolveRepoRoot: async () => repoRoot,
}),
firstAgentContext: { prompt: "Fix the login flow" },
logger: createLogger(),
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
});
return {
prompt: String(generateStructured.mock.calls[0]?.[0].prompt),
};
}
function createTempDir(prefix: string): string {
const tempDir = mkdtempSync(path.join(tmpdir(), prefix));
cleanupPaths.push(tempDir);
return tempDir;
}
function writeConfig(repoRoot: string, config: unknown): void {
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
@@ -8,6 +8,8 @@ import {
generateStructuredAgentResponseWithFallback, generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js"; } from "./agent/agent-response-loop.js";
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
interface BranchNameGeneratorLogger { interface BranchNameGeneratorLogger {
warn: (obj: object, msg?: string) => void; warn: (obj: object, msg?: string) => void;
@@ -17,6 +19,7 @@ interface BranchNameGeneratorLogger {
export interface GenerateBranchNameFromFirstAgentContextOptions { export interface GenerateBranchNameFromFirstAgentContextOptions {
agentManager: AgentManager; agentManager: AgentManager;
cwd: string; cwd: string;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
firstAgentContext: FirstAgentContext | undefined; firstAgentContext: FirstAgentContext | undefined;
logger: BranchNameGeneratorLogger; logger: BranchNameGeneratorLogger;
deps?: { deps?: {
@@ -28,16 +31,25 @@ const BranchNameSchema = z.object({
branch: z.string().min(1).max(100), branch: z.string().min(1).max(100),
}); });
function buildPrompt(seed: string): string { async function buildPrompt(
return [ seed: string,
options: {
cwd: string;
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
},
): Promise<string> {
return buildMetadataPrompt({
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
configKey: "branchName",
before: [
"Generate a git branch name for a coding agent based on the user prompt and attachments.", "Generate a git branch name for a coding agent based on the user prompt and attachments.",
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.", "Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.", "No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
"Return JSON only with a single field 'branch'.", ].join("\n"),
"", after: "Return JSON only with a single field 'branch'.",
"User context:", trailing: `User context:\n${seed}`,
seed, });
].join("\n");
} }
export async function generateBranchNameFromFirstAgentContext( export async function generateBranchNameFromFirstAgentContext(
@@ -56,7 +68,10 @@ export async function generateBranchNameFromFirstAgentContext(
const result = await generator({ const result = await generator({
manager: options.agentManager, manager: options.agentManager,
cwd: options.cwd, cwd: options.cwd,
prompt: buildPrompt(seed), prompt: await buildPrompt(seed, {
cwd: options.cwd,
workspaceGitService: options.workspaceGitService,
}),
schema: BranchNameSchema, schema: BranchNameSchema,
schemaName: "BranchName", schemaName: "BranchName",
maxRetries: 2, maxRetries: 2,
+8
View File
@@ -55,22 +55,30 @@ import {
import { import {
PaseoConfigRawSchema, PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema, PaseoLifecycleCommandRawSchema,
PaseoMetadataGenerationEntrySchema,
PaseoMetadataGenerationSchema,
PaseoScriptEntryRawSchema, PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema, PaseoWorktreeConfigRawSchema,
PaseoConfigRevisionSchema, PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema, ProjectConfigRpcErrorSchema,
type PaseoConfigRaw, type PaseoConfigRaw,
type PaseoConfigRevision, type PaseoConfigRevision,
type PaseoMetadataGeneration,
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw, type PaseoScriptEntryRaw,
type ProjectConfigRpcError, type ProjectConfigRpcError,
} from "../utils/paseo-config-schema.js"; } from "../utils/paseo-config-schema.js";
export { export {
PaseoConfigRawSchema, PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema, PaseoLifecycleCommandRawSchema,
PaseoMetadataGenerationEntrySchema,
PaseoMetadataGenerationSchema,
PaseoScriptEntryRawSchema, PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema, PaseoWorktreeConfigRawSchema,
type PaseoConfigRaw, type PaseoConfigRaw,
type PaseoConfigRevision, type PaseoConfigRevision,
type PaseoMetadataGeneration,
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw, type PaseoScriptEntryRaw,
type ProjectConfigRpcError, type ProjectConfigRpcError,
}; };
@@ -0,0 +1,46 @@
import { readPaseoConfigJson } from "./paseo-config-file.js";
import { PaseoConfigSchema } from "./paseo-config-schema.js";
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
export type MetadataConfigKey = "agentTitle" | "branchName" | "commitMessage" | "pullRequest";
export interface RepoRootResolver {
resolveRepoRoot: (cwd: string) => Promise<string>;
}
export interface BuildMetadataPromptOptions {
cwd: string;
configKey: MetadataConfigKey;
before: string;
after: string;
trailing?: string;
workspaceGitService?: RepoRootResolver;
}
export async function buildMetadataPrompt(options: BuildMetadataPromptOptions): Promise<string> {
const instructions = await readProjectMetadataInstructions(options);
const head = isNonEmptyString(instructions)
? wrapWithUserInstructions(options.before, instructions, options.after)
: `${options.before}\n${options.after}`;
return options.trailing ? `${head}\n\n${options.trailing}` : head;
}
async function readProjectMetadataInstructions(
options: Pick<BuildMetadataPromptOptions, "cwd" | "configKey" | "workspaceGitService">,
): Promise<string | undefined> {
if (!options.workspaceGitService) {
return undefined;
}
try {
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
const json = readPaseoConfigJson(repoRoot);
const config = PaseoConfigSchema.parse(json);
return config.metadataGeneration?.[options.configKey]?.instructions;
} catch {
return undefined;
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import { PaseoConfigRawSchema, PaseoConfigSchema } from "./paseo-config-schema.js";
describe("paseo config schema", () => {
it("parses an empty config without metadata generation", () => {
const parsed = PaseoConfigSchema.parse({});
expect(parsed).toEqual({});
expect(parsed.metadataGeneration).toBeUndefined();
});
it("parses old-style worktree and scripts config unchanged", () => {
const config = {
worktree: {
setup: "npm install",
teardown: ["npm run clean"],
},
scripts: {
dev: {
type: "service",
command: "npm run dev",
port: 5173,
},
},
};
expect(PaseoConfigSchema.parse(config)).toEqual({
worktree: {
setup: ["npm install"],
teardown: ["npm run clean"],
},
scripts: config.scripts,
});
});
it("parses all metadata generation instruction entries", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
branchName: { instructions: "Prefix branches with feat/." },
commitMessage: { instructions: "Use imperative mood." },
pullRequest: { instructions: "Include risk notes." },
},
});
});
it("parses partial metadata generation instructions with missing entries undefined", () => {
const parsed = PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Keep it short." },
},
});
expect(parsed.metadataGeneration).toEqual({
agentTitle: { instructions: "Keep it short." },
});
expect(parsed.metadataGeneration?.branchName).toBeUndefined();
expect(parsed.metadataGeneration?.commitMessage).toBeUndefined();
expect(parsed.metadataGeneration?.pullRequest).toBeUndefined();
});
it("passes through unknown metadata generation fields", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
}),
).toEqual({
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
futureField: 42,
},
});
});
it("passes through unknown metadata generator entry fields", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: {
instructions: "Use concise titles.",
model: "haiku",
},
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {
instructions: "Use concise titles.",
model: "haiku",
},
},
});
});
it("falls back to an empty metadata generator entry when instructions has an invalid type", () => {
expect(
PaseoConfigSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
},
});
});
it("raw schema preserves old-style config while accepting metadata generation", () => {
const config = {
worktree: {
setup: "npm install",
teardown: ["npm run clean"],
},
scripts: {
dev: {
type: "service",
command: "npm run dev",
},
},
metadataGeneration: {
agentTitle: { instructions: "Use concise titles." },
},
};
expect(PaseoConfigRawSchema.parse(config)).toEqual(config);
});
it("raw schema falls back to an empty metadata generator entry when instructions has an invalid type", () => {
expect(
PaseoConfigRawSchema.parse({
metadataGeneration: {
agentTitle: { instructions: 42 },
},
}),
).toEqual({
metadataGeneration: {
agentTitle: {},
},
});
});
});
@@ -30,10 +30,28 @@ export const PaseoWorktreeConfigRawSchema = z
}) })
.passthrough(); .passthrough();
export const PaseoMetadataGenerationEntrySchema = z
.object({
instructions: z.string().optional(),
})
.passthrough()
.catch({});
export const PaseoMetadataGenerationSchema = z
.object({
agentTitle: PaseoMetadataGenerationEntrySchema.optional(),
branchName: PaseoMetadataGenerationEntrySchema.optional(),
commitMessage: PaseoMetadataGenerationEntrySchema.optional(),
pullRequest: PaseoMetadataGenerationEntrySchema.optional(),
})
.passthrough()
.catch({});
export const PaseoConfigRawSchema = z export const PaseoConfigRawSchema = z
.object({ .object({
worktree: PaseoWorktreeConfigRawSchema.optional(), worktree: PaseoWorktreeConfigRawSchema.optional(),
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(), scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
}) })
.passthrough(); .passthrough();
@@ -49,6 +67,7 @@ export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({ export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
worktree: WorktreeConfigSchema.optional(), worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}), scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
}) })
.passthrough() .passthrough()
.catch({}); .catch({});
@@ -69,6 +88,8 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
]); ]);
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>; export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoMetadataGenerationEntry = z.infer<typeof PaseoMetadataGenerationEntrySchema>;
export type PaseoMetadataGeneration = z.infer<typeof PaseoMetadataGenerationSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>; export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>; export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>; export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { wrapWithUserInstructions } from "./wrap-user-instructions.js";
const beforeBlock = "Follow the default metadata guidelines.";
const afterBlock = 'Return JSON only with field "title".';
const overrideNotice =
"The instructions below are provided by the project owner and override the guidelines above where they conflict.";
describe("wrapWithUserInstructions", () => {
it("wraps user instructions with the override notice", () => {
expect(wrapWithUserInstructions(beforeBlock, "Use conventional commits.", afterBlock)).toBe(
`${beforeBlock}
<user-instructions>
${overrideNotice}
Use conventional commits.
</user-instructions>
${afterBlock}`,
);
});
it("preserves multi-line instructions verbatim inside the block", () => {
const output = wrapWithUserInstructions(beforeBlock, "line1\nline2", afterBlock);
expect(output).toContain("line1\nline2");
});
});
@@ -0,0 +1,18 @@
const USER_INSTRUCTIONS_NOTICE =
"The instructions below are provided by the project owner and override the guidelines above where they conflict.";
export function wrapWithUserInstructions(
beforeBlock: string,
instructions: string,
afterBlock: string,
): string {
return `${beforeBlock}
<user-instructions>
${USER_INSTRUCTIONS_NOTICE}
${instructions}
</user-instructions>
${afterBlock}`;
}