From 5e55aa387f4d33209f55dc6ec00cb590ae7cbc2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 22:16:24 -0800 Subject: [PATCH] Move worktree branch auto-rename into provider turn reactor - Generate and rename temporary `t3code/*` worktree branches on first user turn in `ProviderCommandReactor` - Remove websocket/client branch-generation RPCs and related contract/API surface - Update Codex text-generation prompt to include attachment metadata only when attachments exist, with expanded tests --- .../OrchestrationEngineHarness.integration.ts | 17 ++ .../git/Layers/CodexTextGeneration.test.ts | 67 ++++++- .../src/git/Layers/CodexTextGeneration.ts | 17 +- .../Layers/ProviderCommandReactor.test.ts | 189 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 117 +++++++++++ apps/server/src/serverLayers.ts | 9 +- apps/server/src/wsServer.test.ts | 110 ---------- apps/server/src/wsServer.ts | 54 ----- apps/web/src/components/ChatView.tsx | 72 ------- apps/web/src/wsNativeApi.ts | 3 - packages/contracts/src/ipc.ts | 8 - packages/contracts/src/ws.ts | 6 - 12 files changed, 405 insertions(+), 264 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 643c6a2e9e04f08b237940f1106234b16b958d0e..c99c29adf2c30c4ee17abae8d34eaf982fbdbe4e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -23,6 +23,8 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; +import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; +import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -227,8 +229,23 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), ); + const gitCoreLayer = Layer.succeed( + GitCore, + { + renameBranch: (input: Parameters[0]) => + Effect.succeed({ branch: input.newBranch }), + } as unknown as GitCoreShape, + ); + const textGenerationLayer = Layer.succeed( + TextGeneration, + { + generateBranchName: () => Effect.succeed({ branch: null }), + } as unknown as TextGenerationShape, + ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index d6930948cc6ce7d2d40cb407e772644d3289b2b6..7055ded51966ac79a1bd77fa14e3ea90ea3fbf5e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -38,7 +38,19 @@ function makeFakeCodexBinary(dir: string) { ' printf "%s\\n" "missing --image input" >&2', " exit 2", "fi", - "cat >/dev/null", + 'stdin_content="$(cat)"', + 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', + ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 3", + " }", + "fi", + 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', + ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', + ' printf "%s\\n" "stdin contained forbidden content" >&2', + " exit 4", + " fi", + "fi", 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', "fi", @@ -60,6 +72,8 @@ function withFakeCodexEnv( exitCode?: number; stderr?: string; requireImage?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; }, effect: Effect.Effect, ) { @@ -73,6 +87,8 @@ function withFakeCodexEnv( const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; const previousStderr = process.env.T3_FAKE_CODEX_STDERR; const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; yield* Effect.sync(() => { process.env.PATH = `${binDir}:${previousPath ?? ""}`; @@ -95,6 +111,18 @@ function withFakeCodexEnv( } else { delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; } + + if (input.stdinMustContain !== undefined) { + process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; + } else { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + } + + if (input.stdinMustNotContain !== undefined) { + process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; + } else { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; + } }); return { @@ -103,6 +131,8 @@ function withFakeCodexEnv( previousExitCode, previousStderr, previousRequireImage, + previousStdinMustContain, + previousStdinMustNotContain, }; }), () => effect, @@ -133,6 +163,18 @@ function withFakeCodexEnv( } else { process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; } + + if (previous.previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; + } + + if (previous.previousStdinMustNotContain === undefined) { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; + } }), ); } @@ -202,6 +244,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { output: JSON.stringify({ branch: " Feat/Session ", }), + stdinMustNotContain: "Image attachments supplied to the model", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -216,6 +259,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("omits attachment metadata section when no attachments are provided", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/session-timeout", + }), + stdinMustNotContain: "Attachment metadata:", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix timeout behavior.", + }); + + expect(generated.branch).toBe("fix/session-timeout"); + }), + ), + ); + it.effect("passes image attachments through as codex image inputs", () => withFakeCodexEnv( { @@ -223,6 +287,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { branch: "fix/ui-regression", }), requireImage: true, + stdinMustContain: "Attachment metadata:", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index a24a3e90bde54f23fc6d95b4e3d72bedc01e9b02..d19bb81bafe28b995aeb7f88174228622cabfa04 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -503,7 +503,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); - const prompt = [ + const promptSections = [ "You generate concise git branch names.", "Return a JSON object with key: branch.", "Rules:", @@ -512,14 +512,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { "- Use plain words only, no issue prefixes and no punctuation-heavy text.", "- If images are attached, use them as primary context for visual/UI issues.", "", - `Image attachments supplied to the model: ${imagePaths.length}`, - "", "User message:", limitSection(input.message, 8_000), - "", - "Attachment metadata:", - attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none", - ].join("\n"); + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + const prompt = promptSections.join("\n"); const generated = yield* runCodexJson({ operation: "generateBranchName", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6bbf951b7eafe67c8543d6aa908b02f0f684408f..c4644eeb3fc7e1998a688a34456945ed0b43315c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -20,6 +20,8 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; +import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; @@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn((_: unknown) => Effect.void); const stopSession = vi.fn((_: unknown) => Effect.void); + const renameBranch = vi.fn((_: unknown) => + Effect.succeed({ + branch: "t3code/generated-name", + }), + ); + const generateBranchName = vi.fn((_: unknown) => + Effect.succeed({ + branch: "generated-name" as string | null, + }), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.succeed( + TextGeneration, + { generateBranchName } as unknown as TextGenerationShape, + ), + ), ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { interruptTurn, respondToRequest, stopSession, + renameBranch, + generateBranchName, }; } @@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.sandboxMode).toBe("workspace-write"); }); + it("generates and renames temporary worktree branch on first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/89abc123", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-worktree-rename"), + role: "user", + text: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + + expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + message: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }); + expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + oldBranch: "t3code/89abc123", + newBranch: "t3code/generated-name", + }); + + await waitFor(() => { + const readModel = Effect.runSync(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + return thread?.branch === "t3code/generated-name"; + }); + }); + + it("skips worktree branch generation after the first user turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-first"), + role: "user", + text: "first", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-second"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-second"), + role: "user", + text: "second", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.generateBranchName.mock.calls.length).toBe(1); + expect(harness.renameBranch.mock.calls.length).toBe(1); + }); + + it("skips worktree rename when generated branch is null", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateBranchName.mockImplementationOnce((_: unknown) => + Effect.succeed({ + branch: null, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/0000abcd", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-null-branch"), + role: "user", + text: "Fix visual regression", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await Effect.runPromise(Effect.sleep("20 millis")); + expect(harness.renameBranch.mock.calls.length).toBe(0); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.branch).toBe("t3code/0000abcd"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1a206ff646150175caa9be49dce7001992e47aeb..072f76134c52a72c93c1194fc47bac27a3eb91a9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,6 +15,8 @@ import { import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; +const WORKTREE_BRANCH_PREFIX = "t3code"; +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); + +function isTemporaryWorktreeBranch(branch: string): boolean { + return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); +} + +function buildGeneratedWorktreeBranchName(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + + const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) + ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + : normalized; + + const branchFragment = withoutPrefix + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; + return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; +} const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const git = yield* GitCore; + const textGeneration = yield* TextGeneration; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -247,6 +280,81 @@ const make = Effect.gen(function* () { }); }); + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly messageId: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + }) { + if (!input.branch || !input.worktreePath) { + return; + } + if (!isTemporaryWorktreeBranch(input.branch)) { + return; + } + + const thread = yield* resolveThread(input.threadId); + if (!thread) { + return; + } + + const userMessages = thread.messages.filter((message) => message.role === "user"); + if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { + return; + } + + const oldBranch = input.branch; + const cwd = input.worktreePath; + const attachments = input.attachments ?? []; + const renameEffect = textGeneration + .generateBranchName({ + cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + }) + .pipe( + Effect.flatMap((generated) => { + if (!generated.branch) { + return Effect.void; + } + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === oldBranch) { + return Effect.void; + } + return git + .renameBranch({ + cwd, + oldBranch, + newBranch: targetBranch, + }) + .pipe( + Effect.flatMap((renamed) => + orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }), + ), + Effect.asVoid, + ); + }), + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { + threadId: input.threadId, + cwd, + oldBranch, + cause: Cause.pretty(cause), + }), + ), + ); + + yield* Effect.forkScoped(renameEffect); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -273,6 +381,15 @@ const make = Effect.gen(function* () { return; } + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + messageId: message.id, + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }); + yield* sendTurnForThread({ threadId: event.payload.threadId, messageText: message.text, diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index d5de9b4e9d1594287f506f4fedaa2a5e5f66ef40..45ebda3b5b4dce07c85253ab307aace5c8644db8 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -56,6 +56,9 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { + const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); + const textGenerationLayer = CodexTextGenerationLive; + const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), @@ -78,6 +81,8 @@ export function makeServerRuntimeServicesLayer() { ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -92,12 +97,10 @@ export function makeServerRuntimeServicesLayer() { Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), ); - const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); - const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(CodexTextGenerationLive), + Layer.provideMerge(textGenerationLayer), ); return Layer.mergeAll( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 6d4b02a6c2d1e8d40b70c937270387d06a1ec3ac..bed3d5b90af403c765276b2a52e18db96684ba65 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -46,7 +46,6 @@ import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; import { GitCore } from "./git/Services/GitCore.ts"; -import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; @@ -374,7 +373,6 @@ describe("WebSocket Server", () => { GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch" >; - textGeneration?: Pick; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -407,9 +405,6 @@ describe("WebSocket Server", () => { options.gitCore ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) : Layer.empty, - options.textGeneration - ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape) - : Layer.empty, options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, @@ -1477,7 +1472,6 @@ describe("WebSocket Server", () => { ); const initRepo = vi.fn(() => Effect.void); const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); - const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const })); const pullCurrentBranch = vi.fn(() => Effect.fail( new GitCommandError({ @@ -1497,9 +1491,6 @@ describe("WebSocket Server", () => { renameBranch, pullCurrentBranch, }, - textGeneration: { - generateBranchName, - }, }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1522,61 +1513,6 @@ describe("WebSocket Server", () => { expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); - const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, { - cwd: "/repo/path", - message: "Add retry logic for websocket reconnects", - attachments: [ - { - type: "image", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", - }, - ], - }); - expect(generatedBranchResponse.error).toBeUndefined(); - expect(generatedBranchResponse.result).toEqual({ branch: "new-name" }); - expect(generateBranchName).toHaveBeenCalledWith({ - cwd: "/repo/path", - message: "Add retry logic for websocket reconnects", - attachments: [ - { - type: "image", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", - }, - ], - }); - - const generateAndRenameResponse = await sendRequest( - ws, - WS_METHODS.gitGenerateAndRenameBranch, - { - cwd: "/repo/path", - oldBranch: "t3code/tmp-123", - message: "Add retry logic for websocket reconnects", - attachments: [ - { - type: "image", - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", - }, - ], - }, - ); - expect(generateAndRenameResponse.error).toBeUndefined(); - expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" }); - expect(renameBranch).toHaveBeenCalledWith({ - cwd: "/repo/path", - oldBranch: "t3code/tmp-123", - newBranch: "t3code/new-name", - }); - const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, { cwd: "/repo/path", oldBranch: "t3code/tmp-456", @@ -1591,52 +1527,6 @@ describe("WebSocket Server", () => { }); }); - it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => { - const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); - const generateBranchName = vi.fn(() => Effect.succeed({ branch: null })); - - server = await createTestServer({ - cwd: "/test", - gitCore: { - listBranches: () => - Effect.succeed({ - branches: [], - isRepo: false, - }), - initRepo: () => Effect.void, - renameBranch, - pullCurrentBranch: () => - Effect.fail( - new GitCommandError({ - operation: "GitCore.test.pullCurrentBranch", - detail: "No upstream configured", - command: "git pull", - cwd: "/repo/path", - }), - ), - }, - textGeneration: { - generateBranchName, - }, - }); - const addr = server.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - const ws = await connectWs(port); - connections.push(ws); - await waitForMessage(ws); - - const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, { - cwd: "/repo/path", - oldBranch: "t3code/tmp-123", - message: "Fix visual regression", - attachments: [], - }); - - expect(response.error).toBeUndefined(); - expect(response.result).toEqual({ branch: null }); - expect(renameBranch).not.toHaveBeenCalled(); - }); - it("supports git.status over websocket", async () => { const statusResult = { branch: "feature/test", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 838b4c50b0fe9e0a93a9fcd38f2ffd75a978b9cf..c0fa1e19de17e1bfbfbb9d3a45e8875f199cb9dd 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -54,7 +54,6 @@ import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; -import { TextGeneration } from "./git/Services/TextGeneration.ts"; import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; /** @@ -136,31 +135,6 @@ function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } -const WORKTREE_BRANCH_PREFIX = "t3code"; - -function buildGeneratedWorktreeBranchName(raw: string): string { - const normalized = raw - .trim() - .toLowerCase() - .replace(/^refs\/heads\//, "") - .replace(/['"`]/g, ""); - - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) - : normalized; - - const branchFragment = withoutPrefix - .replace(/[^a-z0-9/_-]+/g, "-") - .replace(/\/+/g, "/") - .replace(/-+/g, "-") - .replace(/^[./_-]+|[./_-]+$/g, "") - .slice(0, 64) - .replace(/[./_-]+$/g, ""); - - const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; -} - export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery @@ -172,7 +146,6 @@ export type ServerRuntimeServices = | ServerCoreRuntimeServices | GitManager | GitCore - | TextGeneration | TerminalManager | Keybindings | Open; @@ -211,7 +184,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const git = yield* GitCore; - const textGeneration = yield* TextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -600,32 +572,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* git.createWorktree(body); } - case WS_METHODS.gitGenerateBranchName: { - const body = stripRequestTag(request.body); - return yield* textGeneration.generateBranchName(body); - } - - case WS_METHODS.gitGenerateAndRenameBranch: { - const body = stripRequestTag(request.body); - const generated = yield* textGeneration.generateBranchName({ - cwd: body.cwd, - message: body.message, - ...(body.attachments !== undefined ? { attachments: body.attachments } : {}), - }); - if (!generated.branch) { - return { branch: null }; - } - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); - if (targetBranch === body.oldBranch) { - return { branch: body.oldBranch }; - } - return yield* git.renameBranch({ - cwd: body.cwd, - oldBranch: body.oldBranch, - newBranch: targetBranch, - }); - } - case WS_METHODS.gitRenameBranch: { const body = stripRequestTag(request.body); return yield* git.renameBranch(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 56c31c299d358bd63ad87836f09b97f7f504aba1..59b91a8ed134ea42ab186e98026d3909575054ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -247,43 +247,6 @@ function buildTemporaryWorktreeBranchName(message: string): string { return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`; } -function imageReferenceForBranchNaming(image: ComposerImageAttachment): string { - const maybePath = (image.file as File & { path?: unknown }).path; - if (typeof maybePath === "string" && maybePath.trim().length > 0) { - return maybePath.trim(); - } - return image.name; -} - -function buildBranchNameGenerationMessage( - text: string, - images: ReadonlyArray, -): string { - const trimmedText = text.trim(); - const imageReferences = Array.from( - new Set( - images - .map((image) => imageReferenceForBranchNaming(image).trim()) - .filter((reference) => reference.length > 0), - ), - ); - const attachmentsSection = - imageReferences.length > 0 - ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}` - : ""; - - if (trimmedText.length > 0 && attachmentsSection.length > 0) { - return `${trimmedText}\n\n${attachmentsSection}`; - } - if (trimmedText.length > 0) { - return trimmedText; - } - if (attachmentsSection.length > 0) { - return attachmentsSection; - } - return "New worktree change"; -} - const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; @@ -1530,7 +1493,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); - const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map( async ( @@ -1578,7 +1540,6 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); let attemptedTurnStart = false; - let pendingWorktreeBranchRename: Promise | null = null; try { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { @@ -1604,36 +1565,6 @@ export default function ChatView({ threadId }: ChatViewProps) { branch: result.worktree.branch, worktreePath: result.worktree.path, }); - pendingWorktreeBranchRename = (async () => { - const turnAttachments = await turnAttachmentsPromise; - const renamed = await api.git.generateAndRenameBranch({ - cwd: result.worktree.path, - oldBranch: result.worktree.branch, - message: branchNameMessage, - attachments: turnAttachments, - }); - if (!renamed.branch || renamed.branch === result.worktree.branch) { - return; - } - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: renamed.branch, - worktreePath: result.worktree.path, - }); - dispatch({ - type: "SET_THREAD_BRANCH", - threadId: threadIdForSend, - branch: renamed.branch, - worktreePath: result.worktree.path, - }); - })().catch((error) => { - console.warn("Failed to rename generated worktree branch", { - threadId: threadIdForSend, - error: error instanceof Error ? error.message : String(error), - }); - }); const setupScript = setupProjectScript(activeProject.scripts); if (setupScript) { await runProjectScript(setupScript, { @@ -1683,9 +1614,6 @@ export default function ChatView({ threadId }: ChatViewProps) { sandboxMode, createdAt: messageCreatedAt, }); - if (pendingWorktreeBranchRename) { - void pendingWorktreeBranchRename; - } } catch (err) { if ( !attemptedTurnStart && diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index d7b08779258324091ef682d69e25cd8cb0fd12d8..a967ff6b8a963b381280ae73fd0dba285b7ea6da 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -164,9 +164,6 @@ export function createWsNativeApi(): NativeApi { runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), - generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input), - generateAndRenameBranch: (input) => - transport.request(WS_METHODS.gitGenerateAndRenameBranch, input), renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input), removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index c68ba8e0bf1ea6cdc05d2d938261e57b6506c3a2..78bd34ac14db97f20cfe7ac0514ee19839ef4fd3 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,10 +1,6 @@ import type { GitCheckoutInput, GitCreateBranchInput, - GitGenerateAndRenameBranchInput, - GitGenerateAndRenameBranchResult, - GitGenerateBranchNameInput, - GitGenerateBranchNameResult, GitRenameBranchInput, GitRenameBranchResult, GitCreateWorktreeInput, @@ -80,10 +76,6 @@ export interface NativeApi { // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; - generateBranchName: (input: GitGenerateBranchNameInput) => Promise; - generateAndRenameBranch: ( - input: GitGenerateAndRenameBranchInput, - ) => Promise; renameBranch: (input: GitRenameBranchInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 42f3c3f0f9b13f83c1f978f549491122f1602e03..8f0ac0eaffcb3e1a2b4c07e4d83c09f3d35180e2 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -11,9 +11,7 @@ import { } from "./orchestration"; import { GitCheckoutInput, - GitGenerateAndRenameBranchInput, GitCreateBranchInput, - GitGenerateBranchNameInput, GitCreateWorktreeInput, GitInitInput, GitListBranchesInput, @@ -53,8 +51,6 @@ export const WS_METHODS = { gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", - gitGenerateBranchName: "git.generateBranchName", - gitGenerateAndRenameBranch: "git.generateAndRenameBranch", gitRenameBranch: "git.renameBranch", gitRemoveWorktree: "git.removeWorktree", gitCreateBranch: "git.createBranch", @@ -117,8 +113,6 @@ export const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), - tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput), - tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput), tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput), tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), -- 2.54.0