diff --git a/packages/thread-view/src/completed-turn-grouping.ts b/packages/thread-view/src/completed-turn-grouping.ts index df6a8e6a2d..02448a9265 100644 --- a/packages/thread-view/src/completed-turn-grouping.ts +++ b/packages/thread-view/src/completed-turn-grouping.ts @@ -142,13 +142,55 @@ function splitCompletedTurnMessages( }; } +function isAssistantResponseMessage( + message: EventProjectionMessage | undefined, +): boolean { + return ( + message?.kind === "assistant-text" && message.isLegacyUserMessage !== true + ); +} + +/** + * Assistant text that the provider followed directly with more assistant + * text, with no work in between, was a complete response, not narration about + * upcoming tool activity. Providers re-query the model after it stops without + * telling bb why (a Claude Code Stop hook injects its reason as a synthetic + * user message that never becomes a thread event), so the turn carries two + * answers and only the last one is the terminal message. The earlier answer + * must stay visible at rest instead of being folded into the collapsed work + * summary. Text followed by work keeps the existing collapse. + */ +function findVisibleResponseMessageIds( + summaryMessages: readonly EventProjectionMessage[], + terminalMessage: EventProjectionMessage | undefined, +): Set { + const visibleIds = new Set(); + for (let index = 0; index < summaryMessages.length; index += 1) { + const message = summaryMessages[index]; + const nextMessage = summaryMessages[index + 1] ?? terminalMessage; + if ( + isAssistantResponseMessage(message) && + isAssistantResponseMessage(nextMessage) + ) { + visibleIds.add(message.id); + } + } + return visibleIds; +} + function groupCompletedTurnSummaryMessages( turn: EventProjectionTurn, summaryMessages: EventProjectionMessage[], + terminalMessage: EventProjectionMessage | undefined, ): CompletedTurnSummaryItem[] { const externalBoundarySeqs = turn.externalUserBoundarySeqs ?? []; + const visibleResponseIds = findVisibleResponseMessageIds( + summaryMessages, + terminalMessage, + ); if ( externalBoundarySeqs.length === 0 && + visibleResponseIds.size === 0 && !summaryMessages.some(isTimelineUngroupableMessage) ) { return [ @@ -227,6 +269,14 @@ function groupCompletedTurnSummaryMessages( for (const message of summaryMessages) { flushExternalBoundariesBefore(message); + if (visibleResponseIds.has(message.id)) { + flushGroupedMessages(); + items.push({ + kind: "ungrouped-message", + message, + }); + continue; + } if (isTimelineUngroupableMessage(message)) { flushGroupedMessages( message.kind === "user" && message.initiator === "user", @@ -256,7 +306,11 @@ export function groupCompletedTurnMessages( splitCompletedTurnMessages(messages, turn.terminalMessage); return { summaryItems: unwrapSingletonContextManagementGroups( - groupCompletedTurnSummaryMessages(turn, summaryMessages), + groupCompletedTurnSummaryMessages( + turn, + summaryMessages, + terminalMessages[0], + ), ), terminalMessages, trailingMessages, diff --git a/packages/thread-view/test/completed-turn-grouping.test.ts b/packages/thread-view/test/completed-turn-grouping.test.ts index aa24884073..19d509f4b6 100644 --- a/packages/thread-view/test/completed-turn-grouping.test.ts +++ b/packages/thread-view/test/completed-turn-grouping.test.ts @@ -4,6 +4,7 @@ import { groupCompletedTurnMessages } from "../src/completed-turn-grouping.js"; import type { CompletedTurnMessageGroups } from "../src/completed-turn-grouping.js"; import type { EventProjectionAssistantTextMessage, + EventProjectionCommandMessage, EventProjectionMessage, EventProjectionOperationMessage, EventProjectionTurnRequest, @@ -39,6 +40,23 @@ function assistantMessage( }; } +function commandMessage(args: MessageBaseArgs): EventProjectionCommandMessage { + return { + ...messageBase(args), + kind: "command", + callId: args.id, + command: "pnpm test", + cwd: "/repo", + parsedIntents: [], + source: null, + output: "", + exitCode: 0, + completedAt: args.seq, + approvalStatus: null, + status: "completed", + }; +} + interface UserMessageArgs extends MessageBaseArgs { initiator?: EventProjectionUserMessage["initiator"]; turnRequest?: EventProjectionTurnRequest; @@ -160,7 +178,7 @@ describe("groupCompletedTurnMessages", () => { it("uses one summary group when no messages are ungroupable", () => { const messages = [ assistantMessage({ id: "assistant-1", seq: 1 }), - assistantMessage({ id: "assistant-2", seq: 2 }), + commandMessage({ id: "command-1", seq: 2 }), ]; const groups = groupCompletedTurnMessages( completedTurn(messages, undefined), @@ -176,8 +194,63 @@ describe("groupCompletedTurnMessages", () => { }, ]); expect(summarySourceMessageIds(groups)).toEqual([ - ["assistant-1", "assistant-2"], + ["assistant-1", "command-1"], + ]); + }); + + it("keeps an assistant response visible when more assistant text follows it directly", () => { + const answer = assistantMessage({ id: "answer", seq: 1 }); + const hookReply = assistantMessage({ id: "hook-reply", seq: 2 }); + const groups = groupCompletedTurnMessages( + completedTurn([answer, hookReply], hookReply), + ); + + expect(groups.summaryItems).toEqual([ + { kind: "ungrouped-message", message: answer }, + ]); + expect(groups.terminalMessages).toEqual([hookReply]); + }); + + it("folds narration that precedes work and keeps the response that precedes the terminal text", () => { + const narration = assistantMessage({ id: "narration", seq: 1 }); + const command = commandMessage({ id: "command", seq: 2 }); + const answer = assistantMessage({ id: "answer", seq: 3 }); + const hookReply = assistantMessage({ id: "hook-reply", seq: 4 }); + const groups = groupCompletedTurnMessages( + completedTurn([narration, command, answer, hookReply], hookReply), + ); + + expect(groups.summaryItems).toMatchObject([ + { + kind: "summary", + startedAt: 1, + completedAt: 4, + segmentIndex: 0, + sourceMessages: [{ id: "narration" }, { id: "command" }], + summaryCount: 2, + }, + { kind: "ungrouped-message", message: { id: "answer" } }, + ]); + expect(groups.terminalMessages).toEqual([hookReply]); + }); + + it("keeps every response in a run of adjacent assistant texts", () => { + const first = assistantMessage({ id: "first", seq: 1 }); + const second = assistantMessage({ id: "second", seq: 2 }); + const command = commandMessage({ id: "command", seq: 3 }); + const terminal = assistantMessage({ id: "terminal", seq: 4 }); + const groups = groupCompletedTurnMessages( + completedTurn([first, second, command, terminal], terminal), + ); + + expect(groups.summaryItems).toMatchObject([ + { kind: "ungrouped-message", message: { id: "first" } }, + { + kind: "summary", + sourceMessages: [{ id: "second" }, { id: "command" }], + }, ]); + expect(groups.terminalMessages).toEqual([terminal]); }); it("preserves the last assistant message before an ungroupable user message", () => { @@ -291,7 +364,7 @@ describe("groupCompletedTurnMessages", () => { }); it("slices terminal and trailing messages out of the summary groups", () => { - const before = assistantMessage({ id: "before", seq: 1 }); + const before = commandMessage({ id: "before", seq: 1 }); const terminal = assistantMessage({ id: "terminal", seq: 2 }); const trailing = assistantMessage({ id: "trailing", seq: 3 }); const groups = groupCompletedTurnMessages( 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 efadcb69ca..45554181e0 100644 --- a/packages/thread-view/test/completed-turn-summary-rendering.test.ts +++ b/packages/thread-view/test/completed-turn-summary-rendering.test.ts @@ -109,6 +109,94 @@ describe("completed turn summary rendering", () => { expect(rowSignatures(turnRow.children ?? [])).toEqual(["work:command"]); }); + it("keeps an assistant answer visible when the provider re-queries and the model answers again", () => { + // A Claude Code Stop hook blocks the stop and injects its reason as a + // synthetic user message that bb never sees (get-bb/bb#1355). The turn + // then holds two assistant texts back to back: the real answer and a + // short reply to the hook. Only the reply is the terminal message. + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "SQLite vs Postgres for a desktop app? End with a question.", + }); + const answer = + "- SQLite is a file, zero ops.\n- Postgres needs a server.\n**Question for you**: multi-user someday?"; + const hookReply = + "The verify gate is open: no fresh fast-loop verdict for HEAD, nothing actionable this turn."; + + const timeline = renderCompletedTimeline({ + events: [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ itemId: "assistant-1", text: answer }), + event.assistantCompleted({ itemId: "assistant-2", text: hookReply }), + event.turnCompleted(), + ], + }); + + expect(rowSignatures(timeline.rows)).toEqual([ + "conversation:user", + "conversation:assistant", + "conversation:assistant", + ]); + expect( + timeline.rows.flatMap((row) => + row.kind === "conversation" && row.role === "assistant" + ? [row.text] + : [], + ), + ).toEqual([answer, hookReply]); + expect(turnRows(timeline.rows)).toHaveLength(0); + }); + + it("folds narration before work but keeps the answer that precedes a hook reply", () => { + const event = createTimelineEventFactory({ threadId: "thread-1" }); + const request = event.clientTurnRequested({ + target: { kind: "new-turn" }, + text: "Is the daemon command blob pruning durable?", + }); + + const timeline = renderCompletedTimeline({ + events: [ + request, + event.turnStarted(), + event.inputAccepted({ clientRequestId: request.data.requestId }), + event.assistantCompleted({ + itemId: "assistant-1", + text: "Let me check the pruning code.", + }), + event.commandCompleted({ itemId: "tool-1", command: "rg prune" }), + event.assistantCompleted({ + itemId: "assistant-2", + text: "Yes: pruning runs inside the daemon transaction, so it is durable.", + }), + event.assistantCompleted({ + itemId: "assistant-3", + text: "The verify gate is still open for HEAD.", + }), + event.turnCompleted(), + ], + }); + + expect(rowSignatures(timeline.rows)).toEqual([ + "conversation:user", + "turn:4-5", + "conversation:assistant", + "conversation:assistant", + ]); + const turnRow = requireOnlyTurnRow(timeline.rows); + expect(turnRow).toMatchObject({ + startedAt: 2, + completedAt: 8, + summaryCount: 2, + }); + expect(rowSignatures(turnRow.children ?? [])).toEqual([ + "conversation:assistant", + "work:command", + ]); + }); + it("keeps turn-scoped environment directory update operations inside the completed turn summary", () => { const event = createTimelineEventFactory({ threadId: "thread-1" }); const request = event.clientTurnRequested({