- 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
Showing the changes to apps/server/src/orchestration (2 of 12 files). Show all
Unified · Split
| @@ -20,6 +20,8 @@ import { · 19 unmodified lines | ||
| 20 | 20 | ProviderService, |
| 21 | 21 | type ProviderServiceShape, |
| 22 | 22 | } 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"; | |
| 23 | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 25 | 27 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; |
| @@ -92,6 +94,16 @@ describe("ProviderCommandReactor", () => { · 66 unmodified lines | ||
| 92 | 94 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 93 | 95 | const respondToRequest = vi.fn((_: unknown) => Effect.void); |
| 94 | 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 | 108 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 97 | 109 | const service: ProviderServiceShape = { |
| @@ -115,6 +127,13 @@ describe("ProviderCommandReactor", () => { · 17 unmodified lines | ||
| 115 | 127 | const layer = ProviderCommandReactorLive.pipe( |
| 116 | 128 | Layer.provideMerge(orchestrationLayer), |
| 117 | 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 | 138 | runtime = ManagedRuntime.make(layer); |
| 120 | 139 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| @@ -155,6 +174,8 @@ describe("ProviderCommandReactor", () => { · 34 unmodified lines | ||
| 155 | 174 | interruptTurn, |
| 156 | 175 | respondToRequest, |
| 157 | 176 | stopSession, |
| 177 | + renameBranch, | |
| 178 | + generateBranchName, | |
| 158 | 179 | }; |
| 159 | 180 | } |
| 160 | 181 | |
| @@ -196,6 +217,174 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines | ||
| 196 | 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 | 388 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 200 | 389 | const harness = await createHarness(); |
| 201 | 390 | const now = new Date().toISOString(); |
| @@ -15,6 +15,8 @@ import { · 14 unmodified lines | ||
| 15 | 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 16 | 16 | |
| 17 | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 18 | +import { GitCore } from "../../git/Services/GitCore.ts"; | |
| 19 | +import { TextGeneration } from "../../git/Services/TextGeneration.ts"; | |
| 18 | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 19 | 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 20 | 22 | import { |
| @@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines | ||
| 66 | 68 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 67 | 69 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; |
| 68 | 70 | 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 | +} | |
| 69 | 100 | |
| 70 | 101 | const make = Effect.gen(function* () { |
| 71 | 102 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 72 | 103 | const providerService = yield* ProviderService; |
| 104 | + const git = yield* GitCore; | |
| 105 | + const textGeneration = yield* TextGeneration; | |
| 73 | 106 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 74 | 107 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 75 | 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 | 358 | const processTurnStartRequested = Effect.fnUntraced(function* ( |
| 251 | 359 | event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>, |
| 252 | 360 | ) { |
| @@ -273,6 +381,15 @@ const make = Effect.gen(function* () { · 20 unmodified lines | ||
| 273 | 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 | 393 | yield* sendTurnForThread({ |
| 277 | 394 | threadId: event.payload.threadId, |
| 278 | 395 | messageText: message.text, |