From 484ffc4334a776f7326c12217fb9f674d6895188 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 24 Jun 2026 23:48:00 +0800 Subject: [PATCH] refactor(server): extract git-metadata generators into checkout module (#1702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/server/src/server/session.ts | 197 +------------- .../session/checkout/checkout-session.test.ts | 23 +- .../session/checkout/checkout-session.ts | 15 +- .../checkout/git-metadata-generator.test.ts | 150 +++++++++++ .../checkout/git-metadata-generator.ts | 241 ++++++++++++++++++ 5 files changed, 430 insertions(+), 196 deletions(-) create mode 100644 packages/server/src/server/session/checkout/git-metadata-generator.test.ts create mode 100644 packages/server/src/server/session/checkout/git-metadata-generator.ts diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5a4f0fffe..a9e5259b5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { - 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 { const dirty = await this.isWorkingTreeDirty(cwd); if (dirty) { diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index 9fcde7335..daece9f56 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -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; host?: Partial; + gitMetadataGenerator?: Partial; }) { 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", diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 068d0af22..a54e9bbf1 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -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; - generateCommitMessage(cwd: string): Promise; - 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; } diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.test.ts b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts new file mode 100644 index 000000000..9c3020040 --- /dev/null +++ b/packages/server/src/server/session/checkout/git-metadata-generator.test.ts @@ -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; + +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) { + const generateCalls: Array> = []; + const generation: StructuredTextGeneration = { + generate: async (request: StructuredTextGenerationRequest): Promise => { + generateCalls.push(request as StructuredTextGenerationRequest); + return handler(request as StructuredTextGenerationRequest) 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.", + }); + }); +}); diff --git a/packages/server/src/server/session/checkout/git-metadata-generator.ts b/packages/server/src/server/session/checkout/git-metadata-generator.ts new file mode 100644 index 000000000..06f276f13 --- /dev/null +++ b/packages/server/src/server/session/checkout/git-metadata-generator.ts @@ -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; + generatePullRequestText(cwd: string, baseRef?: string): Promise; +} + +/** + * 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(request: StructuredTextGenerationRequest): Promise; +} + +export interface StructuredTextGenerationRequest { + cwd: string; + prompt: string; + schema: z.ZodType; + schemaName: string; + agentTitle: string; +} + +type GitMetadataDiffSource = Pick; +type CheckoutDiffOptions = Parameters[1]; +type CheckoutDiff = Awaited>; + +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 { + 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; + 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 + ); +}