- 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 | ProviderService, | 20 | ProviderService, |
| 21 | type ProviderServiceShape, | 21 | type ProviderServiceShape, |
| 22 | } from "../../provider/Services/ProviderService.ts"; | 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 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 25 | import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; | 27 | import { 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(); |
| @@ -15,6 +15,8 @@ import { · 14 unmodified lines | |||
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 16 | 16 | ||
| 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | ||
| 19 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; | ||
| 18 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 19 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 20 | import { | 22 | import { |
| @@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; · 45 unmodified lines | |||
| 66 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); | 68 | const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); |
| 67 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; | 69 | const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; |
| 68 | const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; | 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 | const make = Effect.gen(function* () { | 101 | const 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, |