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:
Mohamed Boudra
2026-06-24 23:48:00 +08:00
committed by GitHub
parent 507345dbee
commit 484ffc4334
5 changed files with 430 additions and 196 deletions

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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.",
});
});
});

View File

@@ -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
);
}