History

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

Julius Marminge committed 8 months ago

Commit 5e55aa31 parent a21095b12 files · +405 −264Browse files

Unified · Split

12 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +17 −0
  2. apps/server/src/git/Layers/CodexTextGeneration.test.ts +66 −1
  3. apps/server/src/git/Layers/CodexTextGeneration.ts +10 −7
  4. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +189 −0
  5. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +117 −0
  6. apps/server/src/serverLayers.ts +6 −3
  7. apps/server/src/wsServer.test.ts +0 −110
  8. apps/server/src/wsServer.ts +0 −54
  9. apps/web/src/components/ChatView.tsx +0 −72
  10. apps/web/src/wsNativeApi.ts +0 −3
  11. packages/contracts/src/ipc.ts +0 −8
  12. packages/contracts/src/ws.ts +0 −6
apps/server/integration/OrchestrationEngineHarness.integration.ts+17−0
@@ -23,6 +23,8 @@ import { · 22 unmodified lines
2323
2424 import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
2525 import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
26+import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
27+import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
2628 import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
2729 import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
2830 import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
@@ -227,8 +229,23 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { · 198 unmodified lines
227229 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
228230 Layer.provideMerge(runtimeServicesLayer),
229231 );
232+ const gitCoreLayer = Layer.succeed(
233+ GitCore,
234+ {
235+ renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
236+ Effect.succeed({ branch: input.newBranch }),
237+ } as unknown as GitCoreShape,
238+ );
239+ const textGenerationLayer = Layer.succeed(
240+ TextGeneration,
241+ {
242+ generateBranchName: () => Effect.succeed({ branch: null }),
243+ } as unknown as TextGenerationShape,
244+ );
230245 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
231246 Layer.provideMerge(runtimeServicesLayer),
247+ Layer.provideMerge(gitCoreLayer),
248+ Layer.provideMerge(textGenerationLayer),
232249 );
233250 const checkpointReactorLayer = CheckpointReactorLive.pipe(
234251 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/git/Layers/CodexTextGeneration.test.ts+66−1
@@ -38,7 +38,19 @@ function makeFakeCodexBinary(dir: string) { · 37 unmodified lines
3838 ' printf "%s\\n" "missing --image input" >&2',
3939 " exit 2",
4040 "fi",
41− "cat >/dev/null",
41+ 'stdin_content="$(cat)"',
42+ 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then',
43+ ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {',
44+ ' printf "%s\\n" "stdin missing expected content" >&2',
45+ " exit 3",
46+ " }",
47+ "fi",
48+ 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then',
49+ ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then',
50+ ' printf "%s\\n" "stdin contained forbidden content" >&2',
51+ " exit 4",
52+ " fi",
53+ "fi",
4254 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
4355 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
4456 "fi",
@@ -60,6 +72,8 @@ function withFakeCodexEnv<A, E, R>( · 15 unmodified lines
6072 exitCode?: number;
6173 stderr?: string;
6274 requireImage?: boolean;
75+ stdinMustContain?: string;
76+ stdinMustNotContain?: string;
6377 },
6478 effect: Effect.Effect<A, E, R>,
6579 ) {
@@ -73,6 +87,8 @@ function withFakeCodexEnv<A, E, R>( · 7 unmodified lines
7387 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
7488 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
7589 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
90+ const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
91+ const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
7692
7793 yield* Effect.sync(() => {
7894 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
@@ -95,6 +111,18 @@ function withFakeCodexEnv<A, E, R>( · 16 unmodified lines
95111 } else {
96112 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
97113 }
114+
115+ if (input.stdinMustContain !== undefined) {
116+ process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain;
117+ } else {
118+ delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
119+ }
120+
121+ if (input.stdinMustNotContain !== undefined) {
122+ process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain;
123+ } else {
124+ delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
125+ }
98126 });
99127
100128 return {
@@ -103,6 +131,8 @@ function withFakeCodexEnv<A, E, R>( · 2 unmodified lines
103131 previousExitCode,
104132 previousStderr,
105133 previousRequireImage,
134+ previousStdinMustContain,
135+ previousStdinMustNotContain,
106136 };
107137 }),
108138 () => effect,
@@ -133,6 +163,18 @@ function withFakeCodexEnv<A, E, R>( · 24 unmodified lines
133163 } else {
134164 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
135165 }
166+
167+ if (previous.previousStdinMustContain === undefined) {
168+ delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN;
169+ } else {
170+ process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain;
171+ }
172+
173+ if (previous.previousStdinMustNotContain === undefined) {
174+ delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN;
175+ } else {
176+ process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain;
177+ }
136178 }),
137179 );
138180 }
@@ -202,6 +244,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 63 unmodified lines
202244 output: JSON.stringify({
203245 branch: " Feat/Session ",
204246 }),
247+ stdinMustNotContain: "Image attachments supplied to the model",
205248 },
206249 Effect.gen(function* () {
207250 const textGeneration = yield* TextGeneration;
@@ -216,6 +259,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 8 unmodified lines
216259 ),
217260 );
218261
262+ it.effect("omits attachment metadata section when no attachments are provided", () =>
263+ withFakeCodexEnv(
264+ {
265+ output: JSON.stringify({
266+ branch: "fix/session-timeout",
267+ }),
268+ stdinMustNotContain: "Attachment metadata:",
269+ },
270+ Effect.gen(function* () {
271+ const textGeneration = yield* TextGeneration;
272+
273+ const generated = yield* textGeneration.generateBranchName({
274+ cwd: process.cwd(),
275+ message: "Fix timeout behavior.",
276+ });
277+
278+ expect(generated.branch).toBe("fix/session-timeout");
279+ }),
280+ ),
281+ );
282+
219283 it.effect("passes image attachments through as codex image inputs", () =>
220284 withFakeCodexEnv(
221285 {
@@ -223,6 +287,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 1 unmodified line
223287 branch: "fix/ui-regression",
224288 }),
225289 requireImage: true,
290+ stdinMustContain: "Attachment metadata:",
226291 },
227292 Effect.gen(function* () {
228293 const textGeneration = yield* TextGeneration;
apps/server/src/git/Layers/CodexTextGeneration.ts+10−7
@@ -503,7 +503,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 502 unmodified lines
503503 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
504504 );
505505
506− const prompt = [
506+ const promptSections = [
507507 "You generate concise git branch names.",
508508 "Return a JSON object with key: branch.",
509509 "Rules:",
@@ -512,14 +512,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 2 unmodified lines
512512 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
513513 "- If images are attached, use them as primary context for visual/UI issues.",
514514 "",
515− `Image attachments supplied to the model: ${imagePaths.length}`,
516− "",
517515 "User message:",
518516 limitSection(input.message, 8_000),
519− "",
520− "Attachment metadata:",
521− attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",
522− ].join("\n");
517+ ];
518+ if (attachmentLines.length > 0) {
519+ promptSections.push(
520+ "",
521+ "Attachment metadata:",
522+ limitSection(attachmentLines.join("\n"), 4_000),
523+ );
524+ }
525+ const prompt = promptSections.join("\n");
523526
524527 const generated = yield* runCodexJson({
525528 operation: "generateBranchName",
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+189−0
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
2020 ProviderService,
2121 type ProviderServiceShape,
2222 } from "../../provider/Services/ProviderService.ts";
23+import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
24+import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
2325 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2426 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2527 import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
@@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { · 66 unmodified lines
9294 const interruptTurn = vi.fn((_: unknown) => Effect.void);
9395 const respondToRequest = vi.fn((_: unknown) => Effect.void);
9496 const stopSession = vi.fn((_: unknown) => Effect.void);
97+ const renameBranch = vi.fn((_: unknown) =>
98+ Effect.succeed({
99+ branch: "t3code/generated-name",
100+ }),
101+ );
102+ const generateBranchName = vi.fn((_: unknown) =>
103+ Effect.succeed({
104+ branch: "generated-name" as string | null,
105+ }),
106+ );
95107
96108 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97109 const service: ProviderServiceShape = {
@@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { · 17 unmodified lines
115127 const layer = ProviderCommandReactorLive.pipe(
116128 Layer.provideMerge(orchestrationLayer),
117129 Layer.provideMerge(Layer.succeed(ProviderService, service)),
130+ Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
131+ Layer.provideMerge(
132+ Layer.succeed(
133+ TextGeneration,
134+ { generateBranchName } as unknown as TextGenerationShape,
135+ ),
136+ ),
118137 );
119138 runtime = ManagedRuntime.make(layer);
120139 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
@@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { · 34 unmodified lines
155174 interruptTurn,
156175 respondToRequest,
157176 stopSession,
177+ renameBranch,
178+ generateBranchName,
158179 };
159180 }
160181
@@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
196217 expect(thread?.session?.sandboxMode).toBe("workspace-write");
197218 });
198219
220+ it("generates and renames temporary worktree branch on first turn", async () => {
221+ const harness = await createHarness();
222+ const now = new Date().toISOString();
223+
224+ await Effect.runPromise(
225+ harness.engine.dispatch({
226+ type: "thread.meta.update",
227+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"),
228+ threadId: ThreadId.makeUnsafe("thread-1"),
229+ branch: "t3code/89abc123",
230+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
231+ }),
232+ );
233+
234+ await Effect.runPromise(
235+ harness.engine.dispatch({
236+ type: "thread.turn.start",
237+ commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"),
238+ threadId: ThreadId.makeUnsafe("thread-1"),
239+ message: {
240+ messageId: asMessageId("user-message-worktree-rename"),
241+ role: "user",
242+ text: "Fix visual bug from screenshot",
243+ attachments: [
244+ {
245+ type: "image",
246+ name: "bug.png",
247+ mimeType: "image/png",
248+ sizeBytes: 5,
249+ dataUrl: "data:image/png;base64,SGVsbG8=",
250+ },
251+ ],
252+ },
253+ approvalPolicy: "on-request",
254+ sandboxMode: "workspace-write",
255+ createdAt: now,
256+ }),
257+ );
258+
259+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
260+ await waitFor(() => harness.renameBranch.mock.calls.length === 1);
261+
262+ expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
263+ cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
264+ message: "Fix visual bug from screenshot",
265+ attachments: [
266+ {
267+ type: "image",
268+ name: "bug.png",
269+ mimeType: "image/png",
270+ sizeBytes: 5,
271+ dataUrl: "data:image/png;base64,SGVsbG8=",
272+ },
273+ ],
274+ });
275+ expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({
276+ cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123",
277+ oldBranch: "t3code/89abc123",
278+ newBranch: "t3code/generated-name",
279+ });
280+
281+ await waitFor(() => {
282+ const readModel = Effect.runSync(harness.engine.getReadModel());
283+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
284+ return thread?.branch === "t3code/generated-name";
285+ });
286+ });
287+
288+ it("skips worktree branch generation after the first user turn", async () => {
289+ const harness = await createHarness();
290+ const now = new Date().toISOString();
291+
292+ await Effect.runPromise(
293+ harness.engine.dispatch({
294+ type: "thread.meta.update",
295+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"),
296+ threadId: ThreadId.makeUnsafe("thread-1"),
297+ branch: "t3code/1234abcd",
298+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd",
299+ }),
300+ );
301+
302+ await Effect.runPromise(
303+ harness.engine.dispatch({
304+ type: "thread.turn.start",
305+ commandId: CommandId.makeUnsafe("cmd-turn-start-first"),
306+ threadId: ThreadId.makeUnsafe("thread-1"),
307+ message: {
308+ messageId: asMessageId("user-message-first"),
309+ role: "user",
310+ text: "first",
311+ attachments: [],
312+ },
313+ approvalPolicy: "on-request",
314+ sandboxMode: "workspace-write",
315+ createdAt: now,
316+ }),
317+ );
318+
319+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
320+
321+ await Effect.runPromise(
322+ harness.engine.dispatch({
323+ type: "thread.turn.start",
324+ commandId: CommandId.makeUnsafe("cmd-turn-start-second"),
325+ threadId: ThreadId.makeUnsafe("thread-1"),
326+ message: {
327+ messageId: asMessageId("user-message-second"),
328+ role: "user",
329+ text: "second",
330+ attachments: [],
331+ },
332+ approvalPolicy: "on-request",
333+ sandboxMode: "workspace-write",
334+ createdAt: now,
335+ }),
336+ );
337+
338+ await waitFor(() => harness.sendTurn.mock.calls.length === 2);
339+ expect(harness.generateBranchName.mock.calls.length).toBe(1);
340+ expect(harness.renameBranch.mock.calls.length).toBe(1);
341+ });
342+
343+ it("skips worktree rename when generated branch is null", async () => {
344+ const harness = await createHarness();
345+ const now = new Date().toISOString();
346+ harness.generateBranchName.mockImplementationOnce((_: unknown) =>
347+ Effect.succeed({
348+ branch: null,
349+ }),
350+ );
351+
352+ await Effect.runPromise(
353+ harness.engine.dispatch({
354+ type: "thread.meta.update",
355+ commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"),
356+ threadId: ThreadId.makeUnsafe("thread-1"),
357+ branch: "t3code/0000abcd",
358+ worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd",
359+ }),
360+ );
361+
362+ await Effect.runPromise(
363+ harness.engine.dispatch({
364+ type: "thread.turn.start",
365+ commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"),
366+ threadId: ThreadId.makeUnsafe("thread-1"),
367+ message: {
368+ messageId: asMessageId("user-message-null-branch"),
369+ role: "user",
370+ text: "Fix visual regression",
371+ attachments: [],
372+ },
373+ approvalPolicy: "on-request",
374+ sandboxMode: "workspace-write",
375+ createdAt: now,
376+ }),
377+ );
378+
379+ await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
380+ await Effect.runPromise(Effect.sleep("20 millis"));
381+ expect(harness.renameBranch.mock.calls.length).toBe(0);
382+
383+ const readModel = await Effect.runPromise(harness.engine.getReadModel());
384+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
385+ expect(thread?.branch).toBe("t3code/0000abcd");
386+ });
387+
199388 it("reuses the same provider session when runtime mode is unchanged", async () => {
200389 const harness = await createHarness();
201390 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+117−0
@@ -15,6 +15,8 @@ import { · 14 unmodified lines
1515 import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1616
1717 import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18+import { GitCore } from "../../git/Services/GitCore.ts";
19+import { TextGeneration } from "../../git/Services/TextGeneration.ts";
1820 import { ProviderService } from "../../provider/Services/ProviderService.ts";
1921 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2022 import {
@@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines
6668 const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
6769 const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
6870 const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
71+const WORKTREE_BRANCH_PREFIX = "t3code";
72+const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
73+
74+function isTemporaryWorktreeBranch(branch: string): boolean {
75+ return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
76+}
77+
78+function buildGeneratedWorktreeBranchName(raw: string): string {
79+ const normalized = raw
80+ .trim()
81+ .toLowerCase()
82+ .replace(/^refs\/heads\//, "")
83+ .replace(/['"`]/g, "");
84+
85+ const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
86+ ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
87+ : normalized;
88+
89+ const branchFragment = withoutPrefix
90+ .replace(/[^a-z0-9/_-]+/g, "-")
91+ .replace(/\/+/g, "/")
92+ .replace(/-+/g, "-")
93+ .replace(/^[./_-]+|[./_-]+$/g, "")
94+ .slice(0, 64)
95+ .replace(/[./_-]+$/g, "");
96+
97+ const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
98+ return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
99+}
69100
70101 const make = Effect.gen(function* () {
71102 const orchestrationEngine = yield* OrchestrationEngineService;
72103 const providerService = yield* ProviderService;
104+ const git = yield* GitCore;
105+ const textGeneration = yield* TextGeneration;
73106 const handledTurnStartKeys = yield* Cache.make<string, true>({
74107 capacity: HANDLED_TURN_START_KEY_MAX,
75108 timeToLive: HANDLED_TURN_START_KEY_TTL,
@@ -247,6 +280,81 @@ const make = Effect.gen(function* () { · 171 unmodified lines
247280 });
248281 });
249282
283+ const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {
284+ readonly threadId: ThreadId;
285+ readonly branch: string | null;
286+ readonly worktreePath: string | null;
287+ readonly messageId: string;
288+ readonly messageText: string;
289+ readonly attachments?: ReadonlyArray<ChatAttachment>;
290+ }) {
291+ if (!input.branch || !input.worktreePath) {
292+ return;
293+ }
294+ if (!isTemporaryWorktreeBranch(input.branch)) {
295+ return;
296+ }
297+
298+ const thread = yield* resolveThread(input.threadId);
299+ if (!thread) {
300+ return;
301+ }
302+
303+ const userMessages = thread.messages.filter((message) => message.role === "user");
304+ if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) {
305+ return;
306+ }
307+
308+ const oldBranch = input.branch;
309+ const cwd = input.worktreePath;
310+ const attachments = input.attachments ?? [];
311+ const renameEffect = textGeneration
312+ .generateBranchName({
313+ cwd,
314+ message: input.messageText,
315+ ...(attachments.length > 0 ? { attachments } : {}),
316+ })
317+ .pipe(
318+ Effect.flatMap((generated) => {
319+ if (!generated.branch) {
320+ return Effect.void;
321+ }
322+ const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
323+ if (targetBranch === oldBranch) {
324+ return Effect.void;
325+ }
326+ return git
327+ .renameBranch({
328+ cwd,
329+ oldBranch,
330+ newBranch: targetBranch,
331+ })
332+ .pipe(
333+ Effect.flatMap((renamed) =>
334+ orchestrationEngine.dispatch({
335+ type: "thread.meta.update",
336+ commandId: serverCommandId("worktree-branch-rename"),
337+ threadId: input.threadId,
338+ branch: renamed.branch,
339+ worktreePath: cwd,
340+ }),
341+ ),
342+ Effect.asVoid,
343+ );
344+ }),
345+ Effect.catchCause((cause) =>
346+ Effect.logWarning("provider command reactor failed to generate or rename worktree branch", {
347+ threadId: input.threadId,
348+ cwd,
349+ oldBranch,
350+ cause: Cause.pretty(cause),
351+ }),
352+ ),
353+ );
354+
355+ yield* Effect.forkScoped(renameEffect);
356+ });
357+
250358 const processTurnStartRequested = Effect.fnUntraced(function* (
251359 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
252360 ) {
@@ -273,6 +381,15 @@ const make = Effect.gen(function* () { · 20 unmodified lines
273381 return;
274382 }
275383
384+ yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
385+ threadId: event.payload.threadId,
386+ branch: thread.branch,
387+ worktreePath: thread.worktreePath,
388+ messageId: message.id,
389+ messageText: message.text,
390+ ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
391+ });
392+
276393 yield* sendTurnForThread({
277394 threadId: event.payload.threadId,
278395 messageText: message.text,
apps/server/src/serverLayers.ts+6−3
@@ -56,6 +56,9 @@ export function makeServerProviderLayer(): Layer.Layer< · 55 unmodified lines
5656 }
5757
5858 export function makeServerRuntimeServicesLayer() {
59+ const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
60+ const textGenerationLayer = CodexTextGenerationLive;
61+
5962 const orchestrationLayer = OrchestrationEngineLive.pipe(
6063 Layer.provide(OrchestrationProjectionPipelineLive),
6164 Layer.provide(OrchestrationEventStoreLive),
@@ -78,6 +81,8 @@ export function makeServerRuntimeServicesLayer() { · 16 unmodified lines
7881 );
7982 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
8083 Layer.provideMerge(runtimeServicesLayer),
84+ Layer.provideMerge(gitCoreLayer),
85+ Layer.provideMerge(textGenerationLayer),
8186 );
8287 const checkpointReactorLayer = CheckpointReactorLive.pipe(
8388 Layer.provideMerge(runtimeServicesLayer),
@@ -92,12 +97,10 @@ export function makeServerRuntimeServicesLayer() { · 8 unmodified lines
9297 Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive),
9398 );
9499
95− const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
96−
97100 const gitManagerLayer = GitManagerLive.pipe(
98101 Layer.provideMerge(gitCoreLayer),
99102 Layer.provideMerge(GitHubCliLive),
100− Layer.provideMerge(CodexTextGenerationLive),
103+ Layer.provideMerge(textGenerationLayer),
101104 );
102105
103106 return Layer.mergeAll(
apps/server/src/wsServer.test.ts+0−110
@@ -46,7 +46,6 @@ import { Open, type OpenShape } from "./open"; · 45 unmodified lines
4646 import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4747 import type { GitCoreShape } from "./git/Services/GitCore.ts";
4848 import { GitCore } from "./git/Services/GitCore.ts";
49−import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
5049 import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5150 import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5251
@@ -374,7 +373,6 @@ describe("WebSocket Server", () => { · 321 unmodified lines
374373 GitCoreShape,
375374 "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"
376375 >;
377− textGeneration?: Pick<TextGenerationShape, "generateBranchName">;
378376 terminalManager?: TerminalManagerShape;
379377 } = {},
380378 ): Promise<Http.Server> {
@@ -407,9 +405,6 @@ describe("WebSocket Server", () => { · 26 unmodified lines
407405 options.gitCore
408406 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
409407 : Layer.empty,
410− options.textGeneration
411− ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape)
412− : Layer.empty,
413408 options.terminalManager
414409 ? Layer.succeed(TerminalManager, options.terminalManager)
415410 : Layer.empty,
@@ -1477,7 +1472,6 @@ describe("WebSocket Server", () => { · 1061 unmodified lines
14771472 );
14781473 const initRepo = vi.fn(() => Effect.void);
14791474 const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1480− const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const }));
14811475 const pullCurrentBranch = vi.fn(() =>
14821476 Effect.fail(
14831477 new GitCommandError({
@@ -1497,9 +1491,6 @@ describe("WebSocket Server", () => { · 13 unmodified lines
14971491 renameBranch,
14981492 pullCurrentBranch,
14991493 },
1500− textGeneration: {
1501− generateBranchName,
1502− },
15031494 });
15041495 const addr = server.address();
15051496 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
@@ -1522,61 +1513,6 @@ describe("WebSocket Server", () => { · 16 unmodified lines
15221513 expect(pullResponse.error?.message).toContain("No upstream configured");
15231514 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
15241515
1525− const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, {
1526− cwd: "/repo/path",
1527− message: "Add retry logic for websocket reconnects",
1528− attachments: [
1529− {
1530− type: "image",
1531− name: "bug.png",
1532− mimeType: "image/png",
1533− sizeBytes: 5,
1534− dataUrl: "data:image/png;base64,SGVsbG8=",
1535− },
1536− ],
1537− });
1538− expect(generatedBranchResponse.error).toBeUndefined();
1539− expect(generatedBranchResponse.result).toEqual({ branch: "new-name" });
1540− expect(generateBranchName).toHaveBeenCalledWith({
1541− cwd: "/repo/path",
1542− message: "Add retry logic for websocket reconnects",
1543− attachments: [
1544− {
1545− type: "image",
1546− name: "bug.png",
1547− mimeType: "image/png",
1548− sizeBytes: 5,
1549− dataUrl: "data:image/png;base64,SGVsbG8=",
1550− },
1551− ],
1552− });
1553−
1554− const generateAndRenameResponse = await sendRequest(
1555− ws,
1556− WS_METHODS.gitGenerateAndRenameBranch,
1557− {
1558− cwd: "/repo/path",
1559− oldBranch: "t3code/tmp-123",
1560− message: "Add retry logic for websocket reconnects",
1561− attachments: [
1562− {
1563− type: "image",
1564− name: "bug.png",
1565− mimeType: "image/png",
1566− sizeBytes: 5,
1567− dataUrl: "data:image/png;base64,SGVsbG8=",
1568− },
1569− ],
1570− },
1571− );
1572− expect(generateAndRenameResponse.error).toBeUndefined();
1573− expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" });
1574− expect(renameBranch).toHaveBeenCalledWith({
1575− cwd: "/repo/path",
1576− oldBranch: "t3code/tmp-123",
1577− newBranch: "t3code/new-name",
1578− });
1579−
15801516 const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {
15811517 cwd: "/repo/path",
15821518 oldBranch: "t3code/tmp-456",
@@ -1591,52 +1527,6 @@ describe("WebSocket Server", () => { · 8 unmodified lines
15911527 });
15921528 });
15931529
1594− it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => {
1595− const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1596− const generateBranchName = vi.fn(() => Effect.succeed({ branch: null }));
1597−
1598− server = await createTestServer({
1599− cwd: "/test",
1600− gitCore: {
1601− listBranches: () =>
1602− Effect.succeed({
1603− branches: [],
1604− isRepo: false,
1605− }),
1606− initRepo: () => Effect.void,
1607− renameBranch,
1608− pullCurrentBranch: () =>
1609− Effect.fail(
1610− new GitCommandError({
1611− operation: "GitCore.test.pullCurrentBranch",
1612− detail: "No upstream configured",
1613− command: "git pull",
1614− cwd: "/repo/path",
1615− }),
1616− ),
1617− },
1618− textGeneration: {
1619− generateBranchName,
1620− },
1621− });
1622− const addr = server.address();
1623− const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1624− const ws = await connectWs(port);
1625− connections.push(ws);
1626− await waitForMessage(ws);
1627−
1628− const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, {
1629− cwd: "/repo/path",
1630− oldBranch: "t3code/tmp-123",
1631− message: "Fix visual regression",
1632− attachments: [],
1633− });
1634−
1635− expect(response.error).toBeUndefined();
1636− expect(response.result).toEqual({ branch: null });
1637− expect(renameBranch).not.toHaveBeenCalled();
1638− });
1639−
16401530 it("supports git.status over websocket", async () => {
16411531 const statusResult = {
16421532 branch: "feature/test",
apps/server/src/wsServer.ts+0−54
@@ -54,7 +54,6 @@ import { clamp } from "effect/Number"; · 53 unmodified lines
5454 import { Open } from "./open";
5555 import { ServerConfig } from "./config";
5656 import { GitCore } from "./git/Services/GitCore.ts";
57−import { TextGeneration } from "./git/Services/TextGeneration.ts";
5857 import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
5958
6059 /**
@@ -136,31 +135,6 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { · 75 unmodified lines
136135 return Struct.omit(body, ["_tag"]);
137136 }
138137
139−const WORKTREE_BRANCH_PREFIX = "t3code";
140−
141−function buildGeneratedWorktreeBranchName(raw: string): string {
142− const normalized = raw
143− .trim()
144− .toLowerCase()
145− .replace(/^refs\/heads\//, "")
146− .replace(/['"`]/g, "");
147−
148− const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
149− ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
150− : normalized;
151−
152− const branchFragment = withoutPrefix
153− .replace(/[^a-z0-9/_-]+/g, "-")
154− .replace(/\/+/g, "/")
155− .replace(/-+/g, "-")
156− .replace(/^[./_-]+|[./_-]+$/g, "")
157− .slice(0, 64)
158− .replace(/[./_-]+$/g, "");
159−
160− const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
161− return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
162−}
163−
164138 export type ServerCoreRuntimeServices =
165139 | OrchestrationEngineService
166140 | ProjectionSnapshotQuery
@@ -172,7 +146,6 @@ export type ServerRuntimeServices = · 5 unmodified lines
172146 | ServerCoreRuntimeServices
173147 | GitManager
174148 | GitCore
175− | TextGeneration
176149 | TerminalManager
177150 | Keybindings
178151 | Open;
@@ -211,7 +184,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 32 unmodified lines
211184 const terminalManager = yield* TerminalManager;
212185 const keybindingsManager = yield* Keybindings;
213186 const git = yield* GitCore;
214− const textGeneration = yield* TextGeneration;
215187 const fileSystem = yield* FileSystem.FileSystem;
216188 const path = yield* Path.Path;
217189
@@ -600,32 +572,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 382 unmodified lines
600572 return yield* git.createWorktree(body);
601573 }
602574
603− case WS_METHODS.gitGenerateBranchName: {
604− const body = stripRequestTag(request.body);
605− return yield* textGeneration.generateBranchName(body);
606− }
607−
608− case WS_METHODS.gitGenerateAndRenameBranch: {
609− const body = stripRequestTag(request.body);
610− const generated = yield* textGeneration.generateBranchName({
611− cwd: body.cwd,
612− message: body.message,
613− ...(body.attachments !== undefined ? { attachments: body.attachments } : {}),
614− });
615− if (!generated.branch) {
616− return { branch: null };
617− }
618− const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
619− if (targetBranch === body.oldBranch) {
620− return { branch: body.oldBranch };
621− }
622− return yield* git.renameBranch({
623− cwd: body.cwd,
624− oldBranch: body.oldBranch,
625− newBranch: targetBranch,
626− });
627− }
628−
629575 case WS_METHODS.gitRenameBranch: {
630576 const body = stripRequestTag(request.body);
631577 return yield* git.renameBranch(body);
apps/web/src/components/ChatView.tsx+0−72
@@ -247,43 +247,6 @@ function buildTemporaryWorktreeBranchName(message: string): string { · 246 unmodified lines
247247 return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`;
248248 }
249249
250−function imageReferenceForBranchNaming(image: ComposerImageAttachment): string {
251− const maybePath = (image.file as File & { path?: unknown }).path;
252− if (typeof maybePath === "string" && maybePath.trim().length > 0) {
253− return maybePath.trim();
254− }
255− return image.name;
256−}
257−
258−function buildBranchNameGenerationMessage(
259− text: string,
260− images: ReadonlyArray<ComposerImageAttachment>,
261−): string {
262− const trimmedText = text.trim();
263− const imageReferences = Array.from(
264− new Set(
265− images
266− .map((image) => imageReferenceForBranchNaming(image).trim())
267− .filter((reference) => reference.length > 0),
268− ),
269− );
270− const attachmentsSection =
271− imageReferences.length > 0
272− ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}`
273− : "";
274−
275− if (trimmedText.length > 0 && attachmentsSection.length > 0) {
276− return `${trimmedText}\n\n${attachmentsSection}`;
277− }
278− if (trimmedText.length > 0) {
279− return trimmedText;
280− }
281− if (attachmentsSection.length > 0) {
282− return attachmentsSection;
283− }
284− return "New worktree change";
285−}
286−
287250 const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
288251 pathValue: string;
289252 kind: "file" | "directory";
@@ -1530,7 +1493,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1240 unmodified lines
15301493 const composerImagesSnapshot = [...composerImages];
15311494 const messageIdForSend = newMessageId();
15321495 const messageCreatedAt = new Date().toISOString();
1533− const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot);
15341496 const turnAttachmentsPromise = Promise.all(
15351497 composerImagesSnapshot.map(
15361498 async (
@@ -1578,7 +1540,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 41 unmodified lines
15781540 setComposerHighlightedItemId(null);
15791541
15801542 let attemptedTurnStart = false;
1581− let pendingWorktreeBranchRename: Promise<void> | null = null;
15821543 try {
15831544 // On first message: lock in branch + create worktree if needed.
15841545 if (baseBranchForWorktree) {
@@ -1604,36 +1565,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 19 unmodified lines
16041565 branch: result.worktree.branch,
16051566 worktreePath: result.worktree.path,
16061567 });
1607− pendingWorktreeBranchRename = (async () => {
1608− const turnAttachments = await turnAttachmentsPromise;
1609− const renamed = await api.git.generateAndRenameBranch({
1610− cwd: result.worktree.path,
1611− oldBranch: result.worktree.branch,
1612− message: branchNameMessage,
1613− attachments: turnAttachments,
1614− });
1615− if (!renamed.branch || renamed.branch === result.worktree.branch) {
1616− return;
1617− }
1618− await api.orchestration.dispatchCommand({
1619− type: "thread.meta.update",
1620− commandId: newCommandId(),
1621− threadId: threadIdForSend,
1622− branch: renamed.branch,
1623− worktreePath: result.worktree.path,
1624− });
1625− dispatch({
1626− type: "SET_THREAD_BRANCH",
1627− threadId: threadIdForSend,
1628− branch: renamed.branch,
1629− worktreePath: result.worktree.path,
1630− });
1631− })().catch((error) => {
1632− console.warn("Failed to rename generated worktree branch", {
1633− threadId: threadIdForSend,
1634− error: error instanceof Error ? error.message : String(error),
1635− });
1636− });
16371568 const setupScript = setupProjectScript(activeProject.scripts);
16381569 if (setupScript) {
16391570 await runProjectScript(setupScript, {
@@ -1683,9 +1614,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 43 unmodified lines
16831614 sandboxMode,
16841615 createdAt: messageCreatedAt,
16851616 });
1686− if (pendingWorktreeBranchRename) {
1687− void pendingWorktreeBranchRename;
1688− }
16891617 } catch (err) {
16901618 if (
16911619 !attemptedTurnStart &&
apps/web/src/wsNativeApi.ts+0−3
@@ -164,9 +164,6 @@ export function createWsNativeApi(): NativeApi { · 163 unmodified lines
164164 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
165165 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
166166 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
167− generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input),
168− generateAndRenameBranch: (input) =>
169− transport.request(WS_METHODS.gitGenerateAndRenameBranch, input),
170167 renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input),
171168 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
172169 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
packages/contracts/src/ipc.ts+0−8
@@ -1,10 +1,6 @@
11 import type {
22 GitCheckoutInput,
33 GitCreateBranchInput,
4− GitGenerateAndRenameBranchInput,
5− GitGenerateAndRenameBranchResult,
6− GitGenerateBranchNameInput,
7− GitGenerateBranchNameResult,
84 GitRenameBranchInput,
95 GitRenameBranchResult,
106 GitCreateWorktreeInput,
@@ -80,10 +76,6 @@ export interface NativeApi { · 69 unmodified lines
8076 // Existing branch/worktree API
8177 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
8278 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
83− generateBranchName: (input: GitGenerateBranchNameInput) => Promise<GitGenerateBranchNameResult>;
84− generateAndRenameBranch: (
85− input: GitGenerateAndRenameBranchInput,
86− ) => Promise<GitGenerateAndRenameBranchResult>;
8779 renameBranch: (input: GitRenameBranchInput) => Promise<GitRenameBranchResult>;
8880 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
8981 createBranch: (input: GitCreateBranchInput) => Promise<void>;
packages/contracts/src/ws.ts+0−6
@@ -11,9 +11,7 @@ import { · 10 unmodified lines
1111 } from "./orchestration";
1212 import {
1313 GitCheckoutInput,
14− GitGenerateAndRenameBranchInput,
1514 GitCreateBranchInput,
16− GitGenerateBranchNameInput,
1715 GitCreateWorktreeInput,
1816 GitInitInput,
1917 GitListBranchesInput,
@@ -53,8 +51,6 @@ export const WS_METHODS = { · 33 unmodified lines
5351 gitRunStackedAction: "git.runStackedAction",
5452 gitListBranches: "git.listBranches",
5553 gitCreateWorktree: "git.createWorktree",
56− gitGenerateBranchName: "git.generateBranchName",
57− gitGenerateAndRenameBranch: "git.generateAndRenameBranch",
5854 gitRenameBranch: "git.renameBranch",
5955 gitRemoveWorktree: "git.removeWorktree",
6056 gitCreateBranch: "git.createBranch",
@@ -117,8 +113,6 @@ export const WebSocketRequestBody = Schema.Union([ · 56 unmodified lines
117113 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
118114 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
119115 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
120− tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput),
121− tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput),
122116 tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput),
123117 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
124118 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),