From 40224ee60a7b06c3a81fb163836afb74fa28f890 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 03:28:49 +0000 Subject: [PATCH] Keep adjacent assistant answers visible in completed turns When a Claude Code Stop hook blocks a stop, Claude Code injects the hook's reason as a synthetic user message and the model answers again. bb never sees that boundary, so one turn ends with two assistant texts back to back. Completed-turn grouping kept only the last text visible and folded the real answer into the collapsed "Worked for" summary. Treat an assistant text that is directly followed by more assistant text (no work in between) as a complete response and keep it as an ungrouped row. Text followed by tool activity still collapses as interim narration. Co-Authored-By: Claude --- .../src/completed-turn-grouping.ts | 56 +++++++++++- .../test/completed-turn-grouping.test.ts | 79 ++++++++++++++++- .../completed-turn-summary-rendering.test.ts | 88 +++++++++++++++++++ 3 files changed, 219 insertions(+), 4 deletions(-) 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({