Add restart semantics when requested provider changes and cover with tests.
Unified · Split
| @@ -87,13 +87,20 @@ describe("ProviderCommandReactor", () => { · 86 unmodified lines | |||
| 87 | const runtimeSessions: Array<ProviderSession> = []; | 87 | const runtimeSessions: Array<ProviderSession> = []; |
| 88 | const startSession = vi.fn((_: unknown, input: unknown) => { | 88 | const startSession = vi.fn((_: unknown, input: unknown) => { |
| 89 | const sessionIndex = nextSessionIndex++; | 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 | const resumeCursor = | 97 | const resumeCursor = |
| 91 | typeof input === "object" && input !== null && "resumeCursor" in input | 98 | typeof input === "object" && input !== null && "resumeCursor" in input |
| 92 | ? input.resumeCursor | 99 | ? input.resumeCursor |
| 93 | : undefined; | 100 | : undefined; |
| 94 | const session: ProviderSession = { | 101 | const session: ProviderSession = { |
| 95 | sessionId: asSessionId(`sess-${sessionIndex}`), | 102 | sessionId: asSessionId(`sess-${sessionIndex}`), |
| 96 | provider: "codex" as const, | 103 | provider, |
| 97 | status: "ready" as const, | 104 | status: "ready" as const, |
| 98 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), | 105 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), |
| 99 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, | 106 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, |
| @@ -240,165 +247,55 @@ describe("ProviderCommandReactor", () => { · 140 unmodified lines | |||
| 240 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); | 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 | const harness = await createHarness(); | 251 | const harness = await createHarness(); |
| 245 | const now = new Date().toISOString(); | 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 | await Effect.runPromise( | 254 | await Effect.runPromise( |
| 328 | harness.engine.dispatch({ | 255 | harness.engine.dispatch({ |
| 329 | type: "thread.turn.start", | 256 | type: "thread.turn.start", |
| 330 | commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"), | 257 | commandId: CommandId.makeUnsafe("cmd-turn-start-provider-first"), |
| 331 | threadId: ThreadId.makeUnsafe("thread-1"), | 258 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 332 | message: { | 259 | message: { |
| 333 | messageId: asMessageId("user-message-persisted-attachments"), | 260 | messageId: asMessageId("user-message-provider-first"), |
| 334 | role: "user", | 261 | role: "user", |
| 335 | text: "Fix visual bug from screenshot", | 262 | text: "hello claude", |
| 336 | attachments: [ | 263 | attachments: [], |
| 337 | { | ||
| 338 | type: "image", | ||
| 339 | id: "thread-1-att-persisted", | ||
| 340 | name: "bug.png", | ||
| 341 | mimeType: "image/png", | ||
| 342 | sizeBytes: 5, | ||
| 343 | }, | ||
| 344 | ], | ||
| 345 | }, | 264 | }, |
| 265 | provider: "claudeCode", | ||
| 346 | approvalPolicy: "on-request", | 266 | approvalPolicy: "on-request", |
| 347 | sandboxMode: "workspace-write", | 267 | sandboxMode: "workspace-write", |
| 348 | createdAt: now, | 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 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 273 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 354 | 274 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | |
| 355 | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ | 275 | provider: "claudeCode", |
| 356 | cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", | 276 | cwd: "/tmp/provider-project", |
| 357 | message: "Fix visual bug from screenshot", | 277 | model: "gpt-5-codex", |
| 358 | attachments: [ | 278 | approvalPolicy: "on-request", |
| 359 | { | 279 | sandboxMode: "workspace-write", |
| 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 | ], | ||
| 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 | const harness = await createHarness(); | 289 | const harness = await createHarness(); |
| 383 | const now = new Date().toISOString(); | 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 | await Effect.runPromise( | 292 | await Effect.runPromise( |
| 396 | harness.engine.dispatch({ | 293 | harness.engine.dispatch({ |
| 397 | type: "thread.turn.start", | 294 | type: "thread.turn.start", |
| 398 | commandId: CommandId.makeUnsafe("cmd-turn-start-first"), | 295 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-1"), |
| 399 | threadId: ThreadId.makeUnsafe("thread-1"), | 296 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 400 | message: { | 297 | message: { |
| 401 | messageId: asMessageId("user-message-first"), | 298 | messageId: asMessageId("user-message-unchanged-1"), |
| 402 | role: "user", | 299 | role: "user", |
| 403 | text: "first", | 300 | text: "first", |
| 404 | attachments: [], | 301 | attachments: [], |
| @@ -409,15 +306,16 @@ describe("ProviderCommandReactor", () => { · 4 unmodified lines | |||
| 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 | await Effect.runPromise( | 312 | await Effect.runPromise( |
| 415 | harness.engine.dispatch({ | 313 | harness.engine.dispatch({ |
| 416 | type: "thread.turn.start", | 314 | type: "thread.turn.start", |
| 417 | commandId: CommandId.makeUnsafe("cmd-turn-start-second"), | 315 | commandId: CommandId.makeUnsafe("cmd-turn-start-unchanged-2"), |
| 418 | threadId: ThreadId.makeUnsafe("thread-1"), | 316 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 419 | message: { | 317 | message: { |
| 420 | messageId: asMessageId("user-message-second"), | 318 | messageId: asMessageId("user-message-unchanged-2"), |
| 421 | role: "user", | 319 | role: "user", |
| 422 | text: "second", | 320 | text: "second", |
| 423 | attachments: [], | 321 | attachments: [], |
| @@ -429,75 +327,27 @@ describe("ProviderCommandReactor", () => { · 5 unmodified lines | |||
| 429 | ); | 327 | ); |
| 430 | 328 | ||
| 431 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | 329 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 432 | expect(harness.generateBranchName.mock.calls.length).toBe(1); | 330 | expect(harness.startSession.mock.calls.length).toBe(1); |
| 433 | expect(harness.renameBranch.mock.calls.length).toBe(1); | 331 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 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"); | ||
| 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 | const harness = await createHarness(); | 335 | const harness = await createHarness(); |
| 486 | const now = new Date().toISOString(); | 336 | const now = new Date().toISOString(); |
| 487 | 337 | ||
| 488 | await Effect.runPromise( | 338 | await Effect.runPromise( |
| 489 | harness.engine.dispatch({ | 339 | harness.engine.dispatch({ |
| 490 | type: "thread.turn.start", | 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 | threadId: ThreadId.makeUnsafe("thread-1"), | 342 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 493 | message: { | 343 | message: { |
| 494 | messageId: asMessageId("user-message-unchanged-1"), | 344 | messageId: asMessageId("user-message-runtime-mode-1"), |
| 495 | role: "user", | 345 | role: "user", |
| 496 | text: "first", | 346 | text: "first", |
| 497 | attachments: [], | 347 | attachments: [], |
| 498 | }, | 348 | }, |
| 499 | approvalPolicy: "on-request", | 349 | approvalPolicy: "never", |
| 500 | sandboxMode: "workspace-write", | 350 | sandboxMode: "danger-full-access", |
| 501 | createdAt: now, | 351 | createdAt: now, |
| 502 | }), | 352 | }), |
| 503 | ); | 353 | ); |
| @@ -508,10 +358,10 @@ describe("ProviderCommandReactor", () => { · 4 unmodified lines | |||
| 508 | await Effect.runPromise( | 358 | await Effect.runPromise( |
| 509 | harness.engine.dispatch({ | 359 | harness.engine.dispatch({ |
| 510 | type: "thread.turn.start", | 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 | threadId: ThreadId.makeUnsafe("thread-1"), | 362 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 513 | message: { | 363 | message: { |
| 514 | messageId: asMessageId("user-message-unchanged-2"), | 364 | messageId: asMessageId("user-message-runtime-mode-2"), |
| 515 | role: "user", | 365 | role: "user", |
| 516 | text: "second", | 366 | text: "second", |
| 517 | attachments: [], | 367 | attachments: [], |
| @@ -522,28 +372,44 @@ describe("ProviderCommandReactor", () => { · 4 unmodified lines | |||
| 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 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | 377 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 526 | expect(harness.startSession.mock.calls.length).toBe(1); | 378 | |
| 527 | expect(harness.stopSession.mock.calls.length).toBe(0); | 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 | const harness = await createHarness(); | 397 | const harness = await createHarness(); |
| 532 | const now = new Date().toISOString(); | 398 | const now = new Date().toISOString(); |
| 533 | 399 | ||
| 534 | await Effect.runPromise( | 400 | await Effect.runPromise( |
| 535 | harness.engine.dispatch({ | 401 | harness.engine.dispatch({ |
| 536 | type: "thread.turn.start", | 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 | threadId: ThreadId.makeUnsafe("thread-1"), | 404 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 539 | message: { | 405 | message: { |
| 540 | messageId: asMessageId("user-message-runtime-mode-1"), | 406 | messageId: asMessageId("user-message-provider-switch-1"), |
| 541 | role: "user", | 407 | role: "user", |
| 542 | text: "first", | 408 | text: "first", |
| 543 | attachments: [], | 409 | attachments: [], |
| 544 | }, | 410 | }, |
| 545 | approvalPolicy: "never", | 411 | approvalPolicy: "on-request", |
| 546 | sandboxMode: "danger-full-access", | 412 | sandboxMode: "workspace-write", |
| 547 | createdAt: now, | 413 | createdAt: now, |
| 548 | }), | 414 | }), |
| 549 | ); | 415 | ); |
| @@ -554,14 +420,15 @@ describe("ProviderCommandReactor", () => { · 4 unmodified lines | |||
| 554 | await Effect.runPromise( | 420 | await Effect.runPromise( |
| 555 | harness.engine.dispatch({ | 421 | harness.engine.dispatch({ |
| 556 | type: "thread.turn.start", | 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 | threadId: ThreadId.makeUnsafe("thread-1"), | 424 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 559 | message: { | 425 | message: { |
| 560 | messageId: asMessageId("user-message-runtime-mode-2"), | 426 | messageId: asMessageId("user-message-provider-switch-2"), |
| 561 | role: "user", | 427 | role: "user", |
| 562 | text: "second", | 428 | text: "second", |
| 563 | attachments: [], | 429 | attachments: [], |
| 564 | }, | 430 | }, |
| 431 | provider: "claudeCode", | ||
| 565 | approvalPolicy: "on-request", | 432 | approvalPolicy: "on-request", |
| 566 | sandboxMode: "workspace-write", | 433 | sandboxMode: "workspace-write", |
| 567 | createdAt: now, | 434 | createdAt: now, |
| @@ -574,17 +441,16 @@ describe("ProviderCommandReactor", () => { · 6 unmodified lines | |||
| 574 | 441 | ||
| 575 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | 442 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 576 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | 443 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 444 | provider: "claudeCode", | ||
| 577 | resumeCursor: { opaque: "cursor-1" }, | 445 | resumeCursor: { opaque: "cursor-1" }, |
| 578 | approvalPolicy: "on-request", | 446 | approvalPolicy: "on-request", |
| 579 | sandboxMode: "workspace-write", | 447 | sandboxMode: "workspace-write", |
| 580 | }); | 448 | }); |
| 581 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ | ||
| 582 | sessionId: asSessionId("sess-2"), | ||
| 583 | }); | ||
| 584 | 449 | ||
| 585 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 450 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 586 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 451 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 587 | expect(thread?.session?.providerSessionId).toBe("sess-2"); | 452 | expect(thread?.session?.providerSessionId).toBe("sess-2"); |
| 453 | expect(thread?.session?.providerName).toBe("claudeCode"); | ||
| 588 | expect(thread?.session?.approvalPolicy).toBe("on-request"); | 454 | expect(thread?.session?.approvalPolicy).toBe("on-request"); |
| 589 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); | 455 | expect(thread?.session?.sandboxMode).toBe("workspace-write"); |
| 590 | }); | 456 | }); |
| @@ -168,6 +168,7 @@ const make = Effect.gen(function* () { · 167 unmodified lines | |||
| 168 | threadId: ThreadId, | 168 | threadId: ThreadId, |
| 169 | createdAt: string, | 169 | createdAt: string, |
| 170 | options?: { | 170 | options?: { |
| 171 | readonly provider?: ProviderKind; | ||
| 171 | readonly approvalPolicy?: ProviderApprovalPolicy; | 172 | readonly approvalPolicy?: ProviderApprovalPolicy; |
| 172 | readonly sandboxMode?: ProviderSandboxMode; | 173 | readonly sandboxMode?: ProviderSandboxMode; |
| 173 | }, | 174 | }, |
| @@ -182,10 +183,11 @@ const make = Effect.gen(function* () { · 8 unmodified lines | |||
| 182 | options?.approvalPolicy ?? thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY; | 183 | options?.approvalPolicy ?? thread.session?.approvalPolicy ?? DEFAULT_APPROVAL_POLICY; |
| 183 | const desiredSandboxMode = | 184 | const desiredSandboxMode = |
| 184 | options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; | 185 | options?.sandboxMode ?? thread.session?.sandboxMode ?? DEFAULT_SANDBOX_MODE; |
| 185 | const preferredProvider: ProviderKind | undefined = | 186 | const currentProvider: ProviderKind | undefined = |
| 186 | thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" | 187 | thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" |
| 187 | ? thread.session.providerName | 188 | ? thread.session.providerName |
| 188 | : undefined; | 189 | : undefined; |
| 190 | const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider; | ||
| 189 | const effectiveCwd = resolveThreadWorkspaceCwd({ | 191 | const effectiveCwd = resolveThreadWorkspaceCwd({ |
| 190 | thread, | 192 | thread, |
| 191 | projects: readModel.projects, | 193 | projects: readModel.projects, |
| @@ -196,12 +198,17 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 196 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), | 198 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), |
| 197 | ); | 199 | ); |
| 198 | 200 | ||
| 199 | const startProviderSession = (resumeCursor?: unknown) => | 201 | const startProviderSession = (input?: { |
| 202 | readonly resumeCursor?: unknown; | ||
| 203 | readonly provider?: ProviderKind; | ||
| 204 | }) => | ||
| 200 | providerService.startSession(threadId, { | 205 | providerService.startSession(threadId, { |
| 201 | ...(preferredProvider ? { provider: preferredProvider } : {}), | 206 | ...(input?.provider ?? preferredProvider |
| 207 | ? { provider: input?.provider ?? preferredProvider } | ||
| 208 | : {}), | ||
| 202 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 209 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 203 | ...(thread.model ? { model: thread.model } : {}), | 210 | ...(thread.model ? { model: thread.model } : {}), |
| 204 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), | 211 | ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 205 | approvalPolicy: desiredApprovalPolicy, | 212 | approvalPolicy: desiredApprovalPolicy, |
| 206 | sandboxMode: desiredSandboxMode, | 213 | sandboxMode: desiredSandboxMode, |
| 207 | }); | 214 | }); |
| @@ -232,13 +239,17 @@ const make = Effect.gen(function* () { · 24 unmodified lines | |||
| 232 | options.approvalPolicy !== thread.session?.approvalPolicy; | 239 | options.approvalPolicy !== thread.session?.approvalPolicy; |
| 233 | const sandboxModeChanged = | 240 | const sandboxModeChanged = |
| 234 | options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; | 241 | options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; |
| 242 | const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; | ||
| 235 | 243 | ||
| 236 | if (!approvalPolicyChanged && !sandboxModeChanged) { | 244 | if (!approvalPolicyChanged && !sandboxModeChanged && !providerChanged) { |
| 237 | return existingSessionId; | 245 | return existingSessionId; |
| 238 | } | 246 | } |
| 239 | 247 | ||
| 240 | const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); | 248 | const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); |
| 241 | const restartedSession = yield* startProviderSession(resumeCursor); | 249 | const restartedSession = yield* startProviderSession({ |
| 250 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), | ||
| 251 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), | ||
| 252 | }); | ||
| 242 | yield* bindSessionToThread(restartedSession); | 253 | yield* bindSessionToThread(restartedSession); |
| 243 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( | 254 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( |
| 244 | Effect.catchCause((cause) => | 255 | Effect.catchCause((cause) => |
| @@ -252,7 +263,9 @@ const make = Effect.gen(function* () { · 7 unmodified lines | |||
| 252 | return restartedSession.sessionId; | 263 | return restartedSession.sessionId; |
| 253 | } | 264 | } |
| 254 | 265 | ||
| 255 | const startedSession = yield* startProviderSession(); | 266 | const startedSession = yield* startProviderSession({ |
| 267 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), | ||
| 268 | }); | ||
| 256 | yield* bindSessionToThread(startedSession); | 269 | yield* bindSessionToThread(startedSession); |
| 257 | return startedSession.sessionId; | 270 | return startedSession.sessionId; |
| 258 | }); | 271 | }); |
| @@ -261,6 +274,7 @@ const make = Effect.gen(function* () { · 2 unmodified lines | |||
| 261 | readonly threadId: ThreadId; | 274 | readonly threadId: ThreadId; |
| 262 | readonly messageText: string; | 275 | readonly messageText: string; |
| 263 | readonly attachments?: ReadonlyArray<ChatAttachment>; | 276 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 277 | readonly provider?: ProviderKind; | ||
| 264 | readonly model?: string; | 278 | readonly model?: string; |
| 265 | readonly effort?: string; | 279 | readonly effort?: string; |
| 266 | readonly approvalPolicy: ProviderApprovalPolicy; | 280 | readonly approvalPolicy: ProviderApprovalPolicy; |
| @@ -272,6 +286,7 @@ const make = Effect.gen(function* () { · 5 unmodified lines | |||
| 272 | return; | 286 | return; |
| 273 | } | 287 | } |
| 274 | const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { | 288 | const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { |
| 289 | ...(input.provider !== undefined ? { provider: input.provider } : {}), | ||
| 275 | approvalPolicy: input.approvalPolicy, | 290 | approvalPolicy: input.approvalPolicy, |
| 276 | sandboxMode: input.sandboxMode, | 291 | sandboxMode: input.sandboxMode, |
| 277 | }); | 292 | }); |
| @@ -394,6 +409,7 @@ const make = Effect.gen(function* () { · 116 unmodified lines | |||
| 394 | threadId: event.payload.threadId, | 409 | threadId: event.payload.threadId, |
| 395 | messageText: message.text, | 410 | messageText: message.text, |
| 396 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | 411 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), |
| 412 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), | ||
| 397 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | 413 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 398 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), | 414 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), |
| 399 | approvalPolicy: event.payload.approvalPolicy, | 415 | approvalPolicy: event.payload.approvalPolicy, |