| ... | ... | @@ -87,13 +87,20 @@ describe("ProviderCommandReactor", () => { |
| 87 | 87 | const runtimeSessions: Array<ProviderSession> = []; |
| 88 | 88 | const startSession = vi.fn((_: unknown, input: unknown) => { |
| 89 | 89 | const sessionIndex = nextSessionIndex++; |
| 90 | const provider = |
| 91 | typeof input === "object" && |
| 92 | input !== null && |
| 93 | "provider" in input && |
| 94 | (input.provider === "codex" || input.provider === "claudeCode") |
| 95 | ? input.provider |
| 96 | : "codex"; |
| 90 | 97 | const resumeCursor = |
| 91 | 98 | typeof input === "object" && input !== null && "resumeCursor" in input |
| 92 | 99 | ? input.resumeCursor |
| 93 | 100 | : undefined; |
| 94 | 101 | const session: ProviderSession = { |
| 95 | 102 | sessionId: asSessionId(`sess-${sessionIndex}`), |
| 96 | | provider: "codex" as const, |
| 103 | provider, |
| 97 | 104 | status: "ready" as const, |
| 98 | 105 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), |
| 99 | 106 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, |
| ... | ... | @@ -240,165 +247,55 @@ describe("ProviderCommandReactor", () => { |
| 240 | 247 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); |
| 241 | 248 | }); |
| 242 | 249 | |
| 243 | | it("generates and renames temporary worktree branch on first turn", async () => { |
| 250 | it("starts first turn with requested provider when provider is specified", async () => { |
| 244 | 251 | const harness = await createHarness(); |
| 245 | 252 | const now = new Date().toISOString(); |
| 246 | 253 | |
| 247 | | await Effect.runPromise( |
| 248 | | harness.engine.dispatch({ |
| 249 | | type: "thread.meta.update", |
| 250 | | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"), |
| 251 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 252 | | branch: "t3code/89abc123", |
| 253 | | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", |
| 254 | | }), |
| 255 | | ); |
| 256 | | |
| 257 | | await Effect.runPromise( |
| 258 | | harness.engine.dispatch({ |
| 259 | | type: "thread.turn.start", |
| 260 | | commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"), |
| 261 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 262 | | message: { |
| 263 | | messageId: asMessageId("user-message-worktree-rename"), |
| 264 | | role: "user", |
| 265 | | text: "Fix visual bug from screenshot", |
| 266 | | attachments: [ |
| 267 | | { |
| 268 | | type: "image", |
| 269 | | id: "thread-1-att-rename", |
| 270 | | name: "bug.png", |
| 271 | | mimeType: "image/png", |
| 272 | | sizeBytes: 5, |
| 273 | | }, |
| 274 | | ], |
| 275 | | }, |
| 276 | | approvalPolicy: "on-request", |
| 277 | | sandboxMode: "workspace-write", |
| 278 | | createdAt: now, |
| 279 | | }), |
| 280 | | ); |
| 281 | | |
| 282 | | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); |
| 283 | | await waitFor(() => harness.renameBranch.mock.calls.length === 1); |
| 284 | | |
| 285 | | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ |
| 286 | | cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", |
| 287 | | message: "Fix visual bug from screenshot", |
| 288 | | attachments: [ |
| 289 | | { |
| 290 | | type: "image", |
| 291 | | id: "thread-1-att-rename", |
| 292 | | name: "bug.png", |
| 293 | | mimeType: "image/png", |
| 294 | | sizeBytes: 5, |
| 295 | | }, |
| 296 | | ], |
| 297 | | }); |
| 298 | | expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ |
| 299 | | cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", |
| 300 | | oldBranch: "t3code/89abc123", |
| 301 | | newBranch: "t3code/generated-name", |
| 302 | | }); |
| 303 | | |
| 304 | | await waitFor(() => { |
| 305 | | const readModel = Effect.runSync(harness.engine.getReadModel()); |
| 306 | | const thread = readModel.threads.find( |
| 307 | | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| 308 | | ); |
| 309 | | return thread?.branch === "t3code/generated-name"; |
| 310 | | }); |
| 311 | | }); |
| 312 | | |
| 313 | | it("passes persisted attachment references to branch generation and turn start", async () => { |
| 314 | | const harness = await createHarness(); |
| 315 | | const now = new Date().toISOString(); |
| 316 | | |
| 317 | | await Effect.runPromise( |
| 318 | | harness.engine.dispatch({ |
| 319 | | type: "thread.meta.update", |
| 320 | | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"), |
| 321 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 322 | | branch: "t3code/abcdef12", |
| 323 | | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", |
| 324 | | }), |
| 325 | | ); |
| 326 | | |
| 327 | 254 | await Effect.runPromise( |
| 328 | 255 | harness.engine.dispatch({ |
| 329 | 256 | type: "thread.turn.start", |
| 330 | | commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"), |
| 257 | commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), |
| 331 | 258 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 332 | 259 | message: { |
| 333 | | messageId: asMessageId("user-message-persisted-attachments"), |
| 260 | messageId: asMessageId("user-message-provider-first"), |
| 334 | 261 | role: "user", |
| 335 | | text: "Fix visual bug from screenshot", |
| 336 | | attachments: [ |
| 337 | | { |
| 338 | | type: "image", |
| 339 | | id: "thread-1-att-persisted", |
| 340 | | name: "bug.png", |
| 341 | | mimeType: "image/png", |
| 342 | | sizeBytes: 5, |
| 343 | | }, |
| 344 | | ], |
| 262 | text: "hello claude", |
| 263 | attachments: [], |
| 345 | 264 | }, |
| 265 | provider: "claudeCode", |
| 346 | 266 | approvalPolicy: "on-request", |
| 347 | 267 | sandboxMode: "workspace-write", |
| 348 | 268 | createdAt: now, |
| 349 | 269 | }), |
| 350 | 270 | ); |
| 351 | 271 | |
| 352 | | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); |
| 272 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 353 | 273 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 354 | | |
| 355 | | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ |
| 356 | | cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", |
| 357 | | message: "Fix visual bug from screenshot", |
| 358 | | attachments: [ |
| 359 | | { |
| 360 | | type: "image", |
| 361 | | id: "thread-1-att-persisted", |
| 362 | | name: "bug.png", |
| 363 | | mimeType: "image/png", |
| 364 | | sizeBytes: 5, |
| 365 | | }, |
| 366 | | ], |
| 367 | | }); |
| 368 | | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 369 | | attachments: [ |
| 370 | | { |
| 371 | | type: "image", |
| 372 | | id: "thread-1-att-persisted", |
| 373 | | name: "bug.png", |
| 374 | | mimeType: "image/png", |
| 375 | | sizeBytes: 5, |
| 376 | | }, |
| 377 | | ], |
| 274 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 275 | provider: "claudeCode", |
| 276 | cwd: "/tmp/provider-project", |
| 277 | model: "gpt-5-codex", |
| 278 | approvalPolicy: "on-request", |
| 279 | sandboxMode: "workspace-write", |
| 378 | 280 | }); |
| 281 | |
| 282 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 283 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 284 | expect(thread?.session?.providerName).toBe("claudeCode"); |
| 285 | expect(thread?.session?.providerSessionId).toBe("sess-1"); |
| 379 | 286 | }); |
| 380 | 287 | |
| 381 | | it("skips worktree branch generation after the first user turn", async () => { |
| 288 | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 382 | 289 | const harness = await createHarness(); |
| 383 | 290 | const now = new Date().toISOString(); |
| 384 | 291 | |
| 385 | | await Effect.runPromise( |
| 386 | | harness.engine.dispatch({ |
| 387 | | type: "thread.meta.update", |
| 388 | | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"), |
| 389 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 390 | | branch: "t3code/1234abcd", |
| 391 | | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd", |
| 392 | | }), |
| 393 | | ); |
| 394 | | |
| 395 | 292 | await Effect.runPromise( |
| 396 | 293 | harness.engine.dispatch({ |
| 397 | 294 | type: "thread.turn.start", |
| 398 | | commandId: CommandId.makeUnsafe("cmd-turn-start-first"), |
| 295 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), |
| 399 | 296 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 400 | 297 | message: { |
| 401 | | messageId: asMessageId("user-message-first"), |
| 298 | messageId: asMessageId("user-message-unchanged-1"), |
| 402 | 299 | role: "user", |
| 403 | 300 | text: "first", |
| 404 | 301 | attachments: [], |
| ... | ... | @@ -409,15 +306,16 @@ describe("ProviderCommandReactor", () => { |
| 409 | 306 | }), |
| 410 | 307 | ); |
| 411 | 308 | |
| 412 | | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); |
| 309 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 310 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 413 | 311 | |
| 414 | 312 | await Effect.runPromise( |
| 415 | 313 | harness.engine.dispatch({ |
| 416 | 314 | type: "thread.turn.start", |
| 417 | | commandId: CommandId.makeUnsafe("cmd-turn-start-second"), |
| 315 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), |
| 418 | 316 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 419 | 317 | message: { |
| 420 | | messageId: asMessageId("user-message-second"), |
| 318 | messageId: asMessageId("user-message-unchanged-2"), |
| 421 | 319 | role: "user", |
| 422 | 320 | text: "second", |
| 423 | 321 | attachments: [], |
| ... | ... | @@ -429,75 +327,27 @@ describe("ProviderCommandReactor", () => { |
| 429 | 327 | ); |
| 430 | 328 | |
| 431 | 329 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 432 | | expect(harness.generateBranchName.mock.calls.length).toBe(1); |
| 433 | | expect(harness.renameBranch.mock.calls.length).toBe(1); |
| 434 | | }); |
| 435 | | |
| 436 | | it("skips worktree rename when branch-name generation fails", async () => { |
| 437 | | const harness = await createHarness(); |
| 438 | | const now = new Date().toISOString(); |
| 439 | | harness.generateBranchName.mockImplementationOnce(() => |
| 440 | | Effect.fail( |
| 441 | | new TextGenerationError({ |
| 442 | | operation: "generateBranchName", |
| 443 | | detail: "model returned invalid payload", |
| 444 | | }), |
| 445 | | ), |
| 446 | | ); |
| 447 | | |
| 448 | | await Effect.runPromise( |
| 449 | | harness.engine.dispatch({ |
| 450 | | type: "thread.meta.update", |
| 451 | | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"), |
| 452 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 453 | | branch: "t3code/0000abcd", |
| 454 | | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd", |
| 455 | | }), |
| 456 | | ); |
| 457 | | |
| 458 | | await Effect.runPromise( |
| 459 | | harness.engine.dispatch({ |
| 460 | | type: "thread.turn.start", |
| 461 | | commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"), |
| 462 | | threadId: ThreadId.makeUnsafe("thread-1"), |
| 463 | | message: { |
| 464 | | messageId: asMessageId("user-message-null-branch"), |
| 465 | | role: "user", |
| 466 | | text: "Fix visual regression", |
| 467 | | attachments: [], |
| 468 | | }, |
| 469 | | approvalPolicy: "on-request", |
| 470 | | sandboxMode: "workspace-write", |
| 471 | | createdAt: now, |
| 472 | | }), |
| 473 | | ); |
| 474 | | |
| 475 | | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); |
| 476 | | await Effect.runPromise(Effect.sleep("20 millis")); |
| 477 | | expect(harness.renameBranch.mock.calls.length).toBe(0); |
| 478 | | |
| 479 | | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 480 | | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 481 | | expect(thread?.branch).toBe("t3code/0000abcd"); |
| 330 | expect(harness.startSession.mock.calls.length).toBe(1); |
| 331 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 482 | 332 | }); |
| 483 | 333 | |
| 484 | | it("reuses the same provider session when runtime mode is unchanged", async () => { |
| 334 | it("restarts the provider session when runtime mode changes", async () => { |
| 485 | 335 | const harness = await createHarness(); |
| 486 | 336 | const now = new Date().toISOString(); |
| 487 | 337 | |
| 488 | 338 | await Effect.runPromise( |
| 489 | 339 | harness.engine.dispatch({ |
| 490 | 340 | type: "thread.turn.start", |
| 491 | | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), |
| 341 | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-1"), |
| 492 | 342 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 493 | 343 | message: { |
| 494 | | messageId: asMessageId("user-message-unchanged-1"), |
| 344 | messageId: asMessageId("user-message-runtime-mode-1"), |
| 495 | 345 | role: "user", |
| 496 | 346 | text: "first", |
| 497 | 347 | attachments: [], |
| 498 | 348 | }, |
| 499 | | approvalPolicy: "on-request", |
| 500 | | sandboxMode: "workspace-write", |
| 349 | approvalPolicy: "never", |
| 350 | sandboxMode: "danger-full-access", |
| 501 | 351 | createdAt: now, |
| 502 | 352 | }), |
| 503 | 353 | ); |
| ... | ... | @@ -508,10 +358,10 @@ describe("ProviderCommandReactor", () => { |
| 508 | 358 | await Effect.runPromise( |
| 509 | 359 | harness.engine.dispatch({ |
| 510 | 360 | type: "thread.turn.start", |
| 511 | | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), |
| 361 | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-2"), |
| 512 | 362 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 513 | 363 | message: { |
| 514 | | messageId: asMessageId("user-message-unchanged-2"), |
| 364 | messageId: asMessageId("user-message-runtime-mode-2"), |
| 515 | 365 | role: "user", |
| 516 | 366 | text: "second", |
| 517 | 367 | attachments: [], |
| ... | ... | @@ -522,28 +372,44 @@ describe("ProviderCommandReactor", () => { |
| 522 | 372 | }), |
| 523 | 373 | ); |
| 524 | 374 | |
| 375 | await waitFor(() => harness.stopSession.mock.calls.length === 1); |
| 376 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 525 | 377 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 526 | | expect(harness.startSession.mock.calls.length).toBe(1); |
| 527 | | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 378 | |
| 379 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 380 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 381 | resumeCursor: { opaque: "cursor-1" }, |
| 382 | approvalPolicy: "on-request", |
| 383 | sandboxMode: "workspace-write", |
| 384 | }); |
| 385 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 386 | sessionId: asSessionId("sess-2"), |
| 387 | }); |
| 388 | |
| 389 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 390 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 391 | expect(thread?.session?.providerSessionId).toBe("sess-2"); |
| 392 | expect(thread?.session?.approvalPolicy).toBe("on-request"); |
| 393 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); |
| 528 | 394 | }); |
| 529 | 395 | |
| 530 | | it("restarts the provider session when runtime mode changes", async () => { |
| 396 | it("switches provider by restarting the session when turn request provider changes", async () => { |
| 531 | 397 | const harness = await createHarness(); |
| 532 | 398 | const now = new Date().toISOString(); |
| 533 | 399 | |
| 534 | 400 | await Effect.runPromise( |
| 535 | 401 | harness.engine.dispatch({ |
| 536 | 402 | type: "thread.turn.start", |
| 537 | | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-1"), |
| 403 | commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-1"), |
| 538 | 404 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 539 | 405 | message: { |
| 540 | | messageId: asMessageId("user-message-runtime-mode-1"), |
| 406 | messageId: asMessageId("user-message-provider-switch-1"), |
| 541 | 407 | role: "user", |
| 542 | 408 | text: "first", |
| 543 | 409 | attachments: [], |
| 544 | 410 | }, |
| 545 | | approvalPolicy: "never", |
| 546 | | sandboxMode: "danger-full-access", |
| 411 | approvalPolicy: "on-request", |
| 412 | sandboxMode: "workspace-write", |
| 547 | 413 | createdAt: now, |
| 548 | 414 | }), |
| 549 | 415 | ); |
| ... | ... | @@ -554,14 +420,15 @@ describe("ProviderCommandReactor", () => { |
| 554 | 420 | await Effect.runPromise( |
| 555 | 421 | harness.engine.dispatch({ |
| 556 | 422 | type: "thread.turn.start", |
| 557 | | commandId: CommandId.makeUnsafe("cmd-turn-start-runtime-mode-2"), |
| 423 | commandId: CommandId.makeUnsafe("cmd-turn-start-provider-switch-2"), |
| 558 | 424 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 559 | 425 | message: { |
| 560 | | messageId: asMessageId("user-message-runtime-mode-2"), |
| 426 | messageId: asMessageId("user-message-provider-switch-2"), |
| 561 | 427 | role: "user", |
| 562 | 428 | text: "second", |
| 563 | 429 | attachments: [], |
| 564 | 430 | }, |
| 431 | provider: "claudeCode", |
| 565 | 432 | approvalPolicy: "on-request", |
| 566 | 433 | sandboxMode: "workspace-write", |
| 567 | 434 | createdAt: now, |
| ... | ... | @@ -574,17 +441,16 @@ describe("ProviderCommandReactor", () => { |
| 574 | 441 | |
| 575 | 442 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 576 | 443 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 444 | provider: "claudeCode", |
| 577 | 445 | resumeCursor: { opaque: "cursor-1" }, |
| 578 | 446 | approvalPolicy: "on-request", |
| 579 | 447 | sandboxMode: "workspace-write", |
| 580 | 448 | }); |
| 581 | | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 582 | | sessionId: asSessionId("sess-2"), |
| 583 | | }); |
| 584 | 449 | |
| 585 | 450 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 586 | 451 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 587 | 452 | expect(thread?.session?.providerSessionId).toBe("sess-2"); |
| 453 | expect(thread?.session?.providerName).toBe("claudeCode"); |
| 588 | 454 | expect(thread?.session?.approvalPolicy).toBe("on-request"); |
| 589 | 455 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); |
| 590 | 456 | }); |