| author | |
| committer | |
| log | 6866fd6b5ccff82333ad4386ce68cdec03531b68 |
| tree | 160070d51fc3db02f7b5d1e3d2b8f82dafca3972 |
| parent | 7e460f429b740180cd72730418262a2df971ba54 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>2 files changed, 183 insertions(+), 9 deletions(-)
packages/client-runtime/src/state/threadReducer.test.ts+133| ... | ... | @@ -497,6 +497,139 @@ describe("applyThreadDetailEvent", () => { |
| 497 | 497 | expect(result.thread.latestTurn?.completedAt).toBeNull(); |
| 498 | 498 | } |
| 499 | 499 | }); |
| 500 | ||
| 501 | it("keeps latestTurn and checkpoints references across a streaming delta", () => { | |
| 502 | const streamingThread: OrchestrationThread = { | |
| 503 | ...baseThread, | |
| 504 | session: { | |
| 505 | threadId: ThreadId.make("thread-1"), | |
| 506 | status: "running", | |
| 507 | providerName: "claude", | |
| 508 | runtimeMode: "full-access", | |
| 509 | activeTurnId: TurnId.make("turn-1"), | |
| 510 | lastError: null, | |
| 511 | updatedAt: "2026-04-01T06:59:00.000Z", | |
| 512 | }, | |
| 513 | latestTurn: { | |
| 514 | turnId: TurnId.make("turn-1"), | |
| 515 | state: "running", | |
| 516 | requestedAt: "2026-04-01T06:59:00.000Z", | |
| 517 | startedAt: "2026-04-01T06:59:00.000Z", | |
| 518 | completedAt: null, | |
| 519 | assistantMessageId: MessageId.make("msg-2"), | |
| 520 | }, | |
| 521 | messages: [ | |
| 522 | { | |
| 523 | id: MessageId.make("msg-2"), | |
| 524 | role: "assistant", | |
| 525 | text: "Hello", | |
| 526 | turnId: TurnId.make("turn-1"), | |
| 527 | streaming: true, | |
| 528 | createdAt: "2026-04-01T06:00:00.000Z", | |
| 529 | updatedAt: "2026-04-01T06:00:00.000Z", | |
| 530 | }, | |
| 531 | ], | |
| 532 | checkpoints: [ | |
| 533 | { | |
| 534 | turnId: TurnId.make("turn-1"), | |
| 535 | checkpointTurnCount: 1, | |
| 536 | checkpointRef: CheckpointRef.make("ref-1"), | |
| 537 | status: "ready", | |
| 538 | files: [], | |
| 539 | assistantMessageId: MessageId.make("msg-2"), | |
| 540 | completedAt: "2026-04-01T06:00:30.000Z", | |
| 541 | }, | |
| 542 | ], | |
| 543 | }; | |
| 544 | ||
| 545 | const result = applyThreadDetailEvent(streamingThread, { | |
| 546 | ...baseEventFields, | |
| 547 | sequence: 9, | |
| 548 | occurredAt: "2026-04-01T07:00:00.000Z", | |
| 549 | aggregateKind: "thread", | |
| 550 | aggregateId: ThreadId.make("thread-1"), | |
| 551 | type: "thread.message-sent", | |
| 552 | payload: { | |
| 553 | threadId: ThreadId.make("thread-1"), | |
| 554 | messageId: MessageId.make("msg-2"), | |
| 555 | role: "assistant", | |
| 556 | text: ", world", | |
| 557 | turnId: TurnId.make("turn-1"), | |
| 558 | streaming: true, | |
| 559 | createdAt: "2026-04-01T06:00:00.000Z", | |
| 560 | updatedAt: "2026-04-01T07:00:00.000Z", | |
| 561 | }, | |
| 562 | }); | |
| 563 | ||
| 564 | expect(result.kind).toBe("updated"); | |
| 565 | if (result.kind === "updated") { | |
| 566 | expect(result.thread.messages).not.toBe(streamingThread.messages); | |
| 567 | expect(result.thread.messages[0]?.text).toBe("Hello, world"); | |
| 568 | expect(result.thread.latestTurn).toBe(streamingThread.latestTurn); | |
| 569 | expect(result.thread.checkpoints).toBe(streamingThread.checkpoints); | |
| 570 | } | |
| 571 | }); | |
| 572 | ||
| 573 | it("replaces latestTurn and checkpoints when the first assistant message binds the turn", () => { | |
| 574 | const unboundThread: OrchestrationThread = { | |
| 575 | ...baseThread, | |
| 576 | session: { | |
| 577 | threadId: ThreadId.make("thread-1"), | |
| 578 | status: "running", | |
| 579 | providerName: "claude", | |
| 580 | runtimeMode: "full-access", | |
| 581 | activeTurnId: TurnId.make("turn-1"), | |
| 582 | lastError: null, | |
| 583 | updatedAt: "2026-04-01T06:59:00.000Z", | |
| 584 | }, | |
| 585 | latestTurn: { | |
| 586 | turnId: TurnId.make("turn-1"), | |
| 587 | state: "running", | |
| 588 | requestedAt: "2026-04-01T06:59:00.000Z", | |
| 589 | startedAt: "2026-04-01T06:59:00.000Z", | |
| 590 | completedAt: null, | |
| 591 | assistantMessageId: null, | |
| 592 | }, | |
| 593 | checkpoints: [ | |
| 594 | { | |
| 595 | turnId: TurnId.make("turn-1"), | |
| 596 | checkpointTurnCount: 1, | |
| 597 | checkpointRef: CheckpointRef.make("ref-1"), | |
| 598 | status: "ready", | |
| 599 | files: [], | |
| 600 | assistantMessageId: null, | |
| 601 | completedAt: "2026-04-01T06:59:30.000Z", | |
| 602 | }, | |
| 603 | ], | |
| 604 | }; | |
| 605 | ||
| 606 | const result = applyThreadDetailEvent(unboundThread, { | |
| 607 | ...baseEventFields, | |
| 608 | sequence: 9, | |
| 609 | occurredAt: "2026-04-01T07:00:00.000Z", | |
| 610 | aggregateKind: "thread", | |
| 611 | aggregateId: ThreadId.make("thread-1"), | |
| 612 | type: "thread.message-sent", | |
| 613 | payload: { | |
| 614 | threadId: ThreadId.make("thread-1"), | |
| 615 | messageId: MessageId.make("msg-2"), | |
| 616 | role: "assistant", | |
| 617 | text: "Hello", | |
| 618 | turnId: TurnId.make("turn-1"), | |
| 619 | streaming: true, | |
| 620 | createdAt: "2026-04-01T07:00:00.000Z", | |
| 621 | updatedAt: "2026-04-01T07:00:00.000Z", | |
| 622 | }, | |
| 623 | }); | |
| 624 | ||
| 625 | expect(result.kind).toBe("updated"); | |
| 626 | if (result.kind === "updated") { | |
| 627 | expect(result.thread.latestTurn).not.toBe(unboundThread.latestTurn); | |
| 628 | expect(result.thread.latestTurn?.assistantMessageId).toBe("msg-2"); | |
| 629 | expect(result.thread.checkpoints).not.toBe(unboundThread.checkpoints); | |
| 630 | expect(result.thread.checkpoints[0]?.assistantMessageId).toBe("msg-2"); | |
| 631 | } | |
| 632 | }); | |
| 500 | 633 | }); |
| 501 | 634 | |
| 502 | 635 | describe("thread.session-set", () => { |
packages/client-runtime/src/state/threadReducer.ts+50-9| ... | ... | @@ -340,16 +340,18 @@ export function applyThreadDetailEvent( |
| 340 | 340 | // assistant message only settles the turn once the session is no longer |
| 341 | 341 | // running it — providers may emit several assistant messages per turn |
| 342 | 342 | // (commentary between tool calls), and the turn must stay unsettled |
| 343 | // until the provider reports turn end. | |
| 343 | // until the provider reports turn end. Streaming deltas recompute the | |
| 344 | // same record, so the previous reference is kept when nothing changed. | |
| 344 | 345 | const turnStillRunning = |
| 345 | 346 | event.payload.turnId !== null && |
| 346 | 347 | thread.session?.status === "running" && |
| 347 | 348 | thread.session.activeTurnId === event.payload.turnId; |
| 348 | 349 | const settlesTurn = !event.payload.streaming && !turnStillRunning; |
| 349 | const latestTurn: OrchestrationThread["latestTurn"] = | |
| 350 | const latestTurn = reuseLatestTurn( | |
| 351 | thread.latestTurn, | |
| 350 | 352 | event.payload.role === "assistant" && |
| 351 | event.payload.turnId !== null && | |
| 352 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | |
| 353 | event.payload.turnId !== null && | |
| 354 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | |
| 353 | 355 | ? { |
| 354 | 356 | turnId: event.payload.turnId, |
| 355 | 357 | state: settlesTurn |
| ... | ... | @@ -374,9 +376,11 @@ export function applyThreadDetailEvent( |
| 374 | 376 | : null, |
| 375 | 377 | assistantMessageId: event.payload.messageId, |
| 376 | 378 | } |
| 377 | : thread.latestTurn; | |
| 379 | : thread.latestTurn, | |
| 380 | ); | |
| 378 | 381 | |
| 379 | // Rebind checkpoint assistant message IDs for assistant messages. | |
| 382 | // Rebind checkpoint assistant message IDs for assistant messages. The | |
| 383 | // helper hands back the same array when the entry is already bound. | |
| 380 | 384 | const checkpoints = |
| 381 | 385 | event.payload.role === "assistant" && event.payload.turnId !== null |
| 382 | 386 | ? rebindCheckpointAssistantMessage( |
| ... | ... | @@ -403,7 +407,8 @@ export function applyThreadDetailEvent( |
| 403 | 407 | // Leaving the "running" session status is the turn-end signal: settle a |
| 404 | 408 | // still-running latest turn so its duration reflects the whole turn. |
| 405 | 409 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); |
| 406 | const latestTurn: OrchestrationLatestTurn | null = | |
| 410 | const latestTurn = reuseLatestTurn( | |
| 411 | thread.latestTurn, | |
| 407 | 412 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null |
| 408 | 413 | ? { |
| 409 | 414 | turnId: event.payload.session.activeTurnId, |
| ... | ... | @@ -433,7 +438,8 @@ export function applyThreadDetailEvent( |
| 433 | 438 | // "running" is the authoritative turn end. |
| 434 | 439 | completedAt: event.payload.session.updatedAt, |
| 435 | 440 | } |
| 436 | : thread.latestTurn; | |
| 441 | : thread.latestTurn, | |
| 442 | ); | |
| 437 | 443 | |
| 438 | 444 | return { |
| 439 | 445 | kind: "updated", |
| ... | ... | @@ -687,11 +693,46 @@ function checkpointStatusToTurnState( |
| 687 | 693 | } |
| 688 | 694 | } |
| 689 | 695 | |
| 696 | /** | |
| 697 | * Returns `previous` when `next` matches it field for field, otherwise `next`. | |
| 698 | * Streaming cases recompute the latest turn on every delta, and keeping the | |
| 699 | * old reference lets selectors and memos keyed on `latestTurn` skip work. | |
| 700 | */ | |
| 701 | function reuseLatestTurn( | |
| 702 | previous: OrchestrationLatestTurn | null, | |
| 703 | next: OrchestrationLatestTurn | null, | |
| 704 | ): OrchestrationLatestTurn | null { | |
| 705 | if (previous === null || next === null) { | |
| 706 | return next; | |
| 707 | } | |
| 708 | return previous.turnId === next.turnId && | |
| 709 | previous.state === next.state && | |
| 710 | previous.requestedAt === next.requestedAt && | |
| 711 | previous.startedAt === next.startedAt && | |
| 712 | previous.completedAt === next.completedAt && | |
| 713 | previous.assistantMessageId === next.assistantMessageId && | |
| 714 | previous.sourceProposedPlan?.threadId === next.sourceProposedPlan?.threadId && | |
| 715 | previous.sourceProposedPlan?.planId === next.sourceProposedPlan?.planId | |
| 716 | ? previous | |
| 717 | : next; | |
| 718 | } | |
| 719 | ||
| 720 | /** | |
| 721 | * Points the checkpoint for `turnId` at `messageId`. Returns the input array | |
| 722 | * untouched when no checkpoint needs rebinding, so streaming deltas for an | |
| 723 | * already-bound message do not allocate a new `checkpoints` reference. | |
| 724 | */ | |
| 690 | 725 | function rebindCheckpointAssistantMessage( |
| 691 | 726 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, |
| 692 | 727 | turnId: TurnId, |
| 693 | 728 | messageId: MessageId, |
| 694 | ): OrchestrationCheckpointSummary[] { | |
| 729 | ): ReadonlyArray<OrchestrationCheckpointSummary> { | |
| 730 | const needsRebind = checkpoints.some( | |
| 731 | (entry) => entry.turnId === turnId && entry.assistantMessageId !== messageId, | |
| 732 | ); | |
| 733 | if (!needsRebind) { | |
| 734 | return checkpoints; | |
| 735 | } | |
| 695 | 736 | return Arr.map(checkpoints, (entry) => |
| 696 | 737 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, |
| 697 | 738 | ); |