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

Showing the changes to apps/server/src (7 of 12 files). Show all

Unified · Split

7 files
  1. apps/server/src/git/Layers/CodexTextGeneration.test.ts +66 −1
  2. apps/server/src/git/Layers/CodexTextGeneration.ts +10 −7
  3. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +189 −0
  4. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +117 −0
  5. apps/server/src/serverLayers.ts +6 −3
  6. apps/server/src/wsServer.test.ts +0 −110
  7. apps/server/src/wsServer.ts +0 −54
apps/server/src/git/Layers/CodexTextGeneration.test.ts+66−1
@@ -38,7 +38,19 @@ function makeFakeCodexBinary(dir: string) { · 37 unmodified lines
38 ' printf "%s\\n" "missing --image input" >&2',38 ' printf "%s\\n" "missing --image input" >&2',
39 " exit 2",39 " exit 2",
40 "fi",40 "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",
42 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',54 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
43 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',55 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
44 "fi",56 "fi",
@@ -60,6 +72,8 @@ function withFakeCodexEnv<A, E, R>( · 15 unmodified lines
60 exitCode?: number;72 exitCode?: number;
61 stderr?: string;73 stderr?: string;
62 requireImage?: boolean;74 requireImage?: boolean;
75 stdinMustContain?: string;
76 stdinMustNotContain?: string;
63 },77 },
64 effect: Effect.Effect<A, E, R>,78 effect: Effect.Effect<A, E, R>,
65) {79) {
@@ -73,6 +87,8 @@ function withFakeCodexEnv<A, E, R>( · 7 unmodified lines
73 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;87 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
74 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;88 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
75 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;89 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;
76 92
77 yield* Effect.sync(() => {93 yield* Effect.sync(() => {
78 process.env.PATH = `${binDir}:${previousPath ?? ""}`;94 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
@@ -95,6 +111,18 @@ function withFakeCodexEnv<A, E, R>( · 16 unmodified lines
95 } else {111 } else {
96 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;112 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
97 }113 }
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 }
98 });126 });
99 127
100 return {128 return {
@@ -103,6 +131,8 @@ function withFakeCodexEnv<A, E, R>( · 2 unmodified lines
103 previousExitCode,131 previousExitCode,
104 previousStderr,132 previousStderr,
105 previousRequireImage,133 previousRequireImage,
134 previousStdinMustContain,
135 previousStdinMustNotContain,
106 };136 };
107 }),137 }),
108 () => effect,138 () => effect,
@@ -133,6 +163,18 @@ function withFakeCodexEnv<A, E, R>( · 24 unmodified lines
133 } else {163 } else {
134 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;164 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
135 }165 }
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 }
136 }),178 }),
137 );179 );
138}180}
@@ -202,6 +244,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 63 unmodified lines
202 output: JSON.stringify({244 output: JSON.stringify({
203 branch: " Feat/Session ",245 branch: " Feat/Session ",
204 }),246 }),
247 stdinMustNotContain: "Image attachments supplied to the model",
205 },248 },
206 Effect.gen(function* () {249 Effect.gen(function* () {
207 const textGeneration = yield* TextGeneration;250 const textGeneration = yield* TextGeneration;
@@ -216,6 +259,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 8 unmodified lines
216 ),259 ),
217 );260 );
218 261
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
219 it.effect("passes image attachments through as codex image inputs", () =>283 it.effect("passes image attachments through as codex image inputs", () =>
220 withFakeCodexEnv(284 withFakeCodexEnv(
221 {285 {
@@ -223,6 +287,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { · 1 unmodified line
223 branch: "fix/ui-regression",287 branch: "fix/ui-regression",
224 }),288 }),
225 requireImage: true,289 requireImage: true,
290 stdinMustContain: "Attachment metadata:",
226 },291 },
227 Effect.gen(function* () {292 Effect.gen(function* () {
228 const textGeneration = yield* TextGeneration;293 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
503 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,503 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
504 );504 );
505 505
506 const prompt = [506 const promptSections = [
507 "You generate concise git branch names.",507 "You generate concise git branch names.",
508 "Return a JSON object with key: branch.",508 "Return a JSON object with key: branch.",
509 "Rules:",509 "Rules:",
@@ -512,14 +512,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 2 unmodified lines
512 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",512 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
513 "- If images are attached, use them as primary context for visual/UI issues.",513 "- If images are attached, use them as primary context for visual/UI issues.",
514 "",514 "",
515 `Image attachments supplied to the model: ${imagePaths.length}`,
516 "",
517 "User message:",515 "User message:",
518 limitSection(input.message, 8_000),516 limitSection(input.message, 8_000),
519 "",517 ];
520 "Attachment metadata:",518 if (attachmentLines.length > 0) {
521 attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",519 promptSections.push(
522 ].join("\n");520 "",
521 "Attachment metadata:",
522 limitSection(attachmentLines.join("\n"), 4_000),
523 );
524 }
525 const prompt = promptSections.join("\n");
523 526
524 const generated = yield* runCodexJson({527 const generated = yield* runCodexJson({
525 operation: "generateBranchName",528 operation: "generateBranchName",
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+189−0
@@ -20,6 +20,8 @@ import { · 19 unmodified lines
20 ProviderService,20 ProviderService,
21 type ProviderServiceShape,21 type ProviderServiceShape,
22} from "../../provider/Services/ProviderService.ts";22} from "../../provider/Services/ProviderService.ts";
23import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
24import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";25import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";26import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";27import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
@@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { · 66 unmodified lines
92 const interruptTurn = vi.fn((_: unknown) => Effect.void);94 const interruptTurn = vi.fn((_: unknown) => Effect.void);
93 const respondToRequest = vi.fn((_: unknown) => Effect.void);95 const respondToRequest = vi.fn((_: unknown) => Effect.void);
94 const stopSession = vi.fn((_: unknown) => Effect.void);96 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 );
95 107
96 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;108 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97 const service: ProviderServiceShape = {109 const service: ProviderServiceShape = {
@@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { · 17 unmodified lines
115 const layer = ProviderCommandReactorLive.pipe(127 const layer = ProviderCommandReactorLive.pipe(
116 Layer.provideMerge(orchestrationLayer),128 Layer.provideMerge(orchestrationLayer),
117 Layer.provideMerge(Layer.succeed(ProviderService, service)),129 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 ),
118 );137 );
119 runtime = ManagedRuntime.make(layer);138 runtime = ManagedRuntime.make(layer);
120 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));139 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
@@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { · 34 unmodified lines
155 interruptTurn,174 interruptTurn,
156 respondToRequest,175 respondToRequest,
157 stopSession,176 stopSession,
177 renameBranch,
178 generateBranchName,
158 };179 };
159 }180 }
160 181
@@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
196 expect(thread?.session?.sandboxMode).toBe("workspace-write");217 expect(thread?.session?.sandboxMode).toBe("workspace-write");
197 });218 });
198 219
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
199 it("reuses the same provider session when runtime mode is unchanged", async () => {388 it("reuses the same provider session when runtime mode is unchanged", async () => {
200 const harness = await createHarness();389 const harness = await createHarness();
201 const now = new Date().toISOString();390 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+117−0
@@ -15,6 +15,8 @@ import { · 14 unmodified lines
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
16 16
17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";
19import { TextGeneration } from "../../git/Services/TextGeneration.ts";
18import { ProviderService } from "../../provider/Services/ProviderService.ts";20import { ProviderService } from "../../provider/Services/ProviderService.ts";
19import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";21import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
20import {22import {
@@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines
66const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);68const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
67const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";69const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never";
68const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";70const 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}
69 100
70const make = Effect.gen(function* () {101const make = Effect.gen(function* () {
71 const orchestrationEngine = yield* OrchestrationEngineService;102 const orchestrationEngine = yield* OrchestrationEngineService;
72 const providerService = yield* ProviderService;103 const providerService = yield* ProviderService;
104 const git = yield* GitCore;
105 const textGeneration = yield* TextGeneration;
73 const handledTurnStartKeys = yield* Cache.make<string, true>({106 const handledTurnStartKeys = yield* Cache.make<string, true>({
74 capacity: HANDLED_TURN_START_KEY_MAX,107 capacity: HANDLED_TURN_START_KEY_MAX,
75 timeToLive: HANDLED_TURN_START_KEY_TTL,108 timeToLive: HANDLED_TURN_START_KEY_TTL,
@@ -247,6 +280,81 @@ const make = Effect.gen(function* () { · 171 unmodified lines
247 });280 });
248 });281 });
249 282
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
250 const processTurnStartRequested = Effect.fnUntraced(function* (358 const processTurnStartRequested = Effect.fnUntraced(function* (
251 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,359 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
252 ) {360 ) {
@@ -273,6 +381,15 @@ const make = Effect.gen(function* () { · 20 unmodified lines
273 return;381 return;
274 }382 }
275 383
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
276 yield* sendTurnForThread({393 yield* sendTurnForThread({
277 threadId: event.payload.threadId,394 threadId: event.payload.threadId,
278 messageText: message.text,395 messageText: message.text,
apps/server/src/serverLayers.ts+6−3
@@ -56,6 +56,9 @@ export function makeServerProviderLayer(): Layer.Layer< · 55 unmodified lines
56}56}
57 57
58export function makeServerRuntimeServicesLayer() {58export function makeServerRuntimeServicesLayer() {
59 const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
60 const textGenerationLayer = CodexTextGenerationLive;
61
59 const orchestrationLayer = OrchestrationEngineLive.pipe(62 const orchestrationLayer = OrchestrationEngineLive.pipe(
60 Layer.provide(OrchestrationProjectionPipelineLive),63 Layer.provide(OrchestrationProjectionPipelineLive),
61 Layer.provide(OrchestrationEventStoreLive),64 Layer.provide(OrchestrationEventStoreLive),
@@ -78,6 +81,8 @@ export function makeServerRuntimeServicesLayer() { · 16 unmodified lines
78 );81 );
79 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(82 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
80 Layer.provideMerge(runtimeServicesLayer),83 Layer.provideMerge(runtimeServicesLayer),
84 Layer.provideMerge(gitCoreLayer),
85 Layer.provideMerge(textGenerationLayer),
81 );86 );
82 const checkpointReactorLayer = CheckpointReactorLive.pipe(87 const checkpointReactorLayer = CheckpointReactorLive.pipe(
83 Layer.provideMerge(runtimeServicesLayer),88 Layer.provideMerge(runtimeServicesLayer),
@@ -92,12 +97,10 @@ export function makeServerRuntimeServicesLayer() { · 8 unmodified lines
92 Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive),97 Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive),
93 );98 );
94 99
95 const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive));
96
97 const gitManagerLayer = GitManagerLive.pipe(100 const gitManagerLayer = GitManagerLive.pipe(
98 Layer.provideMerge(gitCoreLayer),101 Layer.provideMerge(gitCoreLayer),
99 Layer.provideMerge(GitHubCliLive),102 Layer.provideMerge(GitHubCliLive),
100 Layer.provideMerge(CodexTextGenerationLive),103 Layer.provideMerge(textGenerationLayer),
101 );104 );
102 105
103 return Layer.mergeAll(106 return Layer.mergeAll(
apps/server/src/wsServer.test.ts+0−110
@@ -46,7 +46,6 @@ import { Open, type OpenShape } from "./open"; · 45 unmodified lines
46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
47import type { GitCoreShape } from "./git/Services/GitCore.ts";47import type { GitCoreShape } from "./git/Services/GitCore.ts";
48import { GitCore } from "./git/Services/GitCore.ts";48import { GitCore } from "./git/Services/GitCore.ts";
49import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
50import { GitCommandError, GitManagerError } from "./git/Errors.ts";49import { GitCommandError, GitManagerError } from "./git/Errors.ts";
51import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";50import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
52 51
@@ -374,7 +373,6 @@ describe("WebSocket Server", () => { · 321 unmodified lines
374 GitCoreShape,373 GitCoreShape,
375 "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"374 "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"
376 >;375 >;
377 textGeneration?: Pick<TextGenerationShape, "generateBranchName">;
378 terminalManager?: TerminalManagerShape;376 terminalManager?: TerminalManagerShape;
379 } = {},377 } = {},
380 ): Promise<Http.Server> {378 ): Promise<Http.Server> {
@@ -407,9 +405,6 @@ describe("WebSocket Server", () => { · 26 unmodified lines
407 options.gitCore405 options.gitCore
408 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)406 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
409 : Layer.empty,407 : Layer.empty,
410 options.textGeneration
411 ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape)
412 : Layer.empty,
413 options.terminalManager408 options.terminalManager
414 ? Layer.succeed(TerminalManager, options.terminalManager)409 ? Layer.succeed(TerminalManager, options.terminalManager)
415 : Layer.empty,410 : Layer.empty,
@@ -1477,7 +1472,6 @@ describe("WebSocket Server", () => { · 1061 unmodified lines
1477 );1472 );
1478 const initRepo = vi.fn(() => Effect.void);1473 const initRepo = vi.fn(() => Effect.void);
1479 const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));1474 const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1480 const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const }));
1481 const pullCurrentBranch = vi.fn(() =>1475 const pullCurrentBranch = vi.fn(() =>
1482 Effect.fail(1476 Effect.fail(
1483 new GitCommandError({1477 new GitCommandError({
@@ -1497,9 +1491,6 @@ describe("WebSocket Server", () => { · 13 unmodified lines
1497 renameBranch,1491 renameBranch,
1498 pullCurrentBranch,1492 pullCurrentBranch,
1499 },1493 },
1500 textGeneration: {
1501 generateBranchName,
1502 },
1503 });1494 });
1504 const addr = server.address();1495 const addr = server.address();
1505 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1496 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
@@ -1522,61 +1513,6 @@ describe("WebSocket Server", () => { · 16 unmodified lines
1522 expect(pullResponse.error?.message).toContain("No upstream configured");1513 expect(pullResponse.error?.message).toContain("No upstream configured");
1523 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");1514 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1524 1515
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
1580 const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {1516 const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {
1581 cwd: "/repo/path",1517 cwd: "/repo/path",
1582 oldBranch: "t3code/tmp-456",1518 oldBranch: "t3code/tmp-456",
@@ -1591,52 +1527,6 @@ describe("WebSocket Server", () => { · 8 unmodified lines
1591 });1527 });
1592 });1528 });
1593 1529
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
1640 it("supports git.status over websocket", async () => {1530 it("supports git.status over websocket", async () => {
1641 const statusResult = {1531 const statusResult = {
1642 branch: "feature/test",1532 branch: "feature/test",
apps/server/src/wsServer.ts+0−54
@@ -54,7 +54,6 @@ import { clamp } from "effect/Number"; · 53 unmodified lines
54import { Open } from "./open";54import { Open } from "./open";
55import { ServerConfig } from "./config";55import { ServerConfig } from "./config";
56import { GitCore } from "./git/Services/GitCore.ts";56import { GitCore } from "./git/Services/GitCore.ts";
57import { TextGeneration } from "./git/Services/TextGeneration.ts";
58import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";57import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
59 58
60/**59/**
@@ -136,31 +135,6 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { · 75 unmodified lines
136 return Struct.omit(body, ["_tag"]);135 return Struct.omit(body, ["_tag"]);
137}136}
138 137
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
164export type ServerCoreRuntimeServices =138export type ServerCoreRuntimeServices =
165 | OrchestrationEngineService139 | OrchestrationEngineService
166 | ProjectionSnapshotQuery140 | ProjectionSnapshotQuery
@@ -172,7 +146,6 @@ export type ServerRuntimeServices = · 5 unmodified lines
172 | ServerCoreRuntimeServices146 | ServerCoreRuntimeServices
173 | GitManager147 | GitManager
174 | GitCore148 | GitCore
175 | TextGeneration
176 | TerminalManager149 | TerminalManager
177 | Keybindings150 | Keybindings
178 | Open;151 | Open;
@@ -211,7 +184,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 32 unmodified lines
211 const terminalManager = yield* TerminalManager;184 const terminalManager = yield* TerminalManager;
212 const keybindingsManager = yield* Keybindings;185 const keybindingsManager = yield* Keybindings;
213 const git = yield* GitCore;186 const git = yield* GitCore;
214 const textGeneration = yield* TextGeneration;
215 const fileSystem = yield* FileSystem.FileSystem;187 const fileSystem = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;188 const path = yield* Path.Path;
217 189
@@ -600,32 +572,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 382 unmodified lines
600 return yield* git.createWorktree(body);572 return yield* git.createWorktree(body);
601 }573 }
602 574
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
629 case WS_METHODS.gitRenameBranch: {575 case WS_METHODS.gitRenameBranch: {
630 const body = stripRequestTag(request.body);576 const body = stripRequestTag(request.body);
631 return yield* git.renameBranch(body);577 return yield* git.renameBranch(body);