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 { stat } from "node:fs/promises";
|
||||||
import { basename, normalize, resolve, sep } from "path";
|
import { basename, normalize, resolve, sep } from "path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { z } from "zod";
|
|
||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||||
import {
|
import {
|
||||||
@@ -102,15 +101,7 @@ import {
|
|||||||
type TimelineProjectionEntry,
|
type TimelineProjectionEntry,
|
||||||
type TimelineProjectionMode,
|
type TimelineProjectionMode,
|
||||||
} from "./agent/timeline-projection.js";
|
} from "./agent/timeline-projection.js";
|
||||||
import {
|
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
||||||
StructuredAgentFallbackError,
|
|
||||||
StructuredAgentResponseError,
|
|
||||||
generateStructuredAgentResponseWithFallback,
|
|
||||||
} from "./agent/agent-response-loop.js";
|
|
||||||
import {
|
|
||||||
resolveStructuredGenerationProviders,
|
|
||||||
type StructuredGenerationDaemonConfig,
|
|
||||||
} from "./agent/structured-generation-providers.js";
|
|
||||||
import {
|
import {
|
||||||
getAgentStreamEventTurnId,
|
getAgentStreamEventTurnId,
|
||||||
type AgentPersistenceHandle,
|
type AgentPersistenceHandle,
|
||||||
@@ -150,6 +141,10 @@ import { wrapSpokenInput } from "./voice-config.js";
|
|||||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
||||||
import { VoiceSession } from "./session/voice/voice-session.js";
|
import { VoiceSession } from "./session/voice/voice-session.js";
|
||||||
import { CheckoutSession } from "./session/checkout/checkout-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 { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js";
|
||||||
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
|
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
|
||||||
import { WorkspaceFilesSession } from "./session/files/workspace-files-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 { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
|
||||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import { PushTokenStore } from "./push/token-store.js";
|
import { PushTokenStore } from "./push/token-store.js";
|
||||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
|
||||||
import {
|
import {
|
||||||
archivePersistedWorkspaceRecord,
|
archivePersistedWorkspaceRecord,
|
||||||
archiveWorkspaceContents,
|
archiveWorkspaceContents,
|
||||||
@@ -247,12 +241,6 @@ function resolveSubscriptionId(
|
|||||||
return uuidv4();
|
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(
|
function buildWorkspaceCheckout(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
project: PersistedProjectRecord,
|
project: PersistedProjectRecord,
|
||||||
@@ -719,12 +707,19 @@ export class Session {
|
|||||||
this.handleWorkspaceGitBranchSnapshot(cwd, branchName),
|
this.handleWorkspaceGitBranchSnapshot(cwd, branchName),
|
||||||
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
||||||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||||||
generateCommitMessage: (cwd) => this.generateCommitMessage(cwd),
|
|
||||||
generatePullRequestText: (cwd, baseRef) => this.generatePullRequestText(cwd, baseRef),
|
|
||||||
},
|
},
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
github: this.github,
|
github: this.github,
|
||||||
checkoutDiffManager,
|
checkoutDiffManager,
|
||||||
|
gitMetadataGenerator: createGitMetadataGenerator({
|
||||||
|
workspaceGitService: this.workspaceGitService,
|
||||||
|
generation: createAgentStructuredTextGeneration({
|
||||||
|
agentManager: this.agentManager,
|
||||||
|
providerSnapshotManager,
|
||||||
|
readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(),
|
||||||
|
getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
worktreesRoot: this.worktreesRoot,
|
worktreesRoot: this.worktreesRoot,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
@@ -3258,170 +3253,6 @@ export class Session {
|
|||||||
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
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> {
|
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||||
const dirty = await this.isWorkingTreeDirty(cwd);
|
const dirty = await this.isWorkingTreeDirty(cwd);
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
createNoopWorkspaceGitService,
|
createNoopWorkspaceGitService,
|
||||||
} from "../../test-utils/workspace-git-service-stub.js";
|
} from "../../test-utils/workspace-git-service-stub.js";
|
||||||
import { expandTilde } from "../../../utils/path.js";
|
import { expandTilde } from "../../../utils/path.js";
|
||||||
|
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
|
||||||
|
|
||||||
interface FakeDiffSubscription {
|
interface FakeDiffSubscription {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -64,6 +65,9 @@ interface RecordedHostCalls {
|
|||||||
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
|
handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>;
|
||||||
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
|
renameCurrentBranch: Array<{ cwd: string; branch: string }>;
|
||||||
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
|
checkoutExistingBranch: Array<{ cwd: string; branch: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordedGeneratorCalls {
|
||||||
generateCommitMessage: string[];
|
generateCommitMessage: string[];
|
||||||
generatePullRequestText: Array<{ cwd: string; baseRef?: string }>;
|
generatePullRequestText: Array<{ cwd: string; baseRef?: string }>;
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,7 @@ function makeCheckoutSession(options?: {
|
|||||||
diff?: CheckoutDiffSubscriber;
|
diff?: CheckoutDiffSubscriber;
|
||||||
github?: Partial<GitHubService>;
|
github?: Partial<GitHubService>;
|
||||||
host?: Partial<CheckoutSessionHost>;
|
host?: Partial<CheckoutSessionHost>;
|
||||||
|
gitMetadataGenerator?: Partial<GitMetadataGenerator>;
|
||||||
}) {
|
}) {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const hostCalls: RecordedHostCalls = {
|
const hostCalls: RecordedHostCalls = {
|
||||||
@@ -81,6 +86,8 @@ function makeCheckoutSession(options?: {
|
|||||||
handleWorkspaceGitBranchSnapshot: [],
|
handleWorkspaceGitBranchSnapshot: [],
|
||||||
renameCurrentBranch: [],
|
renameCurrentBranch: [],
|
||||||
checkoutExistingBranch: [],
|
checkoutExistingBranch: [],
|
||||||
|
};
|
||||||
|
const generatorCalls: RecordedGeneratorCalls = {
|
||||||
generateCommitMessage: [],
|
generateCommitMessage: [],
|
||||||
generatePullRequestText: [],
|
generatePullRequestText: [],
|
||||||
};
|
};
|
||||||
@@ -103,15 +110,18 @@ function makeCheckoutSession(options?: {
|
|||||||
hostCalls.checkoutExistingBranch.push({ cwd, branch });
|
hostCalls.checkoutExistingBranch.push({ cwd, branch });
|
||||||
return { source: "local" };
|
return { source: "local" };
|
||||||
},
|
},
|
||||||
|
...options?.host,
|
||||||
|
};
|
||||||
|
const gitMetadataGenerator: GitMetadataGenerator = {
|
||||||
generateCommitMessage: async (cwd) => {
|
generateCommitMessage: async (cwd) => {
|
||||||
hostCalls.generateCommitMessage.push(cwd);
|
generatorCalls.generateCommitMessage.push(cwd);
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
generatePullRequestText: async (cwd, baseRef) => {
|
generatePullRequestText: async (cwd, baseRef) => {
|
||||||
hostCalls.generatePullRequestText.push({ cwd, baseRef });
|
generatorCalls.generatePullRequestText.push({ cwd, baseRef });
|
||||||
return { title: "", body: "" };
|
return { title: "", body: "" };
|
||||||
},
|
},
|
||||||
...options?.host,
|
...options?.gitMetadataGenerator,
|
||||||
};
|
};
|
||||||
const github: GitHubService = { ...createGitHubService(), ...options?.github };
|
const github: GitHubService = { ...createGitHubService(), ...options?.github };
|
||||||
const checkout = new CheckoutSession({
|
const checkout = new CheckoutSession({
|
||||||
@@ -120,11 +130,12 @@ function makeCheckoutSession(options?: {
|
|||||||
github,
|
github,
|
||||||
checkoutDiffManager:
|
checkoutDiffManager:
|
||||||
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
|
options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber,
|
||||||
|
gitMetadataGenerator,
|
||||||
paseoHome: "/tmp/paseo-home",
|
paseoHome: "/tmp/paseo-home",
|
||||||
worktreesRoot: undefined,
|
worktreesRoot: undefined,
|
||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
});
|
});
|
||||||
return { checkout, emitted, hostCalls };
|
return { checkout, emitted, hostCalls, generatorCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGitSnapshot(
|
function createGitSnapshot(
|
||||||
@@ -641,7 +652,7 @@ describe("CheckoutSession", () => {
|
|||||||
|
|
||||||
describe("commit", () => {
|
describe("commit", () => {
|
||||||
it("fails when no message is supplied and none can be generated", async () => {
|
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({
|
await checkout.handleCheckoutCommitRequest({
|
||||||
type: "checkout_commit_request",
|
type: "checkout_commit_request",
|
||||||
@@ -651,7 +662,7 @@ describe("CheckoutSession", () => {
|
|||||||
requestId: "c1",
|
requestId: "c1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(hostCalls.generateCommitMessage).toEqual(["/repo"]);
|
expect(generatorCalls.generateCommitMessage).toEqual(["/repo"]);
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
{
|
{
|
||||||
type: "checkout_commit_response",
|
type: "checkout_commit_response",
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ import {
|
|||||||
} from "../../../utils/checkout-git.js";
|
} from "../../../utils/checkout-git.js";
|
||||||
import { execCommand } from "../../../utils/spawn.js";
|
import { execCommand } from "../../../utils/spawn.js";
|
||||||
import { expandTilde } from "../../../utils/path.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
|
* The collaborators a checkout command reaches that are NOT part of the checkout
|
||||||
* domain: the git-mutation refresh primitive and workspace-update emitters owned
|
* domain: the git-mutation refresh primitive and workspace-update emitters owned
|
||||||
* by the Session shell (also used by worktree/workspace creation), the injected
|
* by the Session shell (also used by worktree/workspace creation), and the injected
|
||||||
* branch operations, and the LLM-backed commit/PR text generators. CheckoutSession
|
* branch operations. CheckoutSession orchestrates them but does not own them.
|
||||||
* orchestrates them but does not own them.
|
|
||||||
*/
|
*/
|
||||||
export interface CheckoutSessionHost {
|
export interface CheckoutSessionHost {
|
||||||
emit(msg: SessionOutboundMessage): void;
|
emit(msg: SessionOutboundMessage): void;
|
||||||
@@ -67,8 +67,6 @@ export interface CheckoutSessionHost {
|
|||||||
branch: string,
|
branch: string,
|
||||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
): Promise<{ previousBranch: string | null; currentBranch: string | null }>;
|
||||||
checkoutExistingBranch(cwd: string, branch: string): Promise<CheckoutExistingBranchResult>;
|
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<
|
type CurrentWorkspacePullRequest = NonNullable<
|
||||||
@@ -95,6 +93,7 @@ export interface CheckoutSessionOptions {
|
|||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
github: GitHubService;
|
github: GitHubService;
|
||||||
checkoutDiffManager: CheckoutDiffSubscriber;
|
checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
|
gitMetadataGenerator: GitMetadataGenerator;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
worktreesRoot: string | undefined;
|
worktreesRoot: string | undefined;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
@@ -117,6 +116,7 @@ export class CheckoutSession {
|
|||||||
private readonly workspaceGitService: WorkspaceGitService;
|
private readonly workspaceGitService: WorkspaceGitService;
|
||||||
private readonly github: GitHubService;
|
private readonly github: GitHubService;
|
||||||
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
private readonly checkoutDiffManager: CheckoutDiffSubscriber;
|
||||||
|
private readonly gitMetadataGenerator: GitMetadataGenerator;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
private readonly worktreesRoot: string | undefined;
|
private readonly worktreesRoot: string | undefined;
|
||||||
private readonly logger: pino.Logger;
|
private readonly logger: pino.Logger;
|
||||||
@@ -127,6 +127,7 @@ export class CheckoutSession {
|
|||||||
this.workspaceGitService = options.workspaceGitService;
|
this.workspaceGitService = options.workspaceGitService;
|
||||||
this.github = options.github;
|
this.github = options.github;
|
||||||
this.checkoutDiffManager = options.checkoutDiffManager;
|
this.checkoutDiffManager = options.checkoutDiffManager;
|
||||||
|
this.gitMetadataGenerator = options.gitMetadataGenerator;
|
||||||
this.paseoHome = options.paseoHome;
|
this.paseoHome = options.paseoHome;
|
||||||
this.worktreesRoot = options.worktreesRoot;
|
this.worktreesRoot = options.worktreesRoot;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
@@ -537,7 +538,7 @@ export class CheckoutSession {
|
|||||||
try {
|
try {
|
||||||
let message = msg.message?.trim() ?? "";
|
let message = msg.message?.trim() ?? "";
|
||||||
if (!message) {
|
if (!message) {
|
||||||
message = await this.host.generateCommitMessage(cwd);
|
message = await this.gitMetadataGenerator.generateCommitMessage(cwd);
|
||||||
}
|
}
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error("Commit message is required");
|
throw new Error("Commit message is required");
|
||||||
@@ -747,7 +748,7 @@ export class CheckoutSession {
|
|||||||
let body = msg.body?.trim() ?? "";
|
let body = msg.body?.trim() ?? "";
|
||||||
|
|
||||||
if (!title || !body) {
|
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 (!title) title = generated.title;
|
||||||
if (!body) body = generated.body;
|
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