| ... | @@ -340,16 +340,18 @@ export function applyThreadDetailEvent( | ... | @@ -340,16 +340,18 @@ export function applyThreadDetailEvent( |
| 340 | // assistant message only settles the turn once the session is no longer | 340 | // assistant message only settles the turn once the session is no longer |
| 341 | // running it — providers may emit several assistant messages per turn | 341 | // running it — providers may emit several assistant messages per turn |
| 342 | // (commentary between tool calls), and the turn must stay unsettled | 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 | const turnStillRunning = | 345 | const turnStillRunning = |
| 345 | event.payload.turnId !== null && | 346 | event.payload.turnId !== null && |
| 346 | thread.session?.status === "running" && | 347 | thread.session?.status === "running" && |
| 347 | thread.session.activeTurnId === event.payload.turnId; | 348 | thread.session.activeTurnId === event.payload.turnId; |
| 348 | const settlesTurn = !event.payload.streaming && !turnStillRunning; | 349 | const settlesTurn = !event.payload.streaming && !turnStillRunning; |
| 349 | const latestTurn: OrchestrationThread["latestTurn"] = | 350 | const latestTurn = reuseLatestTurn( |
| | 351 | thread.latestTurn, |
| 350 | event.payload.role === "assistant" && | 352 | event.payload.role === "assistant" && |
| 351 | event.payload.turnId !== null && | 353 | event.payload.turnId !== null && |
| 352 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | 354 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) |
| 353 | ? { | 355 | ? { |
| 354 | turnId: event.payload.turnId, | 356 | turnId: event.payload.turnId, |
| 355 | state: settlesTurn | 357 | state: settlesTurn |
| ... | @@ -374,9 +376,11 @@ export function applyThreadDetailEvent( | ... | @@ -374,9 +376,11 @@ export function applyThreadDetailEvent( |
| 374 | : null, | 376 | : null, |
| 375 | assistantMessageId: event.payload.messageId, | 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 | const checkpoints = | 384 | const checkpoints = |
| 381 | event.payload.role === "assistant" && event.payload.turnId !== null | 385 | event.payload.role === "assistant" && event.payload.turnId !== null |
| 382 | ? rebindCheckpointAssistantMessage( | 386 | ? rebindCheckpointAssistantMessage( |
| ... | @@ -403,7 +407,8 @@ export function applyThreadDetailEvent( | ... | @@ -403,7 +407,8 @@ export function applyThreadDetailEvent( |
| 403 | // Leaving the "running" session status is the turn-end signal: settle a | 407 | // Leaving the "running" session status is the turn-end signal: settle a |
| 404 | // still-running latest turn so its duration reflects the whole turn. | 408 | // still-running latest turn so its duration reflects the whole turn. |
| 405 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); | 409 | const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); |
| 406 | const latestTurn: OrchestrationLatestTurn | null = | 410 | const latestTurn = reuseLatestTurn( |
| | 411 | thread.latestTurn, |
| 407 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null | 412 | event.payload.session.status === "running" && event.payload.session.activeTurnId !== null |
| 408 | ? { | 413 | ? { |
| 409 | turnId: event.payload.session.activeTurnId, | 414 | turnId: event.payload.session.activeTurnId, |
| ... | @@ -433,7 +438,8 @@ export function applyThreadDetailEvent( | ... | @@ -433,7 +438,8 @@ export function applyThreadDetailEvent( |
| 433 | // "running" is the authoritative turn end. | 438 | // "running" is the authoritative turn end. |
| 434 | completedAt: event.payload.session.updatedAt, | 439 | completedAt: event.payload.session.updatedAt, |
| 435 | } | 440 | } |
| 436 | : thread.latestTurn; | 441 | : thread.latestTurn, |
| | 442 | ); |
| 437 | | 443 | |
| 438 | return { | 444 | return { |
| 439 | kind: "updated", | 445 | kind: "updated", |
| ... | @@ -687,11 +693,46 @@ function checkpointStatusToTurnState( | ... | @@ -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 | function rebindCheckpointAssistantMessage( | 725 | function rebindCheckpointAssistantMessage( |
| 691 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, | 726 | checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>, |
| 692 | turnId: TurnId, | 727 | turnId: TurnId, |
| 693 | messageId: MessageId, | 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 | return Arr.map(checkpoints, (entry) => | 736 | return Arr.map(checkpoints, (entry) => |
| 696 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, | 737 | entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, |
| 697 | ); | 738 | ); |