import * as Crypto from "effect/Crypto";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Option from "effect/Option";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import { ChildProcessSpawner } from "effect/unstable/process";import type * as EffectAcpErrors from "effect-acp/errors";import type * as EffectAcpSchema from "effect-acp/schema"; import { type ChatAttachment, type FxSettings, type ModelSelection, TextGenerationError,} from "@t3tools/contracts";import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { resolveAttachmentPath } from "../attachmentStore.ts";import { ServerConfig } from "../config.ts";import * as TextGeneration from "./TextGeneration.ts";import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt,} from "./TextGenerationPrompts.ts";import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle,} from "./TextGenerationUtils.ts";import { applyFxAcpModelSelection, currentFxModelIdFromSessionSetup, makeFxAcpRuntime, resolveFxAcpBaseModelId,} from "../provider/acp/FxAcpSupport.ts"; const FX_TIMEOUT_MS = 180_000; const isTextGenerationError = Schema.is(TextGenerationError); export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* ( fxSettings: FxSettings, environment: NodeJS.ProcessEnv = process.env,) { const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* ServerConfig; const runFxJson = <S extends Schema.Top>({ operation, cwd, prompt, outputSchemaJson, modelSelection, attachments, }: { operation: | "generateCommitMessage" | "generatePrContent" | "generateBranchName" | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; modelSelection: ModelSelection; attachments?: ReadonlyArray<ChatAttachment> | undefined; }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => Effect.gen(function* () { const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model); const outputRef = yield* Ref.make(""); const runtime = yield* makeFxAcpRuntime({ fxSettings, environment, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), Effect.provideService(Crypto.Crypto, crypto), ); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; if (update.sessionUpdate !== "agent_message_chunk") { return Effect.void; } const content = update.content; if (content.type !== "text") { return Effect.void; } return Ref.update(outputRef, (current) => current + content.text); }); const imagePromptParts = yield* Effect.forEach(attachments ?? [], (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!attachmentPath) { return yield* new TextGenerationError({ operation, detail: `Invalid fx text-generation attachment id '${attachment.id}'.`, }); } const bytes = yield* fileSystem.readFile(attachmentPath).pipe( Effect.mapError( (cause) => new TextGenerationError({ operation, detail: "Failed to read an fx text-generation attachment.", cause, }), ), ); return { type: "image", data: Buffer.from(bytes).toString("base64"), mimeType: attachment.mimeType, } satisfies EffectAcpSchema.ContentBlock; }), ); const promptResult = yield* Effect.gen(function* () { const started = yield* runtime.start(); yield* applyFxAcpModelSelection({ runtime, currentModelId: currentFxModelIdFromSessionSetup(started.sessionSetupResult), requestedModelId: resolvedModel, mapError: (cause) => new TextGenerationError({ operation, detail: "Failed to set Fx ACP base model for text generation.", cause, }), }); return yield* runtime.prompt({ prompt: [{ type: "text", text: prompt }, ...imagePromptParts], }); }).pipe( Effect.timeoutOption(FX_TIMEOUT_MS), Effect.flatMap( Option.match({ onNone: () => Effect.fail( new TextGenerationError({ operation, detail: "Fx ACP request timed out." }), ), onSome: (value) => Effect.succeed(value), }), ), Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => isTextGenerationError(cause) ? cause : new TextGenerationError({ operation, detail: "Fx ACP request failed.", cause, }), ), ); const trimmed = (yield* Ref.get(outputRef)).trim(); if (!trimmed) { return yield* new TextGenerationError({ operation, detail: promptResult.stopReason === "cancelled" ? "Fx ACP request was cancelled." : "Fx Agent returned empty output.", }); } const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); return yield* decodeOutput(extractJsonObject(trimmed)).pipe( Effect.catchTags({ SchemaError: (cause) => Effect.fail( new TextGenerationError({ operation, detail: "Fx Agent returned invalid structured output.", cause, }), ), }), ); }).pipe( Effect.mapError((cause) => isTextGenerationError(cause) ? cause : new TextGenerationError({ operation, detail: "Fx ACP text generation failed.", cause, }), ), Effect.scoped, ); const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = Effect.fn("FxTextGeneration.generateCommitMessage")(function* (input) { const { prompt, outputSchema } = buildCommitMessagePrompt({ branch: input.branch, stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, policy: input.policy, }); const generated = yield* runFxJson({ operation: "generateCommitMessage", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), ...("branch" in generated && typeof generated.branch === "string" ? { branch: sanitizeFeatureBranchName(generated.branch) } : {}), }; }); const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = Effect.fn("FxTextGeneration.generatePrContent")(function* (input) { const { prompt, outputSchema } = buildPrContentPrompt({ baseBranch: input.baseBranch, headBranch: input.headBranch, commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, policy: input.policy, changeRequestTemplate: input.changeRequestTemplate, }); const generated = yield* runFxJson({ operation: "generatePrContent", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, }); return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), }; }); const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = Effect.fn("FxTextGeneration.generateBranchName")(function* (input) { const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, }); const generated = yield* runFxJson({ operation: "generateBranchName", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, attachments: input.attachments, }); return { branch: sanitizeBranchFragment(generated.branch), }; }); const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = Effect.fn("FxTextGeneration.generateThreadTitle")(function* (input) { const { prompt, outputSchema } = buildThreadTitlePrompt({ message: input.message, previousTitle: input.previousTitle, attachments: input.attachments, }); const generated = yield* runFxJson({ operation: "generateThreadTitle", cwd: input.cwd, prompt, outputSchemaJson: outputSchema, modelSelection: input.modelSelection, attachments: input.attachments, }); return { title: sanitizeThreadTitle(generated.title), } satisfies TextGeneration.ThreadTitleGenerationResult; }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, } satisfies TextGeneration.TextGeneration["Service"];});
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/unstable/process";
import type * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpSchema from "effect-acp/schema";
import {
type ChatAttachment,
type FxSettings,
type ModelSelection,
TextGenerationError,
} from "@t3tools/contracts";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
import { extractJsonObject } from "@t3tools/shared/schemaJson";
import { resolveAttachmentPath } from "../attachmentStore.ts";
import { ServerConfig } from "../config.ts";
import * as TextGeneration from "./TextGeneration.ts";
import {
buildBranchNamePrompt,
buildCommitMessagePrompt,
buildPrContentPrompt,
buildThreadTitlePrompt,
} from "./TextGenerationPrompts.ts";
import {
sanitizeCommitSubject,
sanitizePrTitle,
sanitizeThreadTitle,
} from "./TextGenerationUtils.ts";
import {
applyFxAcpModelSelection,
currentFxModelIdFromSessionSetup,
makeFxAcpRuntime,
resolveFxAcpBaseModelId,
} from "../provider/acp/FxAcpSupport.ts";
const FX_TIMEOUT_MS = 180_000;
const isTextGenerationError = Schema.is(TextGenerationError);
export const makeFxTextGeneration = Effect.fn("makeFxTextGeneration")(function* (
fxSettings: FxSettings,
environment: NodeJS.ProcessEnv = process.env,
) {
const crypto = yield* Crypto.Crypto;
const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const serverConfig = yield* ServerConfig;
const runFxJson = <S extends Schema.Top>({
operation,
cwd,
prompt,
outputSchemaJson,
modelSelection,
attachments,
}: {
operation:
| "generateCommitMessage"
| "generatePrContent"
| "generateBranchName"
| "generateThreadTitle";
cwd: string;
prompt: string;
outputSchemaJson: S;
modelSelection: ModelSelection;
attachments?: ReadonlyArray<ChatAttachment> | undefined;
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.gen(function* () {
const resolvedModel = resolveFxAcpBaseModelId(modelSelection.model);
const outputRef = yield* Ref.make("");
const runtime = yield* makeFxAcpRuntime({
fxSettings,
environment,
cwd,
clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner),
Effect.provideService(Crypto.Crypto, crypto),
);
yield* runtime.handleSessionUpdate((notification) => {
const update = notification.update;
if (update.sessionUpdate !== "agent_message_chunk") {
return Effect.void;
}
const content = update.content;
if (content.type !== "text") {
return Effect.void;
}
return Ref.update(outputRef, (current) => current + content.text);
});
const imagePromptParts = yield* Effect.forEach(attachments ?? [], (attachment) =>
Effect.gen(function* () {
const attachmentPath = resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
attachment,
});
if (!attachmentPath) {
return yield* new TextGenerationError({
operation,
detail: `Invalid fx text-generation attachment id '${attachment.id}'.`,
});
}
const bytes = yield* fileSystem.readFile(attachmentPath).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation,
detail: "Failed to read an fx text-generation attachment.",
cause,
}),
),
);
return {
type: "image",
data: Buffer.from(bytes).toString("base64"),
mimeType: attachment.mimeType,
} satisfies EffectAcpSchema.ContentBlock;
}),
);
const promptResult = yield* Effect.gen(function* () {
const started = yield* runtime.start();
yield* applyFxAcpModelSelection({
runtime,
currentModelId: currentFxModelIdFromSessionSetup(started.sessionSetupResult),
requestedModelId: resolvedModel,
mapError: (cause) =>
new TextGenerationError({
operation,
detail: "Failed to set Fx ACP base model for text generation.",
cause,
}),
});
return yield* runtime.prompt({
prompt: [{ type: "text", text: prompt }, ...imagePromptParts],
});
}).pipe(
Effect.timeoutOption(FX_TIMEOUT_MS),
Effect.flatMap(
Option.match({
onNone: () =>
Effect.fail(
new TextGenerationError({ operation, detail: "Fx ACP request timed out." }),
),
onSome: (value) => Effect.succeed(value),
}),
),
Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) =>
isTextGenerationError(cause)
? cause
: new TextGenerationError({
operation,
detail: "Fx ACP request failed.",
cause,
}),
),
);
const trimmed = (yield* Ref.get(outputRef)).trim();
if (!trimmed) {
return yield* new TextGenerationError({
operation,
detail:
promptResult.stopReason === "cancelled"
? "Fx ACP request was cancelled."
: "Fx Agent returned empty output.",
});
}
const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
return yield* decodeOutput(extractJsonObject(trimmed)).pipe(
Effect.catchTags({
SchemaError: (cause) =>
Effect.fail(
new TextGenerationError({
operation,
detail: "Fx Agent returned invalid structured output.",
cause,
}),
),
}),
);
}).pipe(
Effect.mapError((cause) =>
isTextGenerationError(cause)
? cause
: new TextGenerationError({
operation,
detail: "Fx ACP text generation failed.",
cause,
}),
),
Effect.scoped,
);
const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
Effect.fn("FxTextGeneration.generateCommitMessage")(function* (input) {
const { prompt, outputSchema } = buildCommitMessagePrompt({
branch: input.branch,
stagedSummary: input.stagedSummary,
stagedPatch: input.stagedPatch,
includeBranch: input.includeBranch === true,
policy: input.policy,
});
const generated = yield* runFxJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
});
return {
subject: sanitizeCommitSubject(generated.subject),
body: generated.body.trim(),
...("branch" in generated && typeof generated.branch === "string"
? { branch: sanitizeFeatureBranchName(generated.branch) }
: {}),
};
});
const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
Effect.fn("FxTextGeneration.generatePrContent")(function* (input) {
const { prompt, outputSchema } = buildPrContentPrompt({
baseBranch: input.baseBranch,
headBranch: input.headBranch,
commitSummary: input.commitSummary,
diffSummary: input.diffSummary,
diffPatch: input.diffPatch,
policy: input.policy,
changeRequestTemplate: input.changeRequestTemplate,
});
const generated = yield* runFxJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
});
return {
title: sanitizePrTitle(generated.title),
body: generated.body.trim(),
};
});
const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
Effect.fn("FxTextGeneration.generateBranchName")(function* (input) {
const { prompt, outputSchema } = buildBranchNamePrompt({
message: input.message,
attachments: input.attachments,
});
const generated = yield* runFxJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
attachments: input.attachments,
});
return {
branch: sanitizeBranchFragment(generated.branch),
};
});
const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
Effect.fn("FxTextGeneration.generateThreadTitle")(function* (input) {
const { prompt, outputSchema } = buildThreadTitlePrompt({
message: input.message,
previousTitle: input.previousTitle,
attachments: input.attachments,
});
const generated = yield* runFxJson({
operation: "generateThreadTitle",
cwd: input.cwd,
prompt,
outputSchemaJson: outputSchema,
modelSelection: input.modelSelection,
attachments: input.attachments,
});
return {
title: sanitizeThreadTitle(generated.title),
} satisfies TextGeneration.ThreadTitleGenerationResult;
});
return {
generateCommitMessage,
generatePrContent,
generateBranchName,
generateThreadTitle,
} satisfies TextGeneration.TextGeneration["Service"];
});