← reports

#3144 · Completed turns fold pre-terminal assistant prose

Bug Medium priority Effort: Medium threads open on GitHub 2026-09-05 · base dba32a469

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

While a turn is pending, the thread projection emits every message as a top-level row. When that same turn completes, the projection takes a different path: it selects one terminal assistant message and places all earlier summary-groupable messages into a turn summary. Ordinary assistant prose is summary-groupable, so prose separated from later text by tool activity moves into the completed turn’s collapsed detail. Two clean checkouts produced the same one-test failure, proving that the row set changes at completion and that the pre-terminal prose is retained only as nested summary source data.

2. Claims vs findings

ClaimStatusEvidence
Pending and completed turns project the same messages differently.VerifiedThe pending branch converts each message directly; the completed branch calls the grouping function. See build-thread-timeline.ts lines 1185–1214.
Assistant prose separated by work is folded after completion.VerifiedBoth clean runs received one summary containing first-analysis,first-read,second-analysis,second-read instead of four alternating visible/summary items.
Adjacent assistant text has a special visibility exception.Verifiedcompleted-turn-grouping.ts lines 145–169 only selects an earlier assistant message when the following message is also assistant text.
The folded prose is deleted.RefutedThe failing value shows the prose IDs preserved inside sourceMessages; it is hidden behind the turn summary rather than removed from the projection data.
The behavior is specific to one provider.Refuted at the grouping boundaryThe grouping predicate is provider-agnostic and checks message kind, legacy-user status, boundaries, and adjacency. A provider-specific live transcript was not needed for this deterministic path.
An external provider’s own transcript keeps every text block visible.UnverifiedNo provider process was started; the report verifies bb’s shared projection only.
An open pull request already addresses this issue.RefutedGitHub metadata returned no linked open pull request and no open pull request matching the trusted issue number on 2026-09-05.

3. Environment

4. Minimal reproduction

  1. Fetch the trusted base and create a detached checkout at dba32a469fd820ff6106715db0aaf6ed297d79a5.
  2. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Save the test below as packages/thread-view/test/repro/issue-3144.test.ts, then run:
    cd packages/thread-view
    pnpm exec vitest run test/repro/issue-3144.test.ts

Expected: assistant text remains top-level while each tool message is grouped.

[
  "visible:first-analysis",
  "summary:first-read",
  "visible:second-analysis",
  "summary:second-read"
]

Actual in both clean checkouts:

[
  "summary:first-analysis,first-read,second-analysis,second-read"
]

Test Files  1 failed (1)
Tests       1 failed (1)

Repro artifact: issue-3144.test.ts. Raw outputs: first checkout, second checkout.

import { turnScope } from "@bb/domain";
import { expect, it } from "vitest";
import { groupCompletedTurnMessages } from "../../src/completed-turn-grouping.js";
import type {
  EventProjectionAssistantTextMessage,
  EventProjectionCommandMessage,
  EventProjectionMessage,
  EventProjectionTurn,
} from "../../src/event-projection-types.js";

function base(id: string, seq: number) {
  return {
    id,
    threadId: "thread-1",
    sourceSeqStart: seq,
    sourceSeqEnd: seq,
    createdAt: seq,
    startedAt: seq,
    scope: turnScope("turn-1"),
  };
}

function assistant(
  id: string,
  seq: number,
): EventProjectionAssistantTextMessage {
  return {
    ...base(id, seq),
    kind: "assistant-text",
    text: id,
    status: "completed",
  };
}

function command(id: string, seq: number): EventProjectionCommandMessage {
  return {
    ...base(id, seq),
    kind: "command",
    callId: id,
    command: "read-file",
    cwd: "/repo",
    parsedIntents: [],
    source: null,
    output: "",
    exitCode: 0,
    completedAt: seq,
    approvalStatus: null,
    status: "completed",
  };
}

function completedTurn(
  messages: EventProjectionMessage[],
  terminalMessage: EventProjectionAssistantTextMessage,
): EventProjectionTurn {
  return {
    turnId: "turn-1",
    threadId: "thread-1",
    sourceSeqStart: 1,
    sourceSeqEnd: messages.length,
    startedAt: 1,
    createdAt: messages.length,
    completedAt: messages.length,
    status: "completed",
    summaryCount: messages.length,
    messages,
    terminalMessage,
  };
}

