authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:16:24-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log5e55aa387f4d33209f55dc6ec00cb590ae7cbc2a
tree606e9d0c17741c18327b1210d61b569c232c1baa
parenta21095bc9ae48f9555f6344a97834528ec6891af

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

12 files changed, 405 insertions(+), 264 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+17
......@@ -23,6 +23,8 @@ import {
2323
2424import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
2525import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
26import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
27import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
2628import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
2729import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
2830import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
......@@ -227,8 +229,23 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
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) {
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>(
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>(
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>(
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>(
103131 previousExitCode,
104132 previousStderr,
105133 previousRequireImage,
134 previousStdinMustContain,
135 previousStdinMustNotContain,
106136 };
107137 }),
108138 () => effect,
......@@ -133,6 +163,18 @@ function withFakeCodexEnv<A, E, R>(
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) => {
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) => {
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) => {
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* () {
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* () {
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
......@@ -20,6 +20,8 @@ import {
2020 ProviderService,
2121 type ProviderServiceShape,
2222} from "../../provider/Services/ProviderService.ts";
23import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
24import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
2325import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2426import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2527import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
......@@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => {
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", () => {
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", () => {
155174 interruptTurn,
156175 respondToRequest,
157176 stopSession,
177 renameBranch,
178 generateBranchName,
158179 };
159180 }
160181
......@@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => {
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
......@@ -15,6 +15,8 @@ import {
1515import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1616
1717import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";
19import { TextGeneration } from "../../git/Services/TextGeneration.ts";
1820import { ProviderService } from "../../provider/Services/ProviderService.ts";
1921import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2022import {
......@@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000;
6668const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
6769const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
6870const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
71const WORKTREE_BRANCH_PREFIX = "t3code";
72const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
73
74function isTemporaryWorktreeBranch(branch: string): boolean {
75 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
76}
77
78function 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
70101const 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* () {
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* () {
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<
5656}
5757
5858export 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() {
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() {
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-110
......@@ -46,7 +46,6 @@ import { Open, type OpenShape } from "./open";
4646import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
4747import type { GitCoreShape } from "./git/Services/GitCore.ts";
4848import { GitCore } from "./git/Services/GitCore.ts";
49import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
5049import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5150import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5251
......@@ -374,7 +373,6 @@ describe("WebSocket Server", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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-54
......@@ -54,7 +54,6 @@ import { clamp } from "effect/Number";
5454import { Open } from "./open";
5555import { ServerConfig } from "./config";
5656import { GitCore } from "./git/Services/GitCore.ts";
57import { TextGeneration } from "./git/Services/TextGeneration.ts";
5857import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
5958
6059/**
......@@ -136,31 +135,6 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
136135 return Struct.omit(body, ["_tag"]);
137136}
138137
139const WORKTREE_BRANCH_PREFIX = "t3code";
140
141function 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
164138export type ServerCoreRuntimeServices =
165139 | OrchestrationEngineService
166140 | ProjectionSnapshotQuery
......@@ -172,7 +146,6 @@ export type ServerRuntimeServices =
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<
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<
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-72
......@@ -247,43 +247,6 @@ function buildTemporaryWorktreeBranchName(message: string): string {
247247 return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`;
248248}
249249
250function 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
258function 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
287250const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
288251 pathValue: string;
289252 kind: "file" | "directory";
......@@ -1530,7 +1493,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
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) {
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) {
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) {
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-3
......@@ -164,9 +164,6 @@ export function createWsNativeApi(): NativeApi {
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-8
......@@ -1,10 +1,6 @@
11import 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 {
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-6
......@@ -11,9 +11,7 @@ import {
1111} from "./orchestration";
1212import {
1313 GitCheckoutInput,
14 GitGenerateAndRenameBranchInput,
1514 GitCreateBranchInput,
16 GitGenerateBranchNameInput,
1715 GitCreateWorktreeInput,
1816 GitInitInput,
1917 GitListBranchesInput,
......@@ -53,8 +51,6 @@ export const WS_METHODS = {
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([
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),