From 7534ab18e0143299162db019ec0d102ff6acd01c Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 18 Jun 2026 16:44:22 -0700 Subject: [PATCH] Split turn rollups at user boundaries --- .../src/apply-turn-message-detail.ts | 4 ++ .../thread-view/src/build-thread-timeline.ts | 15 ++++- .../src/completed-turn-grouping.ts | 19 +++++- packages/thread-view/src/event-projection.ts | 1 + .../src/group-event-projection-turns.ts | 38 +++++++++++ .../completed-turn-summary-rendering.test.ts | 66 +++++++++++++++++++ 6 files changed, 141 insertions(+), 2 deletions(-) diff --git a/packages/thread-view/src/apply-turn-message-detail.ts b/packages/thread-view/src/apply-turn-message-detail.ts index 749ccf32c..79b10edad 100644 --- a/packages/thread-view/src/apply-turn-message-detail.ts +++ b/packages/thread-view/src/apply-turn-message-detail.ts @@ -108,6 +108,7 @@ function applyTurnMessageDetail( const includeMessages = turn.status === "pending" || turnMessageDetail === "full" || + (turn.externalUserBoundarySeqs?.length ?? 0) > 0 || shouldIncludeSummaryTurnMessages(messages, terminalMessage); const detailedTurn: EventProjectionTurn = { @@ -120,6 +121,9 @@ function applyTurnMessageDetail( completedAt: turn.completedAt, status: turn.status, summaryCount, + ...(turn.externalUserBoundarySeqs + ? { externalUserBoundarySeqs: turn.externalUserBoundarySeqs } + : {}), }; if (terminalMessage) { detailedTurn.terminalMessage = terminalMessage; diff --git a/packages/thread-view/src/build-thread-timeline.ts b/packages/thread-view/src/build-thread-timeline.ts index b2120c514..ced84e163 100644 --- a/packages/thread-view/src/build-thread-timeline.ts +++ b/packages/thread-view/src/build-thread-timeline.ts @@ -1064,6 +1064,19 @@ function hasTurnSummaryRows(rows: TimelineRow[]): boolean { return rows.some((row) => row.kind === "turn"); } +function compareTimelineRowsBySource( + left: TimelineRow, + right: TimelineRow, +): number { + if (left.sourceSeqStart !== right.sourceSeqStart) { + return left.sourceSeqStart - right.sourceSeqStart; + } + if (left.sourceSeqEnd !== right.sourceSeqEnd) { + return left.sourceSeqEnd - right.sourceSeqEnd; + } + return 0; +} + function buildTimelineRows( projection: EventProjection, options: BuildTimelineRowsOptions, @@ -1092,7 +1105,7 @@ function buildTimelineRows( } } - return rows; + return rows.sort(compareTimelineRowsBySource); } export function buildThreadTimelineFromEvents( diff --git a/packages/thread-view/src/completed-turn-grouping.ts b/packages/thread-view/src/completed-turn-grouping.ts index 66877ceaa..37a4b0f51 100644 --- a/packages/thread-view/src/completed-turn-grouping.ts +++ b/packages/thread-view/src/completed-turn-grouping.ts @@ -123,7 +123,11 @@ function groupCompletedTurnSummaryMessages( turn: EventProjectionTurn, summaryMessages: EventProjectionMessage[], ): CompletedTurnSummaryItem[] { - if (!summaryMessages.some(isTimelineUngroupableMessage)) { + const externalBoundarySeqs = turn.externalUserBoundarySeqs ?? []; + if ( + externalBoundarySeqs.length === 0 && + !summaryMessages.some(isTimelineUngroupableMessage) + ) { return [ { kind: "summary", @@ -139,6 +143,7 @@ function groupCompletedTurnSummaryMessages( const items: CompletedTurnSummaryItem[] = []; let groupedMessages: EventProjectionMessage[] = []; let segmentIndex = 0; + let externalBoundaryIndex = 0; function flushGroupedMessages(): void { if (groupedMessages.length === 0) { @@ -159,7 +164,19 @@ function groupCompletedTurnSummaryMessages( groupedMessages = []; } + function flushExternalBoundariesBefore(message: EventProjectionMessage): void { + while ( + externalBoundaryIndex < externalBoundarySeqs.length && + (externalBoundarySeqs[externalBoundaryIndex] ?? 0) < + message.sourceSeqStart + ) { + flushGroupedMessages(); + externalBoundaryIndex += 1; + } + } + for (const message of summaryMessages) { + flushExternalBoundariesBefore(message); if (isTimelineUngroupableMessage(message)) { flushGroupedMessages(); items.push({ diff --git a/packages/thread-view/src/event-projection.ts b/packages/thread-view/src/event-projection.ts index a92d79d4e..ae1869b3d 100644 --- a/packages/thread-view/src/event-projection.ts +++ b/packages/thread-view/src/event-projection.ts @@ -76,6 +76,7 @@ export interface EventProjectionTurn { completedAt: number | null; status: EventProjectionTurnStatus; summaryCount: number; + externalUserBoundarySeqs?: number[]; terminalMessage?: EventProjectionMessage; messages?: EventProjectionMessage[]; } diff --git a/packages/thread-view/src/group-event-projection-turns.ts b/packages/thread-view/src/group-event-projection-turns.ts index 91c3a667e..e45e5492a 100644 --- a/packages/thread-view/src/group-event-projection-turns.ts +++ b/packages/thread-view/src/group-event-projection-turns.ts @@ -156,6 +156,42 @@ function addProjectionTurnMessage( }); } +function isExternalUserBoundaryForTurn( + turnId: string, + turn: EventProjectionTurn, + message: EventProjectionMessage, +): boolean { + if (message.kind !== "user" || message.initiator !== "user") { + return false; + } + if ( + message.sourceSeqStart <= turn.sourceSeqStart || + message.sourceSeqStart >= turn.sourceSeqEnd + ) { + return false; + } + return message.scope.kind !== "turn" || message.scope.turnId !== turnId; +} + +function applyExternalUserBoundaries( + turnsById: Map, + messages: EventProjectionMessage[], +): void { + for (const [turnId, draft] of turnsById) { + const boundarySeqs = new Set(); + for (const message of messages) { + if (isExternalUserBoundaryForTurn(turnId, draft.turn, message)) { + boundarySeqs.add(message.sourceSeqStart); + } + } + if (boundarySeqs.size > 0) { + draft.turn.externalUserBoundarySeqs = [...boundarySeqs].sort( + (left, right) => left - right, + ); + } + } +} + function createEventProjectionEntry( draft: ProjectionEntryDraft, turnsById: Map, @@ -290,6 +326,8 @@ export function groupEventProjectionTurns( addProjectionTurnMessage(turnDraft, message); } + applyExternalUserBoundaries(turnsById, args.messages); + const orderedEntryDrafts = [...entryDrafts].sort((left, right) => { if (left.sourceSeqStart !== right.sourceSeqStart) { return left.sourceSeqStart - right.sourceSeqStart; diff --git a/packages/thread-view/test/completed-turn-summary-rendering.test.ts b/packages/thread-view/test/completed-turn-summary-rendering.test.ts index 2e6f5a697..469909f1c 100644 --- a/packages/thread-view/test/completed-turn-summary-rendering.test.ts +++ b/packages/thread-view/test/completed-turn-summary-rendering.test.ts @@ -256,6 +256,72 @@ describe("completed turn summary rendering", () => { ).toEqual([["work:command"], ["work:command"]]); }); + it("splits background parent turn summaries at later user turn boundaries", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const reviewRequest = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "$ottonomous:review", + }); + const events: TimelineFixtureEvent[] = [ + reviewRequest, + event.turnStarted({ turnId: "parent-review" }), + event.inputAccepted({ + clientRequestId: reviewRequest.data.requestId, + turnId: "parent-review", + }), + event.commandCompleted({ + command: "pnpm exec turbo run test --filter=@bb/thread-view", + itemId: "parent-before-user", + turnId: "parent-review", + }), + event.turnStarted({ turnId: "background-worker" }), + event.commandCompleted({ + command: "rg timeline packages/thread-view", + itemId: "worker-command", + turnId: "background-worker", + }), + event.assistantCompleted({ + itemId: "worker-terminal", + text: "Worker finished.", + turnId: "background-worker", + }), + event.turnCompleted({ turnId: "background-worker" }), + ]; + const followUpRequest = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "one more change", + }); + events.push( + followUpRequest, + event.commandCompleted({ + command: "rg links apps packages", + itemId: "parent-after-user", + turnId: "parent-review", + }), + event.assistantCompleted({ + itemId: "parent-terminal", + text: "I will handle the follow-up.", + turnId: "parent-review", + }), + event.turnCompleted({ turnId: "parent-review" }), + ); + + const timeline = renderCompletedTimeline({ events }); + + expect(rowSignatures(timeline.rows)).toEqual([ + "conversation:user", + "turn:4-4", + "turn:5-8", + "conversation:assistant", + "conversation:user", + "turn:10-10", + "conversation:assistant", + ]); + expect( + turnRows(timeline.rows).map((row) => rowSignatures(row.children ?? [])), + ).toEqual([["work:command"], ["work:command"], ["work:command"]]); + }); + it("does not split completed turn summaries around accepted assistant steers", () => { const event = createTimelineEventFactory({ threadId: "thread-1" }); const events: TimelineFixtureEvent[] = [