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:
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
|||||||
@@ -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,
|
||||||
"Return JSON only with a single field 'message'.",
|
workspaceGitService: this.workspaceGitService,
|
||||||
"",
|
configKey: "commitMessage",
|
||||||
fileList,
|
before: "Write a concise git commit message for the changes below.",
|
||||||
"",
|
after: [
|
||||||
patch.length > 0 ? patch : "(No diff available)",
|
"Return JSON only with a single field 'message'.",
|
||||||
].join("\n");
|
"",
|
||||||
|
fileList,
|
||||||
|
"",
|
||||||
|
patch.length > 0 ? patch : "(No diff available)",
|
||||||
|
].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,
|
||||||
"Return JSON only with fields 'title' and 'body'.",
|
workspaceGitService: this.workspaceGitService,
|
||||||
"",
|
configKey: "pullRequest",
|
||||||
fileList,
|
before: "Write a pull request title and body for the changes below.",
|
||||||
"",
|
after: [
|
||||||
patch.length > 0 ? patch : "(No diff available)",
|
"Return JSON only with fields 'title' and 'body'.",
|
||||||
].join("\n");
|
"",
|
||||||
|
fileList,
|
||||||
|
"",
|
||||||
|
patch.length > 0 ? patch : "(No diff available)",
|
||||||
|
].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,
|
||||||
"Generate a git branch name for a coding agent based on the user prompt and attachments.",
|
options: {
|
||||||
"Branch: concise lowercase slug using letters, numbers, hyphens, and slashes only.",
|
cwd: string;
|
||||||
"No spaces, no uppercase, no leading or trailing hyphen, no consecutive hyphens.",
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||||
"Return JSON only with a single field 'branch'.",
|
},
|
||||||
"",
|
): Promise<string> {
|
||||||
"User context:",
|
return buildMetadataPrompt({
|
||||||
seed,
|
cwd: options.cwd,
|
||||||
].join("\n");
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
configKey: "branchName",
|
||||||
|
before: [
|
||||||
|
"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.",
|
||||||
|
].join("\n"),
|
||||||
|
after: "Return JSON only with a single field 'branch'.",
|
||||||
|
trailing: `User context:\n${seed}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user