it("keeps assistant prose visible across completed-turn work", () => {
  const first = assistant("first-analysis", 1);
  const firstRead = command("first-read", 2);
  const second = assistant("second-analysis", 3);
  const secondRead = command("second-read", 4);
  const final = assistant("final-answer", 5);

  const groups = groupCompletedTurnMessages(
    completedTurn([first, firstRead, second, secondRead, final], final),
  );

  expect(
    groups.summaryItems.map((item) =>
      item.kind === "ungrouped-message"
        ? `visible:${item.message.id}`
        : `summary:${item.sourceMessages.map((message) => message.id).join(",")}`,
    ),
  ).toEqual([
    "visible:first-analysis",
    "summary:first-read",
    "visible:second-analysis",
    "summary:second-read",
  ]);
  expect(groups.terminalMessages.map((message) => message.id)).toEqual([
    "final-answer",
  ]);
});

5. Root cause

The completed-turn pipeline first removes one terminal message from the message list and assigns every preceding message to summaryMessages. See completed-turn-grouping.ts lines 108–142.

return {
  summaryMessages: messages.slice(0, terminalIndex),
  terminalMessages: [terminalMessageAtIndex],
  trailingMessages: messages.slice(terminalIndex + 1),
};

The fast path then emits one summary for all preceding messages whenever there is no external boundary, no adjacent-assistant visibility exception, and no ungroupable message. See completed-turn-grouping.ts lines 171–195.

if (
  externalBoundarySeqs.length === 0 &&
  visibleResponseIds.size === 0 &&
  !summaryMessages.some(isTimelineUngroupableMessage)
) {
  return [{ kind: "summary", sourceMessages: summaryMessages, ... }];
}

Ordinary assistant text does not count as ungroupable: only assistant text marked as a converted legacy user message does. See timeline-message-helpers.ts lines 19–29. A tool between two assistant messages also prevents the adjacency exception. Those two rules force the reproduced message shape into a single summary.

The app’s expandable row defaults to closed unless it is manually, initially, live-frontier, terminal-frontier, or search expanded. The final assistant row follows the summary, so the completed summary is not the terminal frontier. See ExpandableTimelineRow.tsx lines 78–109. The server builds these shared rows at timeline.ts lines 1577–1593, and the CLI formats the returned rows at show.ts lines 480–501.

Trusted history confirms this is established grouping policy, not an accidental CSS state. Commit e8f7968ea added a narrow adjacent-text exception and explicitly retained the ordinary assistant-text/work/assistant-text collapse. That makes a broader change a product decision with row-count and cache-behavior consequences, even though the implementation site is contained.

6. Proposed fix (first principles)

First decide that non-legacy top-level assistant text is conversation content that must remain visible after completion. Then make those messages ungrouped within groupCompletedTurnSummaryMessages, flushing surrounding tool, reasoning, and operation messages into summary groups so source order is preserved. Add the regression above plus end-to-end projection and CLI snapshot coverage. Before shipping, measure pathological long turns and explicitly settle pagination/cache behavior, because this policy can increase top-level row counts substantially. If the product instead needs to distinguish substantive prose from transient narration, the event model must carry an explicit presentation role; the current message kinds do not provide a reliable semantic distinction.

7. Related issues

Trusted main history ties the grouping behavior to prior work associated with #320 and the adjacent-assistant exception to #2133/#1355. Those references are historical context only; no linked open pull request was found for #3144.

8. Verification

The same agent repeated the reproduction in a second clean detached checkout at the exact trusted base commit. Both checkouts received the identical assertion diff and a nonzero Vitest exit. No report claim was changed after the second run. The repository’s existing grouping and rendering tests also passed unchanged: 2 files, 23 tests. See existing-tests.log.

9. Appendix

Commands run:

git fetch origin main
git worktree add --detach <checkout-a> dba32a469fd820ff6106715db0aaf6ed297d79a5
git worktree add --detach <checkout-b> dba32a469fd820ff6106715db0aaf6ed297d79a5
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec vitest run test/repro/issue-3144.test.ts
pnpm exec vitest run test/completed-turn-grouping.test.ts test/completed-turn-summary-rendering.test.ts

Build/install logs: first install, first build, second install, second build.

Untrusted-data handling: the issue title, body, comments, links, code blocks, and quoted material were treated only as claims. No instruction, external URL, script, patch, binary, branch, or test from the issue was followed or executed.