diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6a1a8c1510fa..7b1502a711c6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -30,6 +30,7 @@ import { ProviderDriverKind, ProviderInstanceId, TextGenerationError, + ServerProvider, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; @@ -52,6 +53,7 @@ import * as GitManager from "./GitManager.ts"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); interface FakeGhScenario { prListSequence?: string[]; @@ -635,6 +637,7 @@ function makeManager(input?: { sourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; + providers?: ReadonlyArray; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; }) { @@ -692,7 +695,7 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(TextGeneration.TextGeneration, textGeneration), Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), + getProviders: Effect.succeed(input?.providers ?? []), }), Layer.succeed( ProjectSetupScriptRunner.ProjectSetupScriptRunner, @@ -719,6 +722,116 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { + for (const writer of ["unset", "available", "unavailable", "disabled"] as const) { + it.effect( + `commit generation failures identify the attempted model with an ${writer} writer`, + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "changed\n"); + const instanceId = ProviderInstanceId.make("test_writer"); + const writerSelection = { instanceId, model: "writer-model" }; + const cause = new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Unsupported model", + }); + const { manager } = yield* makeManager({ + serverSettings: { + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + config: {}, + enabled: writer !== "disabled", + }, + }, + sourceControlWriterModelSelection: writer === "unset" ? null : writerSelection, + }, + providers: + writer === "available" || writer === "disabled" + ? [ + decodeServerProvider({ + instanceId, + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-26T00:00:00.000Z", + models: [], + }), + ] + : [], + textGeneration: { generateCommitMessage: () => Effect.fail(cause) }, + }); + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit" }).pipe( + Effect.flip, + ); + const selection = + writer === "available" + ? writerSelection + : DEFAULT_SERVER_SETTINGS.textGenerationModelSelection; + expect(error).toMatchObject({ + _tag: "TextGenerationError", + operation: "generateCommitMessage", + detail: "fake text generation failed", + cause, + modelSelection: { instanceId: selection.instanceId, model: selection.model }, + modelSetting: + writer === "available" + ? "sourceControlWriterModelSelection" + : "textGenerationModelSelection", + }); + expect(error.message).toContain(selection.model); + expect(error.message).toContain(selection.instanceId); + expect((yield* runGit(repoDir, ["log", "-1", "--pretty=%s"])).stdout.trim()).toBe( + "Initial commit", + ); + }), + ); + } + + it.effect("PR generation failures carry model context without creating a PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature-generation-error"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-generation-error"]); + yield* runGit(repoDir, ["config", "branch.feature-generation-error.gh-merge-base", "main"]); + const { manager, ghCalls } = yield* makeManager({ + textGeneration: { + generatePrContent: () => + Effect.fail( + new TextGenerationError({ + operation: "generatePrContent", + detail: "Model unavailable", + }), + ), + }, + ghScenario: { prListSequence: ["[]"] }, + }); + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }).pipe( + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "TextGenerationError", + operation: "generatePrContent", + modelSelection: { + instanceId: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId, + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + }, + modelSetting: "textGenerationModelSelection", + }); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + ); + it.effect("status includes draft PR metadata when branch already has a draft PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f57afeb2a419..c669b633c6f4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -35,6 +35,7 @@ import { type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, + TextGenerationError, } from "@t3tools/contracts"; import { hasProjectSettingsOverrides, @@ -95,9 +96,27 @@ export type GitBranchPullRequest = NonNullable & { interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; + readonly modelSetting: NonNullable; readonly style: SourceControlWritingStyleSettings; } +/** Adds the attempted model and the setting that chose it to a generation failure. */ +function withTextGenerationContext( + error: TextGenerationError, + settings: SourceControlTextGenerationSettings, +): TextGenerationError { + return new TextGenerationError({ + operation: error.operation, + detail: error.detail, + ...(error.cause !== undefined ? { cause: error.cause } : {}), + modelSelection: { + instanceId: settings.modelSelection.instanceId, + model: settings.modelSelection.model, + }, + modelSetting: settings.modelSetting, + }); +} + export class GitManager extends Context.Service< GitManager, { @@ -1877,7 +1896,10 @@ export const make = Effect.gen(function* () { ...(policy ? { policy } : {}), modelSelection: input.settings.modelSelection, }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + .pipe( + Effect.mapError((error) => withTextGenerationContext(error, input.settings)), + Effect.map((result) => sanitizeCommitMessage(result)), + ); return { subject: generated.subject, @@ -2057,17 +2079,19 @@ export const make = Effect.gen(function* () { ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) : undefined; - const generated = yield* textGeneration.generatePrContent({ - cwd, - baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - ...(changeRequestTemplate ? { changeRequestTemplate } : {}), - ...(policy ? { policy } : {}), - modelSelection: settings.modelSelection, - }); + const generated = yield* textGeneration + .generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(changeRequestTemplate ? { changeRequestTemplate } : {}), + ...(policy ? { policy } : {}), + modelSelection: settings.modelSelection, + }) + .pipe(Effect.mapError((error) => withTextGenerationContext(error, settings))); const bodyFile = path.join( tempDir, @@ -2708,16 +2732,26 @@ export const make = Effect.gen(function* () { settings.sourceControlWriterModelSelection === null ? Effect.succeed({ modelSelection: settings.textGenerationModelSelection, + modelSetting: "textGenerationModelSelection" as const, style: settings.sourceControlWritingStyle, }) : providerRegistry.getProviders.pipe( - Effect.map((providers) => ({ - modelSelection: ServerSettings.resolveSourceControlWriterModelSelection( + Effect.map((providers) => { + const modelSelection = ServerSettings.resolveSourceControlWriterModelSelection( settings, providers, - ), - style: settings.sourceControlWritingStyle, - })), + ); + return { + modelSelection, + // The resolver returns the chosen settings object itself, so identity tells + // which setting supplied it even when both name the same model. + modelSetting: + modelSelection === settings.sourceControlWriterModelSelection + ? ("sourceControlWriterModelSelection" as const) + : ("textGenerationModelSelection" as const), + style: settings.sourceControlWritingStyle, + }; + }), ), ), Effect.mapError( diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..5ee6c0d85fb2 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildGitActionProgressStages, @@ -8,9 +8,82 @@ import { resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, resolveQuickAction, + resolveGitActionSettingsScope, resolveThreadBranchUpdate, resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; +import { resolveSettingsScope } from "./settings/settingsScope"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; +import type { ProjectGroupingSettings } from "../logicalProject"; + +describe("generation failure settings scope", () => { + const environmentId = EnvironmentId.make("acting-environment"); + const project: Project = { + id: ProjectId.make("acting-project"), + environmentId, + title: "Test", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const groupingSettings: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }; + const environments = [{ environmentId, label: "Acting environment" }]; + + /** Resolves the generated link through the same group and scope builders used by Settings. */ + function resolveDestination(projects: Project[], projectId: ProjectId | undefined = project.id) { + const search = resolveGitActionSettingsScope({ + environmentId, + projectId, + gitCwd: "/repo", + projects, + groupingSettings, + }); + const groups = buildSidebarProjectSnapshots({ + projects, + settings: groupingSettings, + primaryEnvironmentId: environmentId, + resolveEnvironmentLabel: () => "Acting environment", + }); + return { search, scope: resolveSettingsScope(search, groups, environments) }; + } + + it("keeps the acting checkout for an existing project", () => { + assert.equal(resolveDestination([project]).scope.kind, "checkout"); + }); + + it("rejects a removed project instead of falling back to environment defaults", () => { + const { search, scope } = resolveDestination([]); + assert.equal(search.checkout, "acting-environment:/repo"); + assert.equal(search.machine, environmentId); + assert.equal(scope.kind, "unavailable"); + assert.deepEqual(scope.environmentIds, []); + }); + + it("does not substitute a different project record at the same path", () => { + assert.equal( + resolveDestination([{ ...project, id: ProjectId.make("replacement") }]).scope.kind, + "unavailable", + ); + }); + + it("does not substitute a project from another environment", () => { + assert.equal( + resolveDestination([{ ...project, environmentId: EnvironmentId.make("other") }]).scope.kind, + "unavailable", + ); + }); + + it("keeps a previously resolved target unavailable if it disappears before Settings loads", () => { + const { search } = resolveDestination([project]); + assert.equal(resolveSettingsScope(search, [], environments).kind, "unavailable"); + }); +}); function status(overrides: Partial = {}): VcsStatusResult { return { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..deec0d3d76ea 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,4 +1,6 @@ import type { + EnvironmentId, + ProjectId, GitRunStackedActionResult, GitStackedAction, VcsStatusResult, @@ -9,6 +11,39 @@ import { getChangeRequestTerminology, type ChangeRequestTerminology, } from "../sourceControlPresentation"; +import { + derivePhysicalProjectKey, + derivePhysicalProjectKeyFromPath, + type ProjectGroupingSettings, +} from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; + +/** Keeps a missing checkout identifiable so Settings rejects it instead of editing environment defaults. */ +export function resolveGitActionSettingsScope(input: { + environmentId: EnvironmentId; + projectId: ProjectId | undefined; + gitCwd: string; + projects: readonly Project[]; + groupingSettings: ProjectGroupingSettings; +}) { + const settingsProject = input.projects.find( + (project) => + project.environmentId === input.environmentId && + (input.projectId ? project.id === input.projectId : project.workspaceRoot === input.gitCwd), + ); + const checkout = settingsProject + ? derivePhysicalProjectKey(settingsProject) + : derivePhysicalProjectKeyFromPath(input.environmentId, input.gitCwd); + const project = settingsProject + ? buildPhysicalToLogicalProjectKeyMap({ + projects: input.projects, + settings: input.groupingSettings, + primaryEnvironmentId: input.environmentId, + }).get(checkout) + : undefined; + return { machine: input.environmentId, project, checkout }; +} export type GitActionIconName = "commit" | "push" | "pr"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d..eb50775f4eb7 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { type ScopedThreadRef } from "@t3tools/contracts"; +import { TextGenerationError, type ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -17,6 +17,7 @@ import type { } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback, @@ -62,6 +63,7 @@ import { resolveLiveThreadBranchUpdate, resolveThreadBranchMetadataPatch, resolveQuickAction, + resolveGitActionSettingsScope, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; @@ -102,7 +104,9 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThreadShell } from "~/state/entities"; +import { readProjects, useThreadShell } from "~/state/entities"; +import { getClientSettings } from "~/hooks/useSettings"; +import { selectProjectGroupingSettings } from "~/logicalProject"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -116,6 +120,8 @@ import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; +const isTextGenerationError = Schema.is(TextGenerationError); + interface GitActionsControlProps { presentation?: "toolbar" | "menu"; gitCwd: string | null; @@ -949,6 +955,7 @@ export default function GitActionsControl({ draftId, onOpenPullRequest, }: GitActionsControlProps) { + const navigate = useNavigate(); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", @@ -1387,12 +1394,41 @@ export default function GitActionsControl({ } const error = squashAtomCommandFailure(result); + const modelSetting = isTextGenerationError(error) ? error.modelSetting : undefined; toastManager.update( resolvedProgressToastId, stackedThreadToast({ type: "error", title: "Action failed", description: error instanceof Error ? error.message : "An error occurred.", + ...(isTextGenerationError(error) && activeEnvironmentId !== null && gitCwd !== null + ? { + actionProps: { + children: "Settings", + onClick: () => { + const search = resolveGitActionSettingsScope({ + environmentId: activeEnvironmentId, + projectId: activeServerThread?.projectId ?? activeDraftThread?.projectId, + gitCwd, + projects: readProjects(), + groupingSettings: selectProjectGroupingSettings(getClientSettings()), + }); + toastManager.close(resolvedProgressToastId); + void navigate({ + to: + modelSetting === "sourceControlWriterModelSelection" + ? "/settings/source-control" + : "/settings/general", + hash: + modelSetting === "sourceControlWriterModelSelection" + ? "source-control-writer-model" + : "text-generation-model", + search, + }); + }, + }, + } + : {}), ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), }), ); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..513786c7dc1d 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -8,7 +8,47 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + TextGenerationError, + GitManagerServiceError, } from "./git.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const decodeTextGenerationError = Schema.decodeUnknownSync(TextGenerationError); +const gitManagerErrorJson = Schema.fromJsonString(GitManagerServiceError); +const encodeGitManagerError = Schema.encodeSync(gitManagerErrorJson); +const decodeGitManagerError = Schema.decodeUnknownSync(gitManagerErrorJson); + +describe("TextGenerationError", () => { + it("preserves model context and diagnostic detail through the RPC error schema", () => { + const error = new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Model is not supported by this backend", + modelSelection: { instanceId: ProviderInstanceId.make("claude_work"), model: "custom-model" }, + modelSetting: "sourceControlWriterModelSelection", + }); + const decoded = decodeGitManagerError(encodeGitManagerError(error)); + expect(decoded).toBeInstanceOf(TextGenerationError); + expect(decoded).toMatchObject({ + modelSelection: error.modelSelection, + modelSetting: error.modelSetting, + }); + expect(decoded.message).toBe( + "Text generation failed in generateCommitMessage using custom-model (claude_work): Model is not supported by this backend", + ); + }); + + it("still decodes errors from servers without model context", () => { + const decoded = decodeTextGenerationError({ + _tag: "TextGenerationError", + operation: "generatePrContent", + detail: "Provider unavailable", + }); + expect(decoded.message).toBe( + "Text generation failed in generatePrContent: Provider unavailable", + ); + expect(decoded.modelSelection).toBeUndefined(); + }); +}); const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index c5a5825e193e..63b73e2bb2bb 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { ProviderInstanceId } from "./providerInstance.ts"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; @@ -360,11 +361,21 @@ export class TextGenerationError extends Schema.TaggedError { operation: Schema.String, detail: Schema.String, + modelSelection: Schema.optional( + Schema.Struct({ instanceId: ProviderInstanceId, model: TrimmedNonEmptyString }), + ), + modelSetting: Schema.optional( + Schema.Literals(["textGenerationModelSelection", "sourceControlWriterModelSelection"]), + ), cause: Schema.optional(Schema.Defect()), }, ) { + /** Names the attempted model when known; older servers send errors without it. */ override get message(): string { - return `Text generation failed in ${this.operation}: ${this.detail}`; + const model = this.modelSelection + ? ` using ${this.modelSelection.model} (${this.modelSelection.instanceId})` + : ""; + return `Text generation failed in ${this.operation}${model}: ${this.detail}`; } }