mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(server): extract git-metadata generators into checkout module (#1702)
The LLM-backed commit-message and PR-text generators lived in the 6.6k-line session god-file but were consumed only by CheckoutSession, which reached them through two host callbacks (the host comment even flagged that it "does not own them"). The two methods were ~80% identical. Lift them into session/checkout/git-metadata-generator.ts as a deep module with a two-method interface, collapsing the duplicated diff -> fileList -> patch -> prompt -> generate -> fallback scaffold. The LLM call is now an injected StructuredTextGeneration port: production wires resolve-providers + structured generation; the new unit test injects a fake and exercises success, both fallback paths, and error rethrow without mocking any module. CheckoutSession takes the generator as a typed collaborator instead of two host callbacks; Session sheds ~160 lines plus five now-unused imports. Behaviour is unchanged — the existing session.test.ts generation tests pass untouched.
This commit is contained in:
@@ -4,7 +4,6 @@ import type { FSWatcher } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type { ToolSet } from "ai";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import {
|
||||
@@ -102,15 +101,7 @@ import {
|
||||
type TimelineProjectionEntry,
|
||||
type TimelineProjectionMode,
|
||||
} from "./agent/timeline-projection.js";
|
||||
import {
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
resolveStructuredGenerationProviders,
|
||||
type StructuredGenerationDaemonConfig,
|
||||
} from "./agent/structured-generation-providers.js";
|
||||
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPersistenceHandle,
|
||||
@@ -150,6 +141,10 @@ import { wrapSpokenInput } from "./voice-config.js";
|
||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||
import { VoiceSession } from "./session/voice/voice-session.js";
|
||||
import { CheckoutSession } from "./session/checkout/checkout-session.js";
|
||||
import {
|
||||
createAgentStructuredTextGeneration,
|
||||
createGitMetadataGenerator,
|
||||
} from "./session/checkout/git-metadata-generator.js";
|
||||
import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js";
|
||||
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
|
||||
import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js";
|
||||
@@ -158,7 +153,6 @@ import { ProjectConfigSession } from "./session/project-config/project-config-se
|
||||
import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||
import {
|
||||
archivePersistedWorkspaceRecord,
|
||||
archiveWorkspaceContents,
|
||||
@@ -247,12 +241,6 @@ function resolveSubscriptionId(
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" | "D" | "M" {
|
||||
if (file.isNew) return "A";
|
||||
if (file.isDeleted) return "D";
|
||||
return "M";
|
||||
}
|
||||
|
||||
function buildWorkspaceCheckout(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord,
|
||||
@@ -719,12 +707,19 @@ export class Session {
|
||||
this.handleWorkspaceGitBranchSnapshot(cwd, branchName),
|
||||
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||
generateCommitMessage: (cwd) => this.generateCommitMessage(cwd),
|
||||
generatePullRequestText: (cwd, baseRef) => this.generatePullRequestText(cwd, baseRef),
|
||||
},
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
github: this.github,
|
||||
checkoutDiffManager,
|
||||
gitMetadataGenerator: createGitMetadataGenerator({
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
generation: createAgentStructuredTextGeneration({
|
||||
agentManager: this.agentManager,
|
||||
providerSnapshotManager,
|
||||
readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(),
|
||||
getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd),
|
||||
}),
|
||||
}),
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.sessionLogger,
|
||||
@@ -3258,170 +3253,6 @@ export class Session {
|
||||
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
||||
}
|
||||
|
||||
private async generateCommitMessage(cwd: string): Promise<string> {
|
||||
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||
mode: "uncommitted",
|
||||
includeStructured: true,
|
||||
});
|
||||
const schema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(72)
|
||||
.describe("Concise git commit message, imperative mood, no trailing period."),
|
||||
});
|
||||
const fileList =
|
||||
diff.structured && diff.structured.length > 0
|
||||
? [
|
||||
"Files changed:",
|
||||
...diff.structured.map((file) => {
|
||||
const changeType = diffChangeTypeFor(file);
|
||||
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
|
||||
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
|
||||
}),
|
||||
].join("\n")
|
||||
: "Files changed: (unknown)";
|
||||
const maxPatchChars = 120_000;
|
||||
const patch =
|
||||
diff.diff.length > maxPatchChars
|
||||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||
: diff.diff;
|
||||
const prompt = await buildMetadataPrompt({
|
||||
cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
contract: "Write a concise git commit message for the changes below.",
|
||||
styles: [
|
||||
{
|
||||
configKey: "commitMessage",
|
||||
default: "Concise, imperative mood, no trailing period.",
|
||||
},
|
||||
],
|
||||
after: [
|
||||
"Return JSON only with a single field 'message'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
|
||||
});
|
||||
try {
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return "Update files";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generatePullRequestText(
|
||||
cwd: string,
|
||||
baseRef?: string,
|
||||
): Promise<{
|
||||
title: string;
|
||||
body: string;
|
||||
}> {
|
||||
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
|
||||
mode: "base",
|
||||
baseRef,
|
||||
includeStructured: true,
|
||||
});
|
||||
const schema = z.object({
|
||||
title: z.string().min(1).max(72),
|
||||
body: z.string().min(1),
|
||||
});
|
||||
const fileList =
|
||||
diff.structured && diff.structured.length > 0
|
||||
? [
|
||||
"Files changed:",
|
||||
...diff.structured.map((file) => {
|
||||
const changeType = diffChangeTypeFor(file);
|
||||
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
|
||||
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
|
||||
}),
|
||||
].join("\n")
|
||||
: "Files changed: (unknown)";
|
||||
const maxPatchChars = 200_000;
|
||||
const patch =
|
||||
diff.diff.length > maxPatchChars
|
||||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||||
: diff.diff;
|
||||
const prompt = await buildMetadataPrompt({
|
||||
cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
contract: "Write a pull request title and body for the changes below.",
|
||||
styles: [
|
||||
{
|
||||
configKey: "pullRequest",
|
||||
default: "Clear, descriptive title; body explaining what changed and why.",
|
||||
},
|
||||
],
|
||||
after: [
|
||||
"Return JSON only with fields 'title' and 'body'.",
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
|
||||
});
|
||||
try {
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof StructuredAgentResponseError ||
|
||||
error instanceof StructuredAgentFallbackError
|
||||
) {
|
||||
return {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||
const dirty = await this.isWorkingTreeDirty(cwd);
|
||||
if (dirty) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createNoopWorkspaceGitService,
|
||||
} from "../../test-utils/workspace-git-service-stub.js";
|
||||
import { expandTilde } from "../../../utils/path.js";
|
||||
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
|
||||
|
||||
interface FakeDiffSubscription {
|
||||
cwd: string;
|
||||
@@ -64,6 +65,9 @@ interface RecordedHostCalls {
|
||||
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
|
||||
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
|
||||
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
|
||||
}
|
||||
|
||||
interface RecordedGeneratorCalls {
|
||||
generateCommitMessage: string[];
|
||||
generatePullRequestText: Array<{ cwd: string; baseRef?: string }>;
|
||||
}
|
||||
@@ -73,6 +77,7 @@ function makeCheckoutSession(options?: {
|
||||
diff?: CheckoutDiffSubscriber;
|
||||
github?: Partial<GitHubService>;
|
||||
host?: Partial<CheckoutSessionHost>;
|
||||
gitMetadataGenerator?: Partial<GitMetadataGenerator>;
|
||||
}) {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const hostCalls: RecordedHostCalls = {
|
||||
@@ -81,6 +86,8 @@ function makeCheckoutSession(options?: {
|
||||
handleWorkspaceGitBranchSnapshot: [],
|
||||
renameCurrentBranch: [],
|
||||
checkoutExistingBranch: [],
|
||||
};
|
||||
const generatorCalls: RecordedGeneratorCalls = {
|
||||
generateCommitMessage: [],
|
||||
generatePullRequestText: [],
|
||||
};
|
||||
@@ -103,15 +110,18 @@ function makeCheckoutSession(options?: {
|
||||
hostCalls.checkoutExistingBranch.push({ cwd, branch });
|
||||
return { source: "local" };
|
||||
},
|
||||
...options?.host,
|
||||
};
|
||||
const gitMetadataGenerator: GitMetadataGenerator = {
|
||||
generateCommitMessage: async (cwd) => {
|
||||
hostCalls.generateCommitMessage.push(cwd);
|
||||
generatorCalls.generateCommitMessage.push(cwd);
|
||||
return "";
|
||||
},
|
||||
generatePullRequestText: async (cwd, baseRef) => {
|
||||
hostCalls.generatePullRequestText.push({ cwd, baseRef });
|
||||
generatorCalls.generatePullRequestText.push({ cwd, baseRef });
|
||||
return { title: "", body: "" };
|
||||
},
|
||||
...options?.host,
|
||||
...options?.gitMetadataGenerator,
|
||||
};
|
||||
const github: GitHubService = { ...createGitHubService(), ...options?.github };
|
||||
const checkout = new CheckoutSession({
|
||||
@@ -120,11 +130,12 @@ function makeCheckoutSession(options?: {
|
||||
github,
|
||||
checkoutDiffManager:
|
||||
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
|
||||
gitMetadataGenerator,
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
worktreesRoot: undefined,
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
return { checkout, emitted, hostCalls };
|
||||
return { checkout, emitted, hostCalls, generatorCalls };
|
||||
}
|
||||
|
||||
function createGitSnapshot(
|
||||
@@ -641,7 +652,7 @@ describe("CheckoutSession", () => {
|
||||
|
||||
describe("commit", () => {
|
||||
it("fails when no message is supplied and none can be generated", async () => {
|
||||
const { checkout, emitted, hostCalls } = makeCheckoutSession();
|
||||
const { checkout, emitted, generatorCalls } = makeCheckoutSession();
|
||||
|
||||
await checkout.handleCheckoutCommitRequest({
|
||||
type: "checkout_commit_request",
|
||||
@@ -651,7 +662,7 @@ describe("CheckoutSession", () => {
|
||||
requestId: "c1",
|
||||
});
|
||||
|
||||
expect(hostCalls.generateCommitMessage).toEqual(["/repo"]);
|
||||
expect(generatorCalls.generateCommitMessage).toEqual(["/repo"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "checkout_commit_response",
|
||||
|
||||
@@ -45,13 +45,13 @@ import {
|
||||
} from "../../../utils/checkout-git.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { expandTilde } from "../../../utils/path.js";
|
||||
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
|
||||
|
||||
/**
|
||||
* The collaborators a checkout command reaches that are NOT part of the checkout
|
||||
* domain: the git-mutation refresh primitive and workspace-update emitters owned
|
||||
* by the Session shell (also used by worktree/workspace creation), the injected
|
||||
* branch operations, and the LLM-backed commit/PR text generators. CheckoutSession
|
||||
* orchestrates them but does not own them.
|
||||
* by the Session shell (also used by worktree/workspace creation), and the injected
|
||||
* branch operations. CheckoutSession orchestrates them but does not own them.
|
||||
*/
|
||||
export interface CheckoutSessionHost {
|
||||
emit(msg: SessionOutboundMessage): void;
|
||||
@@ -67,8 +67,6 @@ export interface CheckoutSessionHost {
|
||||
branch: string,
|
||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
||||
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
|
||||
generateCommitMessage(cwd: string): Promise<string>;
|
||||
generatePullRequestText(cwd: string, baseRef?: string): Promise<{ title: string; body: string }>;
|
||||
}
|
||||
|
||||
type CurrentWorkspacePullRequest = NonNullable<
|
||||
@@ -95,6 +93,7 @@ export interface CheckoutSessionOptions {
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
github: GitHubService;
|
||||
checkoutDiffManager: CheckoutDiffSubscriber;
|
||||
gitMetadataGenerator: GitMetadataGenerator;
|
||||
paseoHome: string;
|
||||
worktreesRoot: string | undefined;
|
||||
logger: pino.Logger;
|
||||
@@ -117,6 +116,7 @@ export class CheckoutSession {
|
||||
private readonly workspaceGitService: WorkspaceGitService;
|
||||
private readonly github: GitHubService;
|
||||
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
||||
private readonly gitMetadataGenerator: GitMetadataGenerator;
|
||||
private readonly paseoHome: string;
|
||||
private readonly worktreesRoot: string | undefined;
|
||||
private readonly logger: pino.Logger;
|
||||
@@ -127,6 +127,7 @@ export class CheckoutSession {
|
||||
this.workspaceGitService = options.workspaceGitService;
|
||||
this.github = options.github;
|
||||
this.checkoutDiffManager = options.checkoutDiffManager;
|
||||
this.gitMetadataGenerator = options.gitMetadataGenerator;
|
||||
this.paseoHome = options.paseoHome;
|
||||
this.worktreesRoot = options.worktreesRoot;
|
||||
this.logger = options.logger;
|
||||
@@ -537,7 +538,7 @@ export class CheckoutSession {
|
||||
try {
|
||||
let message = msg.message?.trim() ?? "";
|
||||
if (!message) {
|
||||
message = await this.host.generateCommitMessage(cwd);
|
||||
message = await this.gitMetadataGenerator.generateCommitMessage(cwd);
|
||||
}
|
||||
if (!message) {
|
||||
throw new Error("Commit message is required");
|
||||
@@ -747,7 +748,7 @@ export class CheckoutSession {
|
||||
let body = msg.body?.trim() ?? "";
|
||||
|
||||
if (!title || !body) {
|
||||
const generated = await this.host.generatePullRequestText(cwd, msg.baseRef);
|
||||
const generated = await this.gitMetadataGenerator.generatePullRequestText(cwd, msg.baseRef);
|
||||
if (!title) title = generated.title;
|
||||
if (!body) body = generated.body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
} from "../../agent/agent-response-loop.js";
|
||||
import type { CheckoutDiffCompare, CheckoutDiffResult } from "../../../utils/checkout-git.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import {
|
||||
createGitMetadataGenerator,
|
||||
type StructuredTextGeneration,
|
||||
type StructuredTextGenerationRequest,
|
||||
} from "./git-metadata-generator.js";
|
||||
|
||||
type DiffSource = Pick<WorkspaceGitService, "getCheckoutDiff" | "resolveRepoRoot">;
|
||||
|
||||
function createDiffSource(result: CheckoutDiffResult) {
|
||||
const diffCalls: Array<{ cwd: string; options: CheckoutDiffCompare }> = [];
|
||||
const diffSource: DiffSource = {
|
||||
getCheckoutDiff: async (cwd, options) => {
|
||||
diffCalls.push({ cwd, options });
|
||||
return result;
|
||||
},
|
||||
// buildMetadataPrompt reads paseo.json overrides from here; an unknown root
|
||||
// means no override applies, so the default style is used.
|
||||
resolveRepoRoot: async () => "/tmp/git-metadata-generator-test-missing-root",
|
||||
};
|
||||
return { diffSource, diffCalls };
|
||||
}
|
||||
|
||||
function createGeneration(handler: (request: StructuredTextGenerationRequest<unknown>) => unknown) {
|
||||
const generateCalls: Array<StructuredTextGenerationRequest<unknown>> = [];
|
||||
const generation: StructuredTextGeneration = {
|
||||
generate: async <T>(request: StructuredTextGenerationRequest<T>): Promise<T> => {
|
||||
generateCalls.push(request as StructuredTextGenerationRequest<unknown>);
|
||||
return handler(request as StructuredTextGenerationRequest<unknown>) as T;
|
||||
},
|
||||
};
|
||||
return { generation, generateCalls };
|
||||
}
|
||||
|
||||
const DIFF_WITH_ONE_FILE: CheckoutDiffResult = {
|
||||
diff: "diff --git a/src/foo.ts b/src/foo.ts\n+added\n",
|
||||
structured: [
|
||||
{
|
||||
path: "src/foo.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
hunks: [],
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createGitMetadataGenerator", () => {
|
||||
it("generateCommitMessage returns the generated message from an uncommitted-diff prompt", async () => {
|
||||
const { diffSource, diffCalls } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation, generateCalls } = createGeneration(() => ({
|
||||
message: "Fix the flaky retry test",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
const message = await generator.generateCommitMessage("/repo");
|
||||
|
||||
expect(message).toBe("Fix the flaky retry test");
|
||||
expect(diffCalls).toEqual([
|
||||
{ cwd: "/repo", options: { mode: "uncommitted", includeStructured: true } },
|
||||
]);
|
||||
expect(generateCalls[0]).toMatchObject({
|
||||
cwd: "/repo",
|
||||
schemaName: "CommitMessage",
|
||||
agentTitle: "Commit generator",
|
||||
});
|
||||
expect(generateCalls[0].prompt).toContain("Write a concise git commit message");
|
||||
expect(generateCalls[0].prompt).toContain("M\tsrc/foo.ts\t(+3 -1)");
|
||||
expect(generateCalls[0].prompt).toContain("diff --git a/src/foo.ts");
|
||||
});
|
||||
|
||||
it("generateCommitMessage falls back to a default message when generation exhausts its providers", async () => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
|
||||
it("generateCommitMessage falls back when the generated response cannot be validated", async () => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentResponseError("invalid", {
|
||||
lastResponse: "{}",
|
||||
validationErrors: ["message: required"],
|
||||
});
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).resolves.toBe("Update files");
|
||||
});
|
||||
|
||||
it("generateCommitMessage rethrows errors that are not structured-generation failures", async () => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
await expect(generator.generateCommitMessage("/repo")).rejects.toThrow("network down");
|
||||
});
|
||||
|
||||
it("generatePullRequestText returns the generated title and body from a base-diff prompt", async () => {
|
||||
const { diffSource, diffCalls } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation, generateCalls } = createGeneration(() => ({
|
||||
title: "Add retry with backoff",
|
||||
body: "Retries transient failures up to twice.",
|
||||
}));
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
const result = await generator.generatePullRequestText("/repo", "main");
|
||||
|
||||
expect(result).toEqual({
|
||||
title: "Add retry with backoff",
|
||||
body: "Retries transient failures up to twice.",
|
||||
});
|
||||
expect(diffCalls).toEqual([
|
||||
{ cwd: "/repo", options: { mode: "base", baseRef: "main", includeStructured: true } },
|
||||
]);
|
||||
expect(generateCalls[0]).toMatchObject({
|
||||
cwd: "/repo",
|
||||
schemaName: "PullRequest",
|
||||
agentTitle: "PR generator",
|
||||
});
|
||||
expect(generateCalls[0].prompt).toContain("Write a pull request title and body");
|
||||
});
|
||||
|
||||
it("generatePullRequestText falls back to default PR text when generation fails", async () => {
|
||||
const { diffSource } = createDiffSource(DIFF_WITH_ONE_FILE);
|
||||
const { generation } = createGeneration(() => {
|
||||
throw new StructuredAgentFallbackError([]);
|
||||
});
|
||||
const generator = createGitMetadataGenerator({ workspaceGitService: diffSource, generation });
|
||||
|
||||
await expect(generator.generatePullRequestText("/repo")).resolves.toEqual({
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "../../agent/agent-response-loop.js";
|
||||
import type { AgentManager } from "../../agent/agent-manager.js";
|
||||
import type { ProviderSnapshotManager } from "../../agent/provider-snapshot-manager.js";
|
||||
import {
|
||||
resolveStructuredGenerationProviders,
|
||||
type ResolveStructuredGenerationProvidersOptions,
|
||||
type StructuredGenerationDaemonConfig,
|
||||
} from "../../agent/structured-generation-providers.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import {
|
||||
buildMetadataPrompt,
|
||||
type MetadataConfigKey,
|
||||
} from "../../../utils/build-metadata-prompt.js";
|
||||
|
||||
export interface PullRequestText {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a workspace's commit message and pull-request text from its git diff
|
||||
* via the agent LLM. The checkout subsystem is the only consumer; it owns the
|
||||
* commit/PR commands and asks this for the wording when the user left it blank.
|
||||
*/
|
||||
export interface GitMetadataGenerator {
|
||||
generateCommitMessage(cwd: string): Promise<string>;
|
||||
generatePullRequestText(cwd: string, baseRef?: string): Promise<PullRequestText>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The LLM boundary, injected so the generator's prompt-building and fallback
|
||||
* behaviour is unit-testable with a fake. Production wires
|
||||
* createAgentStructuredTextGeneration (resolve providers → run structured
|
||||
* generation); a failed generation throws StructuredAgent*Error, which the
|
||||
* generator catches and turns into the product fallback text.
|
||||
*/
|
||||
export interface StructuredTextGeneration {
|
||||
generate<T>(request: StructuredTextGenerationRequest<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface StructuredTextGenerationRequest<T> {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T>;
|
||||
schemaName: string;
|
||||
agentTitle: string;
|
||||
}
|
||||
|
||||
type GitMetadataDiffSource = Pick<WorkspaceGitService, "getCheckoutDiff" | "resolveRepoRoot">;
|
||||
type CheckoutDiffOptions = Parameters<GitMetadataDiffSource["getCheckoutDiff"]>[1];
|
||||
type CheckoutDiff = Awaited<ReturnType<GitMetadataDiffSource["getCheckoutDiff"]>>;
|
||||
|
||||
const COMMIT_MESSAGE_SCHEMA = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(72)
|
||||
.describe("Concise git commit message, imperative mood, no trailing period."),
|
||||
});
|
||||
|
||||
const PULL_REQUEST_SCHEMA = z.object({
|
||||
title: z.string().min(1).max(72),
|
||||
body: z.string().min(1),
|
||||
});
|
||||
|
||||
const COMMIT_MESSAGE_FALLBACK = "Update files";
|
||||
const PULL_REQUEST_FALLBACK: PullRequestText = {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
};
|
||||
|
||||
const MAX_COMMIT_PATCH_CHARS = 120_000;
|
||||
const MAX_PULL_REQUEST_PATCH_CHARS = 200_000;
|
||||
|
||||
interface PromptForDiffInput {
|
||||
cwd: string;
|
||||
diffOptions: CheckoutDiffOptions;
|
||||
maxPatchChars: number;
|
||||
contract: string;
|
||||
styleConfigKey: MetadataConfigKey;
|
||||
styleDefault: string;
|
||||
jsonFieldsHint: string;
|
||||
}
|
||||
|
||||
export function createGitMetadataGenerator(deps: {
|
||||
workspaceGitService: GitMetadataDiffSource;
|
||||
generation: StructuredTextGeneration;
|
||||
}): GitMetadataGenerator {
|
||||
const { workspaceGitService, generation } = deps;
|
||||
|
||||
async function buildPromptForDiff(input: PromptForDiffInput): Promise<string> {
|
||||
const diff = await workspaceGitService.getCheckoutDiff(input.cwd, input.diffOptions);
|
||||
const fileList = renderFileList(diff.structured);
|
||||
const patch = truncatePatch(diff.diff, input.maxPatchChars);
|
||||
return buildMetadataPrompt({
|
||||
cwd: input.cwd,
|
||||
workspaceGitService,
|
||||
contract: input.contract,
|
||||
styles: [{ configKey: input.styleConfigKey, default: input.styleDefault }],
|
||||
after: [
|
||||
input.jsonFieldsHint,
|
||||
"",
|
||||
fileList,
|
||||
"",
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async generateCommitMessage(cwd) {
|
||||
const prompt = await buildPromptForDiff({
|
||||
cwd,
|
||||
diffOptions: { mode: "uncommitted", includeStructured: true },
|
||||
maxPatchChars: MAX_COMMIT_PATCH_CHARS,
|
||||
contract: "Write a concise git commit message for the changes below.",
|
||||
styleConfigKey: "commitMessage",
|
||||
styleDefault: "Concise, imperative mood, no trailing period.",
|
||||
jsonFieldsHint: "Return JSON only with a single field 'message'.",
|
||||
});
|
||||
try {
|
||||
const result = await generation.generate({
|
||||
cwd,
|
||||
prompt,
|
||||
schema: COMMIT_MESSAGE_SCHEMA,
|
||||
schemaName: "CommitMessage",
|
||||
agentTitle: "Commit generator",
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return COMMIT_MESSAGE_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async generatePullRequestText(cwd, baseRef) {
|
||||
const prompt = await buildPromptForDiff({
|
||||
cwd,
|
||||
diffOptions: { mode: "base", baseRef, includeStructured: true },
|
||||
maxPatchChars: MAX_PULL_REQUEST_PATCH_CHARS,
|
||||
contract: "Write a pull request title and body for the changes below.",
|
||||
styleConfigKey: "pullRequest",
|
||||
styleDefault: "Clear, descriptive title; body explaining what changed and why.",
|
||||
jsonFieldsHint: "Return JSON only with fields 'title' and 'body'.",
|
||||
});
|
||||
try {
|
||||
return await generation.generate({
|
||||
cwd,
|
||||
prompt,
|
||||
schema: PULL_REQUEST_SCHEMA,
|
||||
schemaName: "PullRequest",
|
||||
agentTitle: "PR generator",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStructuredGenerationFailure(error)) {
|
||||
return PULL_REQUEST_FALLBACK;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Production StructuredTextGeneration: resolve the structured-generation providers
|
||||
* for the cwd, then run the agent with a 2-retry fallback as an internal,
|
||||
* non-persisted session.
|
||||
*/
|
||||
export function createAgentStructuredTextGeneration(deps: {
|
||||
agentManager: AgentManager;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
readDaemonConfig: () => StructuredGenerationDaemonConfig;
|
||||
getFocusedSelection: (
|
||||
cwd: string,
|
||||
) => ResolveStructuredGenerationProvidersOptions["currentSelection"];
|
||||
}): StructuredTextGeneration {
|
||||
return {
|
||||
async generate({ cwd, prompt, schema, schemaName, agentTitle }) {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd,
|
||||
providerSnapshotManager: deps.providerSnapshotManager,
|
||||
daemonConfig: deps.readDaemonConfig(),
|
||||
currentSelection: deps.getFocusedSelection(cwd),
|
||||
});
|
||||
return generateStructuredAgentResponseWithFallback({
|
||||
manager: deps.agentManager,
|
||||
cwd,
|
||||
prompt,
|
||||
schema,
|
||||
schemaName,
|
||||
maxRetries: 2,
|
||||
providers,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: agentTitle,
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderFileList(structured: CheckoutDiff["structured"]): string {
|
||||
if (!structured || structured.length === 0) {
|
||||
return "Files changed: (unknown)";
|
||||
}
|
||||
return [
|
||||
"Files changed:",
|
||||
...structured.map((file) => {
|
||||
const changeType = diffChangeTypeFor(file);
|
||||
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
|
||||
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
|
||||
}),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function truncatePatch(patch: string, maxPatchChars: number): string {
|
||||
if (patch.length <= maxPatchChars) {
|
||||
return patch;
|
||||
}
|
||||
return `${patch.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`;
|
||||
}
|
||||
|
||||
function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" | "D" | "M" {
|
||||
if (file.isNew) return "A";
|
||||
if (file.isDeleted) return "D";
|
||||
return "M";
|
||||
}
|
||||
|
||||
function isStructuredGenerationFailure(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user