Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 242 additions & 23 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { randomUUID } from "node:crypto";

import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import {
CopilotClient,
type CopilotClientOptions,
type SessionEvent,
approveAll,
} from "@github/copilot-sdk";
import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts";

import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { resolveBundledCopilotCliPath } from "../../provider/Layers/copilotCliPath.ts";
import { TextGenerationError } from "../Errors.ts";
import {
type BranchNameGenerationInput,
Expand All @@ -20,6 +28,8 @@ import {
const CODEX_MODEL = "gpt-5.3-codex";
const CODEX_REASONING_EFFORT = "low";
const CODEX_TIMEOUT_MS = 180_000;
const COPILOT_MODEL = DEFAULT_MODEL_BY_PROVIDER.copilot;
const COPILOT_TIMEOUT_MS = 180_000;

function toCodexOutputJsonSchema(schema: Schema.Top): unknown {
const document = Schema.toJsonSchemaDocument(schema);
Expand Down Expand Up @@ -68,6 +78,44 @@ function normalizeCodexError(
});
}

function normalizeCopilotError(
operation: string,
error: unknown,
fallback: string,
): TextGenerationError {
if (Schema.is(TextGenerationError)(error)) {
return error;
}

if (error instanceof Error) {
const lower = error.message.toLowerCase();
if (
lower.includes("copilot") &&
(lower.includes("enoent") ||
lower.includes("not found") ||
lower.includes("missing") ||
lower.includes("spawn"))
) {
return new TextGenerationError({
operation,
detail: "GitHub Copilot CLI is required but not available.",
cause: error,
});
}
return new TextGenerationError({
operation,
detail: `${fallback}: ${error.message}`,
cause: error,
});
}

return new TextGenerationError({
operation,
detail: fallback,
cause: error,
});
}

function limitSection(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
const truncated = value.slice(0, maxChars);
Expand Down Expand Up @@ -95,6 +143,58 @@ function sanitizePrTitle(raw: string): string {
return "Update project changes";
}

function extractJsonCandidate(text: string): string {
const trimmed = text.trim();
if (trimmed.length === 0) {
return trimmed;
}

const fencedMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fencedMatch?.[1]) {
return fencedMatch[1].trim();
}

const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
return trimmed.slice(firstBrace, lastBrace + 1);
}

return trimmed;
}

function extractLastCopilotAssistantText(events: ReadonlyArray<SessionEvent>): string {
let latestCompleted = "";
const deltaByMessageId = new Map<string, string>();
let latestDeltaMessageId: string | null = null;

for (const event of events) {
if (event.type === "assistant.message") {
const content = event.data.content.trim();
if (content.length > 0) {
latestCompleted = content;
}
continue;
}

if (event.type === "assistant.message_delta") {
const next = `${deltaByMessageId.get(event.data.messageId) ?? ""}${event.data.deltaContent}`;
deltaByMessageId.set(event.data.messageId, next);
latestDeltaMessageId = event.data.messageId;
}
}

if (latestCompleted.length > 0) {
return latestCompleted;
}

if (latestDeltaMessageId) {
return (deltaByMessageId.get(latestDeltaMessageId) ?? "").trim();
}

return "";
}

const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -180,6 +280,39 @@ const makeCodexTextGeneration = Effect.gen(function* () {
return { imagePaths };
});

const decodeStructuredText = <S extends Schema.Top>({
operation,
text,
outputSchemaJson,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
text: string;
outputSchemaJson: S;
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.gen(function* () {
const jsonText = extractJsonCandidate(text);
const parsed = yield* Effect.try({
try: () => JSON.parse(jsonText) as unknown,
catch: (cause) =>
new TextGenerationError({
operation,
detail: "Provider returned invalid JSON output.",
cause,
}),
});

return yield* Schema.decodeUnknownEffect(outputSchemaJson)(parsed).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation,
detail: "Provider returned invalid structured output.",
cause,
}),
),
);
});

const runCodexJson = <S extends Schema.Top>({
operation,
cwd,
Expand Down Expand Up @@ -312,6 +445,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {
}).pipe(Effect.ensuring(cleanup));
});

const runCopilotJson = <S extends Schema.Top>({
operation,
cwd,
prompt,
outputSchemaJson,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
prompt: string;
outputSchemaJson: S;
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.tryPromise({
try: async () => {
const cliPath = resolveBundledCopilotCliPath();
const clientOptions: CopilotClientOptions = {
...(cliPath ? { cliPath } : {}),
cwd,
logLevel: "error",
};
const client = new CopilotClient(clientOptions);
let session: Awaited<ReturnType<CopilotClient["createSession"]>> | undefined;

try {
await client.start();
session = await client.createSession({
workingDirectory: cwd,
model: COPILOT_MODEL,
streaming: true,
onPermissionRequest: approveAll,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const response = await session.sendAndWait(
{
prompt,
mode: "immediate",
},
COPILOT_TIMEOUT_MS,
);
const history = await session.getMessages().catch(() => [] as SessionEvent[]);
const assistantText =
response?.data.content?.trim() || extractLastCopilotAssistantText(history);
if (assistantText.length === 0) {
throw new Error("GitHub Copilot did not return an assistant response.");
}
return assistantText;
} finally {
await session?.destroy().catch(() => undefined);
await client.stop().catch(() => []);
}
},
catch: (cause) =>
normalizeCopilotError(operation, cause, "GitHub Copilot text generation failed"),
}).pipe(Effect.flatMap((text) => decodeStructuredText({ operation, text, outputSchemaJson })));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
const wantsBranch = input.includeBranch === true;

Expand Down Expand Up @@ -348,12 +535,22 @@ const makeCodexTextGeneration = Effect.gen(function* () {
body: Schema.String,
});

return runCodexJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson,
}).pipe(
const generateJson =
input.provider === "copilot"
? runCopilotJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson,
})
: runCodexJson({
operation: "generateCommitMessage",
cwd: input.cwd,
prompt,
outputSchemaJson,
});

return generateJson.pipe(
Effect.map(
(generated) =>
({
Expand Down Expand Up @@ -390,15 +587,27 @@ const makeCodexTextGeneration = Effect.gen(function* () {
limitSection(input.diffPatch, 40_000),
].join("\n");

return runCodexJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson: Schema.Struct({
title: Schema.String,
body: Schema.String,
}),
}).pipe(
const outputSchemaJson = Schema.Struct({
title: Schema.String,
body: Schema.String,
});

const generateJson =
input.provider === "copilot"
? runCopilotJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson,
})
: runCodexJson({
operation: "generatePrContent",
cwd: input.cwd,
prompt,
outputSchemaJson,
});

return generateJson.pipe(
Effect.map(
(generated) =>
({
Expand Down Expand Up @@ -441,16 +650,26 @@ const makeCodexTextGeneration = Effect.gen(function* () {
}
const prompt = promptSections.join("\n");

const generated = yield* runCodexJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson: Schema.Struct({
branch: Schema.String,
}),
imagePaths,
const outputSchemaJson = Schema.Struct({
branch: Schema.String,
});

const generated =
input.provider === "copilot"
? yield* runCopilotJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson,
})
: yield* runCodexJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson,
imagePaths,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
branch: sanitizeBranchFragment(generated.branch),
} satisfies BranchNameGenerationResult;
Expand Down
Loading
Loading