Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion packages/thread-view/src/completed-turn-grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const visibleIds = new Set<string>();
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 [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -256,7 +306,11 @@ export function groupCompletedTurnMessages(
splitCompletedTurnMessages(messages, turn.terminalMessage);
return {
summaryItems: unwrapSingletonContextManagementGroups(
groupCompletedTurnSummaryMessages(turn, summaryMessages),
groupCompletedTurnSummaryMessages(
turn,
summaryMessages,
terminalMessages[0],
),
),
terminalMessages,
trailingMessages,
Expand Down
79 changes: 76 additions & 3 deletions packages/thread-view/test/completed-turn-grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
88 changes: 88 additions & 0 deletions packages/thread-view/test/completed-turn-summary-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading