mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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 {
|
||||
applyDraftToConfig,
|
||||
configToDraft,
|
||||
METADATA_PROMPT_KEYS,
|
||||
type LifecycleOriginalKind,
|
||||
type MetadataPromptKey,
|
||||
type ProjectConfigDraft,
|
||||
type ProjectScriptDraft,
|
||||
} from "@/utils/project-config-form";
|
||||
@@ -42,6 +44,40 @@ const SCRIPT_SERVICE_TYPE = "service";
|
||||
|
||||
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 HOST_SWITCHER_LABEL = "Switch host";
|
||||
@@ -432,6 +468,15 @@ function ProjectConfigForm({
|
||||
[updateDraft],
|
||||
);
|
||||
|
||||
const handleMetadataPromptChange = useCallback(
|
||||
(key: MetadataPromptKey, text: string) =>
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
metadataPrompts: { ...d.metadataPrompts, [key]: text },
|
||||
})),
|
||||
[updateDraft],
|
||||
);
|
||||
|
||||
const handleRemoveScript = useCallback(
|
||||
async (script: ProjectScriptDraft) => {
|
||||
const ok = await confirmDialog({
|
||||
@@ -585,6 +630,15 @@ function ProjectConfigForm({
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{METADATA_PROMPT_KEYS.map((key) => (
|
||||
<MetadataPromptSection
|
||||
key={key}
|
||||
promptKey={key}
|
||||
value={draft.metadataPrompts[key]}
|
||||
onChange={handleMetadataPromptChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isStale ? (
|
||||
<View style={styles.calloutWrap}>
|
||||
<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 {
|
||||
script: ProjectScriptDraft;
|
||||
isFirst: boolean;
|
||||
|
||||
@@ -10,6 +10,13 @@ function emptyDraft(): ProjectConfigDraft {
|
||||
teardownText: "",
|
||||
teardownOriginalKind: "missing",
|
||||
scripts: [],
|
||||
metadataPrompts: {
|
||||
agentTitle: "",
|
||||
branchName: "",
|
||||
commitMessage: "",
|
||||
pullRequest: "",
|
||||
},
|
||||
metadataGenerationBase: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +217,100 @@ describe("applyDraftToConfig", () => {
|
||||
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", () => {
|
||||
const base = PaseoConfigRawSchema.parse({
|
||||
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 const METADATA_PROMPT_KEYS = [
|
||||
"agentTitle",
|
||||
"branchName",
|
||||
"commitMessage",
|
||||
"pullRequest",
|
||||
] as const;
|
||||
export type MetadataPromptKey = (typeof METADATA_PROMPT_KEYS)[number];
|
||||
|
||||
export interface ProjectScriptDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -18,6 +31,8 @@ export interface ProjectConfigDraft {
|
||||
teardownText: string;
|
||||
teardownOriginalKind: LifecycleOriginalKind;
|
||||
scripts: ProjectScriptDraft[];
|
||||
metadataPrompts: Record<MetadataPromptKey, string>;
|
||||
metadataGenerationBase: PaseoMetadataGeneration | undefined;
|
||||
}
|
||||
|
||||
interface LifecycleProjection {
|
||||
@@ -88,6 +103,15 @@ function nextScriptDraftId(): string {
|
||||
return `script-draft-${scriptDraftIdCounter}`;
|
||||
}
|
||||
|
||||
function emptyMetadataPrompts(): Record<MetadataPromptKey, string> {
|
||||
return {
|
||||
agentTitle: "",
|
||||
branchName: "",
|
||||
commitMessage: "",
|
||||
pullRequest: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function configToDraft(config: PaseoConfigRaw | null | undefined): ProjectConfigDraft {
|
||||
const worktree = config?.worktree ?? {};
|
||||
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 {
|
||||
setupText: setup.text,
|
||||
setupOriginalKind: setup.kind,
|
||||
teardownText: teardown.text,
|
||||
teardownOriginalKind: teardown.kind,
|
||||
scripts,
|
||||
metadataPrompts,
|
||||
metadataGenerationBase: metadataGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,6 +207,31 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
|
||||
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 };
|
||||
if (Object.keys(nextWorktree).length === 0) {
|
||||
delete result.worktree;
|
||||
@@ -183,5 +243,10 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
|
||||
} else {
|
||||
result.scripts = nextScripts;
|
||||
}
|
||||
if (Object.keys(nextMetadataGeneration).length === 0) {
|
||||
delete result.metadataGeneration;
|
||||
} else {
|
||||
result.metadataGeneration = nextMetadataGeneration;
|
||||
}
|
||||
return result as PaseoConfigRaw;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.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 {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
@@ -18,6 +20,7 @@ export interface AgentMetadataGenerationOptions {
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
initialPrompt?: string | null;
|
||||
explicitTitle?: string | null;
|
||||
paseoHome?: string;
|
||||
@@ -75,16 +78,26 @@ function buildMetadataSchema(
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
function buildPrompt(needs: AgentMetadataNeeds): string {
|
||||
const instructions: string[] = ["Generate metadata for a coding agent based on the user prompt."];
|
||||
|
||||
async function buildPrompt(
|
||||
needs: AgentMetadataNeeds,
|
||||
options: {
|
||||
cwd: string;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
},
|
||||
): Promise<string> {
|
||||
const beforeLines: string[] = ["Generate metadata for a coding agent based on the user prompt."];
|
||||
if (needs.needsTitle) {
|
||||
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 instructions.join("\n");
|
||||
return buildMetadataPrompt({
|
||||
cwd: options.cwd,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
configKey: "agentTitle",
|
||||
before: beforeLines.join("\n"),
|
||||
after: "Return JSON only with a single field 'title'.",
|
||||
trailing: `User prompt:\n${needs.prompt ?? ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateAndApplyAgentMetadata(
|
||||
@@ -110,7 +123,10 @@ export async function generateAndApplyAgentMetadata(
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(needs),
|
||||
prompt: await buildPrompt(needs, {
|
||||
cwd: options.cwd,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
}),
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
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 { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
|
||||
import {
|
||||
generateAndApplyAgentMetadata,
|
||||
@@ -9,6 +13,19 @@ import {
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
const cleanupPaths: string[] = [];
|
||||
const PRE_CHANGE_TITLE_PROMPT = `Generate metadata for a coding agent based on the user prompt.
|
||||
Title: short descriptive label (<= ${MAX_AUTO_AGENT_TITLE_CHARS} chars).
|
||||
Return JSON only with a single field 'title'.
|
||||
|
||||
User prompt:
|
||||
Implement this feature`;
|
||||
|
||||
afterEach(() => {
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createDeps(
|
||||
generateStructuredAgentResponseWithFallback: NonNullable<
|
||||
@@ -93,4 +110,86 @@ describe("agent metadata generator auto-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;
|
||||
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
||||
github?: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
workspaceGitService?: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
|
||||
>;
|
||||
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
|
||||
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
|
||||
@@ -847,6 +850,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: options.paseoHome,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
||||
import type { WorkspaceDescriptorPayload } from "../shared/messages.js";
|
||||
import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js";
|
||||
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
||||
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
@@ -269,6 +270,7 @@ interface SessionForTestOptions {
|
||||
validateBranchRef?: ReturnType<typeof vi.fn>;
|
||||
hasLocalBranch?: ReturnType<typeof vi.fn>;
|
||||
resolveRepoRemoteUrl?: ReturnType<typeof vi.fn>;
|
||||
resolveRepoRoot?: ReturnType<typeof vi.fn>;
|
||||
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
|
||||
@@ -305,6 +307,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
validateBranchRef: vi.fn(),
|
||||
hasLocalBranch: vi.fn(),
|
||||
resolveRepoRemoteUrl: vi.fn(),
|
||||
resolveRepoRoot: vi.fn(),
|
||||
getWorkspaceGitMetadata: vi.fn(),
|
||||
};
|
||||
const messages = options.messages ?? [];
|
||||
@@ -1638,6 +1641,78 @@ describe("session checkout merge 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 () => {
|
||||
const messages: unknown[] = [];
|
||||
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 () => {
|
||||
const messages: unknown[] = [];
|
||||
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
|
||||
@@ -1768,6 +1937,85 @@ describe("session checkout commit handling", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const messages: unknown[] = [];
|
||||
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 () => {
|
||||
const messages: unknown[] = [];
|
||||
const github = { invalidate: vi.fn() };
|
||||
|
||||
@@ -173,6 +173,7 @@ import {
|
||||
writePaseoConfigForEdit,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../utils/paseo-config-file.js";
|
||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
@@ -3110,6 +3111,7 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: params.explicitTitle,
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -3284,6 +3286,7 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
initialPrompt,
|
||||
explicitTitle,
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -3445,6 +3448,7 @@ export class Session {
|
||||
return generateBranchNameFromFirstAgentContext({
|
||||
agentManager: this.agentManager,
|
||||
cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
firstAgentContext,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
@@ -3906,14 +3910,19 @@ export class Session {
|
||||
diff.diff.length > maxPatchChars
|
||||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||
: diff.diff;
|
||||
const prompt = [
|
||||
"Write a concise git commit message for the changes below.",
|
||||
"Return JSON only with a single field 'message'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
const prompt = await buildMetadataPrompt({
|
||||
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'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
@@ -3973,14 +3982,19 @@ export class Session {
|
||||
diff.diff.length > maxPatchChars
|
||||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||
: diff.diff;
|
||||
const prompt = [
|
||||
"Write a pull request title and body for the changes below.",
|
||||
"Return JSON only with fields 'title' and 'body'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n");
|
||||
const prompt = await buildMetadataPrompt({
|
||||
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'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
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 {
|
||||
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 {
|
||||
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() {
|
||||
return {
|
||||
@@ -60,4 +87,129 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
expect(branch).toBe("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,
|
||||
} from "./agent/agent-response-loop.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 {
|
||||
warn: (obj: object, msg?: string) => void;
|
||||
@@ -17,6 +19,7 @@ interface BranchNameGeneratorLogger {
|
||||
export interface GenerateBranchNameFromFirstAgentContextOptions {
|
||||
agentManager: AgentManager;
|
||||
cwd: string;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
logger: BranchNameGeneratorLogger;
|
||||
deps?: {
|
||||
@@ -28,16 +31,25 @@ const BranchNameSchema = z.object({
|
||||
branch: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
function buildPrompt(seed: string): string {
|
||||
return [
|
||||
"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:",
|
||||
seed,
|
||||
].join("\n");
|
||||
async function buildPrompt(
|
||||
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.",
|
||||
"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(
|
||||
@@ -56,7 +68,10 @@ export async function generateBranchNameFromFirstAgentContext(
|
||||
const result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
prompt: buildPrompt(seed),
|
||||
prompt: await buildPrompt(seed, {
|
||||
cwd: options.cwd,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
}),
|
||||
schema: BranchNameSchema,
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
|
||||
@@ -55,22 +55,30 @@ import {
|
||||
import {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
PaseoMetadataGenerationEntrySchema,
|
||||
PaseoMetadataGenerationSchema,
|
||||
PaseoScriptEntryRawSchema,
|
||||
PaseoWorktreeConfigRawSchema,
|
||||
PaseoConfigRevisionSchema,
|
||||
ProjectConfigRpcErrorSchema,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type PaseoMetadataGeneration,
|
||||
type PaseoMetadataGenerationEntry,
|
||||
type PaseoScriptEntryRaw,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../utils/paseo-config-schema.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
PaseoMetadataGenerationEntrySchema,
|
||||
PaseoMetadataGenerationSchema,
|
||||
PaseoScriptEntryRawSchema,
|
||||
PaseoWorktreeConfigRawSchema,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type PaseoMetadataGeneration,
|
||||
type PaseoMetadataGenerationEntry,
|
||||
type PaseoScriptEntryRaw,
|
||||
type ProjectConfigRpcError,
|
||||
};
|
||||
|
||||
46
packages/server/src/utils/build-metadata-prompt.ts
Normal file
46
packages/server/src/utils/build-metadata-prompt.ts
Normal file
@@ -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() !== "";
|
||||
}
|
||||
154
packages/server/src/utils/paseo-config-schema.test.ts
Normal file
154
packages/server/src/utils/paseo-config-schema.test.ts
Normal file
@@ -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();
|
||||
|
||||
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
|
||||
.object({
|
||||
worktree: PaseoWorktreeConfigRawSchema.optional(),
|
||||
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
|
||||
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -49,6 +67,7 @@ export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
|
||||
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
|
||||
worktree: WorktreeConfigSchema.optional(),
|
||||
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
|
||||
metadataGeneration: PaseoMetadataGenerationSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.catch({});
|
||||
@@ -69,6 +88,8 @@ export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
|
||||
]);
|
||||
|
||||
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 PaseoConfig = z.infer<typeof PaseoConfigSchema>;
|
||||
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
|
||||
|
||||
29
packages/server/src/utils/wrap-user-instructions.test.ts
Normal file
29
packages/server/src/utils/wrap-user-instructions.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
18
packages/server/src/utils/wrap-user-instructions.ts
Normal file
18
packages/server/src/utils/wrap-user-instructions.ts
Normal file
@@ -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