From 69ca7ab4fa4e0c2b1e476745f945399bb56be54b Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:30:51 +0000 Subject: [PATCH 1/4] Route active provider to text generation for commit/PR/changelog content --- .../src/git/Layers/CodexTextGeneration.ts | 265 ++++++++++++++++-- apps/server/src/git/Layers/GitManager.test.ts | 58 ++++ apps/server/src/git/Layers/GitManager.ts | 17 +- .../server/src/git/Services/TextGeneration.ts | 5 +- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/GitActionsControl.tsx | 22 +- apps/web/src/components/chat/ChatHeader.tsx | 11 +- apps/web/src/lib/gitReactQuery.test.ts | 42 ++- apps/web/src/lib/gitReactQuery.ts | 5 +- packages/contracts/src/git.ts | 2 + 10 files changed, 404 insertions(+), 34 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93..36c7d81c 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -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, @@ -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); @@ -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); @@ -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): string { + let latestCompleted = ""; + const deltaByMessageId = new Map(); + 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; @@ -180,6 +280,39 @@ const makeCodexTextGeneration = Effect.gen(function* () { return { imagePaths }; }); + const decodeStructuredText = ({ + operation, + text, + outputSchemaJson, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + text: string; + outputSchemaJson: S; + }): Effect.Effect => + 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 = ({ operation, cwd, @@ -312,6 +445,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { }).pipe(Effect.ensuring(cleanup)); }); + const runCopilotJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const cliPath = resolveBundledCopilotCliPath(); + const clientOptions: CopilotClientOptions = { + ...(cliPath ? { cliPath } : {}), + cwd, + logLevel: "error", + }; + const client = new CopilotClient(clientOptions); + let session: Awaited> | undefined; + + try { + await client.start(); + session = await client.createSession({ + workingDirectory: cwd, + model: COPILOT_MODEL, + streaming: true, + onPermissionRequest: approveAll, + }); + + 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 }))); + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => { const wantsBranch = input.includeBranch === true; @@ -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) => ({ @@ -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) => ({ @@ -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, + }); + return { branch: sanitizeBranchFragment(generated.branch), } satisfies BranchNameGenerationResult; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8c72941c..e266a6c4 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -43,6 +43,7 @@ interface FakeGhScenario { interface FakeGitTextGeneration { generateCommitMessage: (input: { cwd: string; + provider?: "codex" | "copilot"; branch: string | null; stagedSummary: string; stagedPatch: string; @@ -53,6 +54,7 @@ interface FakeGitTextGeneration { >; generatePrContent: (input: { cwd: string; + provider?: "codex" | "copilot"; baseBranch: string; headBranch: string; commitSummary: string; @@ -61,6 +63,7 @@ interface FakeGitTextGeneration { }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { cwd: string; + provider?: "codex" | "copilot"; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; } @@ -449,6 +452,7 @@ function runStackedAction( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; + provider?: "codex" | "copilot"; commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; @@ -843,6 +847,60 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("passes the selected provider to generated commit and PR content", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/copilot-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "copilot flow\n"); + + const seenProviders: Array<"codex" | "copilot" | undefined> = []; + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 91, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "feature/copilot-pr", + }, + ]), + ], + }, + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + seenProviders.push(input.provider); + return { subject: "Use GitHub Copilot for git copy", body: "" }; + }), + generatePrContent: (input) => + Effect.sync(() => { + seenProviders.push(input.provider); + return { + title: "Use GitHub Copilot for git copy", + body: "## Summary\n- Route git copy through Copilot\n\n## Testing\n- Not run", + }; + }), + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + provider: "copilot", + }); + + expect(result.commit.status).toBe("created"); + expect(result.pr.status).toBe("created"); + expect(seenProviders).toEqual(["copilot", "copilot"]); + }), + ); + it.effect("featureBranch uses custom commit message and derives branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 83577951..a885f4aa 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -634,6 +634,7 @@ export const makeGitManager = Effect.gen(function* () { const resolveCommitAndBranchSuggestion = (input: { cwd: string; + provider?: "codex" | "copilot"; branch: string | null; commitMessage?: string; /** When true, also produce a semantic feature branch name. */ @@ -661,6 +662,7 @@ export const makeGitManager = Effect.gen(function* () { const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, + ...(input.provider ? { provider: input.provider } : {}), branch: input.branch, stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), @@ -678,6 +680,7 @@ export const makeGitManager = Effect.gen(function* () { const runCommitStep = ( cwd: string, + provider: "codex" | "copilot" | undefined, branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, @@ -688,6 +691,7 @@ export const makeGitManager = Effect.gen(function* () { preResolvedSuggestion ?? (yield* resolveCommitAndBranchSuggestion({ cwd, + ...(provider ? { provider } : {}), branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), @@ -704,7 +708,11 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null) => + const runPrStep = ( + cwd: string, + provider: "codex" | "copilot" | undefined, + fallbackBranch: string | null, + ) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -743,6 +751,7 @@ export const makeGitManager = Effect.gen(function* () { const generated = yield* textGeneration.generatePrContent({ cwd, + ...(provider ? { provider } : {}), baseBranch, headBranch: headContext.headBranch, commitSummary: limitContext(rangeContext.commitSummary, 20_000), @@ -969,6 +978,7 @@ export const makeGitManager = Effect.gen(function* () { const runFeatureBranchStep = ( cwd: string, + provider: "codex" | "copilot" | undefined, branch: string | null, commitMessage?: string, filePaths?: readonly string[], @@ -976,6 +986,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, + ...(provider ? { provider } : {}), branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), @@ -1025,6 +1036,7 @@ export const makeGitManager = Effect.gen(function* () { if (input.featureBranch) { const result = yield* runFeatureBranchStep( input.cwd, + input.provider, initialStatus.branch, input.commitMessage, input.filePaths, @@ -1040,6 +1052,7 @@ export const makeGitManager = Effect.gen(function* () { const commit = yield* runCommitStep( input.cwd, + input.provider, currentBranch, commitMessageForStep, preResolvedCommitSuggestion, @@ -1051,7 +1064,7 @@ export const makeGitManager = Effect.gen(function* () { : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, currentBranch) + ? yield* runPrStep(input.cwd, input.provider, currentBranch) : { status: "skipped_not_requested" as const }; return { diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe..2b98e7c9 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,12 +8,13 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; +import type { ChatAttachment, ProviderKind } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; export interface CommitMessageGenerationInput { cwd: string; + provider?: ProviderKind; branch: string | null; stagedSummary: string; stagedPatch: string; @@ -30,6 +31,7 @@ export interface CommitMessageGenerationResult { export interface PrContentGenerationInput { cwd: string; + provider?: ProviderKind; baseBranch: string; headBranch: string; commitSummary: string; @@ -44,6 +46,7 @@ export interface PrContentGenerationResult { export interface BranchNameGenerationInput { cwd: string; + provider?: ProviderKind; message: string; attachments?: ReadonlyArray | undefined; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2a9a2760..7ac89c7c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4278,6 +4278,7 @@ export default function ChatView({ threadId }: ChatViewProps) { availableEditors={availableEditors} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} + activeProvider={activeProvider} diffOpen={diffOpen} onRunProjectScript={(script) => { void runProjectScript(script); @@ -5022,6 +5023,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; + activeProvider: ProviderKind; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -5042,6 +5044,7 @@ const ChatHeader = memo(function ChatHeader({ availableEditors, diffToggleShortcutLabel, gitCwd, + activeProvider, diffOpen, onRunProjectScript, onAddProjectScript, @@ -5089,7 +5092,13 @@ const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} void; @@ -153,7 +155,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ + gitCwd, + activeThreadId, + activeProvider, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -258,6 +264,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const runGitActionWithToast = useCallback( async ({ action, + provider = activeProvider, commitMessage, forcePushOnlyProgress = false, onConfirmed, @@ -269,6 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions filePaths, }: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; @@ -297,6 +305,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, branchName: actionBranch, includesCommit, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), @@ -348,6 +357,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, + provider, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), @@ -442,6 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }, [ + activeProvider, isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, @@ -452,11 +463,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + const { action, provider, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), @@ -468,6 +480,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutNewBranchAndRunAction = useCallback( (actionParams: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; @@ -484,11 +497,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = + const { action, provider, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ action, + provider, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911b..114e4861 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,5 +1,6 @@ import { type EditorId, + type ProviderKind, type ProjectScript, type ResolvedKeybindingsConfig, type ThreadId, @@ -26,6 +27,7 @@ interface ChatHeaderProps { availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; gitCwd: string | null; + activeProvider: ProviderKind; diffOpen: boolean; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; @@ -46,6 +48,7 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, diffToggleShortcutLabel, gitCwd, + activeProvider, diffOpen, onRunProjectScript, onAddProjectScript, @@ -93,7 +96,13 @@ export const ChatHeader = memo(function ChatHeader({ openInCwd={openInCwd} /> )} - {activeProjectName && } + {activeProjectName && ( + + )} { describe("git mutation options", () => { const queryClient = new QueryClient(); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("attaches cwd-scoped mutation key for runStackedAction", () => { const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); }); + it("forwards provider to runStackedAction RPC", async () => { + const runStackedAction = vi.fn(async () => ({ + action: "commit", + branch: { status: "skipped_not_requested" }, + commit: { status: "created", subject: "Use Copilot" }, + push: { status: "skipped_not_requested" }, + pr: { status: "skipped_not_requested" }, + })); + + vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ + git: { + runStackedAction, + }, + } as never); + + const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient }); + if (!options.mutationFn) { + throw new Error("Expected mutationFn to be defined."); + } + + await options.mutationFn( + { + action: "commit", + provider: "copilot", + }, + {} as never, + ); + + expect(runStackedAction).toHaveBeenCalledWith({ + cwd: "/repo/a", + action: "commit", + provider: "copilot", + }); + }); + it("attaches cwd-scoped mutation key for pull", () => { const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9b5fe773..ad599370 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import type { GitStackedAction } from "@t3tools/contracts"; +import type { GitStackedAction, ProviderKind } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -117,11 +117,13 @@ export function gitRunStackedActionMutationOptions(input: { mutationKey: gitMutationKeys.runStackedAction(input.cwd), mutationFn: async ({ action, + provider, commitMessage, featureBranch, filePaths, }: { action: GitStackedAction; + provider?: ProviderKind; commitMessage?: string; featureBranch?: boolean; filePaths?: string[]; @@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: { return api.git.runStackedAction({ cwd: input.cwd, action, + ...(provider ? { provider } : {}), ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 081b4d0d..d5fe8142 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -59,6 +60,7 @@ export type GitPullInput = typeof GitPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, action: GitStackedAction, + provider: Schema.optional(ProviderKind), commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), filePaths: Schema.optional( From f3c5cc7411f0a263e6d1091352a4f27ee55ce3b3 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:12:27 +0000 Subject: [PATCH 2/4] fix: restrict copilot git text generation permissions Co-authored-by: Capy --- apps/server/src/git/Layers/CodexTextGeneration.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 36c7d81c..ce9dcb11 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -5,8 +5,9 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CopilotClient, type CopilotClientOptions, + type PermissionRequest, + type PermissionRequestResult, type SessionEvent, - approveAll, } from "@github/copilot-sdk"; import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; @@ -163,6 +164,10 @@ function extractJsonCandidate(text: string): string { return trimmed; } +function denyCopilotPermissionRequest(_request: PermissionRequest): PermissionRequestResult { + return { kind: "denied-by-rules" }; +} + function extractLastCopilotAssistantText(events: ReadonlyArray): string { let latestCompleted = ""; const deltaByMessageId = new Map(); @@ -473,7 +478,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { workingDirectory: cwd, model: COPILOT_MODEL, streaming: true, - onPermissionRequest: approveAll, + availableTools: [], + onPermissionRequest: denyCopilotPermissionRequest, }); const response = await session.sendAndWait( From a13b857cbd956b409dc23d66ffa1c0e1b5842506 Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:25:47 +0000 Subject: [PATCH 3/4] fix: harden copilot git text generation Co-authored-by: Capy --- .../src/git/Layers/CodexTextGeneration.ts | 76 ++++++++++++++----- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index ce9dcb11..aa890b50 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -168,6 +168,29 @@ function denyCopilotPermissionRequest(_request: PermissionRequest): PermissionRe return { kind: "denied-by-rules" }; } +function withPromiseTimeout( + operation: () => Promise, + timeoutMs: number, + onTimeout: () => Error, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(onTimeout()); + }, timeoutMs); + + void operation().then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + function extractLastCopilotAssistantText(events: ReadonlyArray): string { let latestCompleted = ""; const deltaByMessageId = new Map(); @@ -473,29 +496,35 @@ const makeCodexTextGeneration = Effect.gen(function* () { let session: Awaited> | undefined; try { - await client.start(); - session = await client.createSession({ - workingDirectory: cwd, - model: COPILOT_MODEL, - streaming: true, - availableTools: [], - onPermissionRequest: denyCopilotPermissionRequest, - }); - - const response = await session.sendAndWait( - { - prompt, - mode: "immediate", + return await withPromiseTimeout( + async () => { + await client.start(); + session = await client.createSession({ + workingDirectory: cwd, + model: COPILOT_MODEL, + streaming: true, + availableTools: [], + onPermissionRequest: denyCopilotPermissionRequest, + }); + + 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; }, COPILOT_TIMEOUT_MS, + () => new Error("GitHub Copilot request timed out."), ); - 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(() => []); @@ -660,6 +689,13 @@ const makeCodexTextGeneration = Effect.gen(function* () { branch: Schema.String, }); + if (input.provider === "copilot" && imagePaths.length > 0) { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Copilot branch generation does not support image attachments yet.", + }); + } + const generated = input.provider === "copilot" ? yield* runCopilotJson({ From f2f2136d046aa8d2e1e6103ce789c9f719f89ded Mon Sep 17 00:00:00 2001 From: zortos293 <65777760+zortos293@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:42:10 +0000 Subject: [PATCH 4/4] fix: cancel timed out copilot git requests Co-authored-by: Capy --- .../src/git/Layers/CodexTextGeneration.ts | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index aa890b50..580b3b1e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -31,6 +31,7 @@ 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; +const COPILOT_TIMEOUT_CANCELLATION_GRACE_MS = 5_000; function toCodexOutputJsonSchema(schema: Schema.Top): unknown { const document = Schema.toJsonSchemaDocument(schema); @@ -171,19 +172,41 @@ function denyCopilotPermissionRequest(_request: PermissionRequest): PermissionRe function withPromiseTimeout( operation: () => Promise, timeoutMs: number, + cancelOnTimeout: () => Promise | void, onTimeout: () => Error, ): Promise { return new Promise((resolve, reject) => { + let settled = false; const timeout = setTimeout(() => { - reject(onTimeout()); + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + void Promise.race([ + Promise.resolve(cancelOnTimeout()).catch(() => undefined), + new Promise((resolveCancellation) => { + setTimeout(resolveCancellation, COPILOT_TIMEOUT_CANCELLATION_GRACE_MS); + }), + ]).finally(() => { + reject(onTimeout()); + }); }, timeoutMs); void operation().then( (value) => { + if (settled) { + return; + } + settled = true; clearTimeout(timeout); resolve(value); }, (error) => { + if (settled) { + return; + } + settled = true; clearTimeout(timeout); reject(error); }, @@ -507,13 +530,28 @@ const makeCodexTextGeneration = Effect.gen(function* () { onPermissionRequest: denyCopilotPermissionRequest, }); - const response = await session.sendAndWait( - { - prompt, - mode: "immediate", - }, - COPILOT_TIMEOUT_MS, - ); + const response = await (async () => { + try { + return await session.sendAndWait( + { + prompt, + mode: "immediate", + }, + COPILOT_TIMEOUT_MS, + ); + } catch (error) { + const history = await session.getMessages().catch(() => [] as SessionEvent[]); + const assistantText = extractLastCopilotAssistantText(history); + if (assistantText.length > 0) { + return { + data: { + content: assistantText, + }, + }; + } + throw error; + } + })(); const history = await session.getMessages().catch(() => [] as SessionEvent[]); const assistantText = response?.data.content?.trim() || extractLastCopilotAssistantText(history); @@ -523,6 +561,16 @@ const makeCodexTextGeneration = Effect.gen(function* () { return assistantText; }, COPILOT_TIMEOUT_MS, + () => + Promise.race([ + Promise.allSettled([ + session ? session.abort().catch(() => undefined) : Promise.resolve(undefined), + client.forceStop().catch(() => undefined), + ]).then(() => undefined), + new Promise((resolveCancellation) => { + setTimeout(resolveCancellation, COPILOT_TIMEOUT_CANCELLATION_GRACE_MS); + }), + ]).then(() => undefined), () => new Error("GitHub Copilot request timed out."), ); } finally {