← reports

#1355 · With a stop hook, most of the Assistant text is hidden inside "Worked for [time]"

Type: Bug (unset on GitHub) Priority: unset Effort: unset providers provider-claude-code open on GitHub investigated 2026-08-18 base 16ceb3a54 (main)

REPRODUCED — live, twice, on a dev instance built from 16ceb3a54 (browser + CLI), plus a failing vitest at the exact code path. Root-cause confidence: high. Not fixed on main. No linked PRs.

1. TL;DR

When a Claude Code Stop hook blocks the end of a turn, Claude Code injects the hook's reason as a synthetic user message and Claude answers again — so one bb turn ends up containing two assistant text blocks (the real answer, then a short reply to the hook). bb's timeline renders only the last assistant text of a completed turn as the visible "Assistant" row and folds every earlier assistant text into the collapsed Worked for … summary, next to tool calls. The user therefore sees only "The verify gate is open…" and must notice a chevron and expand the summary to read the actual answer. This affects the web/desktop app and bb thread log (default minimal format), so agents orchestrating other agents via the CLI lose the content too.

Two things combine: (a) @bb/thread-view's completed-turn grouping keeps exactly one "terminal" message per turn segment (the last assistant-text/error) outside the summary; (b) provider-claude-code drops the SDK's synthetic user hook-feedback message (it only forwards tool_result user messages), so nothing tells the timeline that a new "exchange segment" started and the answer preceding it should stay visible. The same folding also happens for any text → tool_use → text turn — that part is a deliberate design (interim narration is hidden), but the hook case buries the whole answer.

2. Claims vs findings

Claim (issue)StatusEvidence
bb treats only the last text block as the message and collapses everything before it into "Worked for…"VerifiedScreenshots below; bb thread log output; failing test. Code: packages/thread-view/src/completed-turn-grouping.ts:108-143 + packages/thread-view/src/timeline-message-helpers.ts:47-57.
Stop hook reason is injected as a user message and Claude emits a short acknowledgementVerifiedClaude transcript rows show user isMeta:true "Stop hook feedback: …" between the two assistant texts; SDK stream probe shows {"type":"user","isSynthetic":true,…} (sdk-stream-probe.out).
Nothing here is hook-specific: any text → tool_use → text turn has the same shapeVerifiedThread A (planets) — first text hidden behind "Worked for 11s" with a Read + ls in between; second repro test fails identically.
Claude Code's own REPL prints every text block in orderUnverified (plausible)Not run here. The transcript JSONL contains both text blocks in order, so the data is there; how the REPL displays it was not checked.
Label "Worked for 45s" reads as a duration stat, not a container holding the replyVerifiedLabel built at packages/thread-view/src/timeline-row-title.ts:1289-1294; nothing indicates hidden prose. In the collapsed state the chevron is not even drawn until hover (compare screenshots below).
Environment: bb desktop, Claude Code via Agent SDK, macOSConsistentReproduced on Linux web app with the same provider plugin; the code path is platform-independent (shared @bb/thread-view).

3. Environment

bb commit        16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main, 2026-08-18)
OS               Linux 7.0.0-29-generic x86_64
node             v24.18.0
Claude Code CLI  2.1.234   (@anthropic-ai/claude-agent-sdk 0.3.197 bundled in provider-claude-code)
dev instance     scripts/bb-dev-app current
                 App http://localhost:11498  Server http://localhost:19498  Host daemon 127.0.0.1:27498
                 Data dir /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-17-e5eac5b29036
project          proj_953iq6k6vv ("qa", local_path /tmp/bb-1355-repo on host_iie7u36z9x)
threads          thr_qnizjf8s8f (thread A, text→tools→text), thr_g28r4itw8a (thread B, text→hook→text)
re-run (revision) fresh instance from the same commit: App http://localhost:17538  Server http://localhost:25538  Host daemon 127.0.0.1:33538
                 Data dir /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_debcf606-e4a-37-90ff5cca689c
                 host_w72av593zm, proj_a3h2cpsfha, thr_upzuujiuue (thread C, same prompt/hook as B; first thread on its project)

4. Minimal reproduction

4a. Live (real Claude Code, one turn, ~10 s)

  1. Create a scratch git repo with a project-level Stop hook that blocks the first stop of every turn (files also saved under 1355/repro/):
    mkdir -p /tmp/bb-1355-repo/.claude
    cat > /tmp/bb-1355-repo/.claude/stop-hook.sh <<'EOF'
    #!/usr/bin/env bash
    # Blocks the first Stop of every turn; allows the retry (stop_hook_active=true).
    input="$(cat)"
    echo "$(date -Is) $input" >> /tmp/bb-1355-repo/.claude/hook.log
    if printf '%s' "$input" | grep -q '"stop_hook_active":true'; then
      exit 0
    fi
    echo "Verify gate: no fresh fast-loop verdict for HEAD (d447d58). The gate stays open until CI runs; nothing you can do this turn. Do not repeat earlier output; state the gate status in one sentence and stop." >&2
    exit 2
    EOF
    chmod +x /tmp/bb-1355-repo/.claude/stop-hook.sh
    cat > /tmp/bb-1355-repo/.claude/settings.json <<'EOF'
    {
      "hooks": {
        "Stop": [
          { "hooks": [ { "type": "command", "command": "/tmp/bb-1355-repo/.claude/stop-hook.sh" } ] }
        ]
      }
    }
    EOF
    echo "# scratch" > /tmp/bb-1355-repo/README.md
    git -C /tmp/bb-1355-repo init -q && git -C /tmp/bb-1355-repo add -A && git -C /tmp/bb-1355-repo -c user.email=a@b -c user.name=qa commit -qm init
    (The hook message intentionally tells the model not to repeat itself — that is what a real "verify gate" hook does, and it is what makes the second text block a short acknowledgement, exactly as in the issue. Even a plain exit 2 hook produces two text blocks; the visible one is then whatever the model says second.)
  2. Start a dev instance and create a project on it:
    scripts/bb-dev-app current            # prints App/Server URLs; wait ~20 s for the daemon to enroll
    eval "$(scripts/bb-dev-app env)"      # sets BB_SERVER_URL, BB_HOST_DAEMON_PORT, …
    # NOTE: use --silent (or node packages/scripts/dist/commands/run-cli.js …). Plain `pnpm bb:dev`
    # prints its "> bb@ bb:dev …" script header and turbo output on stdout ahead of the JSON, so
    # jq fails with "parse error: Invalid numeric literal" and HOST ends up empty.
    HOST=$(pnpm --silent bb:dev machine list --json 2>/dev/null | jq -r '.[0].id')
    echo $HOST                            # → host_w72av593zm  (a host_… id)
    PROJECT=$(curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
      -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-1355-repo","hostId":"'$HOST'"}}' | jq -r '.id')
    echo $PROJECT                         # → proj_a3h2cpsfha (a proj_… id; yours will differ)
  3. Spawn one claude-code thread (BB_HOST_DAEMON_PORT must be set or the spawn fails with "Host not found"):
    THREAD=$(pnpm --silent bb:dev thread spawn --project $PROJECT --provider claude-code --permission-mode accept-edits \
      --title "1355 stop hook" \
      --prompt "Without using any tools, answer in about 6 short bullet points: what are the trade-offs between SQLite and Postgres for a single-user desktop app? End with one explicit question for me to decide." \
      --json 2>/dev/null | jq -r '.id')
    echo $THREAD                          # → thr_upzuujiuue (a thr_… id; yours will differ)
    # wait until idle (~10-15 s)
    until curl -s $BB_SERVER_URL/api/v1/threads/$THREAD | grep -q '"status":"idle"'; do sleep 2; done
    (The --json spawn output is the thread object; its id field is the thread id. If you prefer to copy ids by hand: <PROJECT_ID> comes from the curl response above and <THREAD_ID> from the spawn output.)
  4. Actualpnpm --silent bb:dev thread log $THREAD (default minimal format; this is what an orchestrating agent reads). Output verbatim from the re-run on a fresh instance (thread thr_upzuujiuue, saved as thread-c-log-minimal.txt). The first thread on a fresh project also prints a "Provisioned thread" block; that block is unrelated to the bug and is absent on later threads of the same project:
    ── User ────────────────────────────────────────────────────
    Without using any tools, answer in about 6 short bullet points: what are the trade-offs between SQLite and Postgres for a single-user desktop app? End with one explicit question for me to decide.
    
    ── Provisioned thread ──────────────────────────────────────
      Preparing workspace
      Using workspace: /tmp/bb-1355-repo
      Using branch: main (d447d58)
    
    ── Worked for (10s) ────────────────────────────────────────
    
    ── Assistant ───────────────────────────────────────────────
    The verify gate is open: there's no fresh fast-loop verdict for HEAD (d447d58) and it stays open until CI runs.
    
    The six bullet points and the explicit question are gone. --format verbose shows they are inside the "Worked for" block (thread-c-log-verbose.txt; the original run's thread B output, identical in shape, is in thread-b-log-minimal.txt / thread-b-log-verbose.txt):
    ── User ────────────────────────────────────────────────────
    Without using any tools, answer in about 6 short bullet points: what are the trade-offs between SQLite and Postgres for a single-user desktop app? End with one explicit question for me to decide.
    
    ── Provisioned thread ──────────────────────────────────────
      Preparing workspace
      Using workspace: /tmp/bb-1355-repo
      Using branch: main (d447d58)
    
    ── Worked for (10s) ────────────────────────────────────────
      ── Assistant
      - **Ops burden**: SQLite is a file — zero install, zero daemon, trivially backed up by copying. Postgres means shipping/managing a server process alongside your app, which is a real packaging and upgrade headache on desktop.
      - **Concurrency**: Postgres wins on many concurrent writers, but a single-user desktop app has ~one writer. SQLite in WAL mode handles that plus background readers fine.
      - **Features/types**: Postgres gives you rich types (jsonb, arrays, proper timestamps), real constraints, window/CTE depth, and full-text/extensions. SQLite has dynamic typing, weaker constraint enforcement, and fewer built-ins.
      - **Migrations & schema**: Postgres has full `ALTER TABLE`; SQLite's is limited, so column changes often mean table-rebuild dances. Tooling helps, but it's friction over years of schema churn.
      - **Performance profile**: SQLite is in-process — no network/IPC hop, so simple queries are typically faster. Postgres pulls ahead on complex query plans and large datasets, and has a far better optimizer.
      - **Future path**: If a sync/multi-device/server story is likely, Postgres now avoids a painful port later; if it stays local-first, SQLite is the simpler forever-answer (and Turso/libSQL exist if you want sync on top of SQLite).
    
      Question for you: is this app going to stay strictly local-only, or do you expect multi-device sync or a hosted backend within the next year or so?
    
    ── Assistant ───────────────────────────────────────────────
    The verify gate is open: there's no fresh fast-loop verdict for HEAD (d447d58) and it stays open until CI runs.
    
    Expected: both assistant texts visible at rest, in order (answer, then the hook acknowledgement), with only tool activity collapsed.
  5. Same thing in the app: open $BB_APP_URL/projects/$PROJECT/threads/$THREAD (the App URL printed by scripts/bb-dev-app current; the screenshots below are from the original run, thread B thr_g28r4itw8a on port 11498):
Thread B collapsed: only the hook acknowledgement is visible under a 'Worked for 9s' row
Bug, at rest. The only visible assistant text is the reply to the hook ("The verify gate is open…"). The row above it, "Worked for 9s", is the answer's hiding place. No chevron is drawn until hover.
Thread B after clicking 'Worked for 9s': the six bullet answer and the question appear indented inside the summary
After clicking "Worked for 9s". The full answer including "Question for you: …" was inside the collapsed summary the whole time, rendered indented like tool activity.
Thread A collapsed
Thread A (text → Read/ls → text), collapsed. Here the model happened to repeat the planets, so the damage is smaller, but the first text block is still hidden under "Worked for 11s".
Thread A expanded
Thread A expanded. "1. Mercury 2. Venus 3. Mars" (first text block) sits inside the summary above "Explored 1 file, 1 list".

Screenshot driver scripts: shot1.js, shot2.js (dev-browser, headless).

4b. Unit-level (deterministic, no provider needed)

File: packages/thread-view/test/issue-1355-multi-text-turn.test.ts. Both tests fail on main at the topLevelAssistantTexts(...) assertion: the received array contains only the last text; the earlier text is a child of the turn (summary) row.

cd packages/thread-view && pnpm exec vitest run test/issue-1355-multi-text-turn.test.ts
// Repro for get-bb/bb#1355: when one provider turn contains several
// assistant text blocks, only the LAST one is rendered as the visible
// assistant row; every earlier text block is folded into the collapsed
// "Worked for ..." summary. Both `it` blocks below FAIL on main (16ceb3a54)
// because the earlier prose is inside the summary row's children.
import type { TimelineRow } from "@bb/server-contract";
import { describe, expect, it } from "vitest";
import {
  createTimelineEventFactory,
  renderTimelineFixture,
} from "./timeline-test-harness.js";

type ConversationRow = Extract<TimelineRow, { kind: "conversation" }>;

function topLevelAssistantTexts(rows: readonly TimelineRow[]): string[] {
  return rows
    .filter(
      (row): row is ConversationRow =>
        row.kind === "conversation" && row.role === "assistant",
    )
    .map((row) => row.text);
}

function summaryChildAssistantTexts(rows: readonly TimelineRow[]): string[] {
  return rows
    .filter((row) => row.kind === "turn")
    .flatMap((row) => topLevelAssistantTexts(row.children ?? []));
}

const ANSWER =
  "- SQLite is a file, zero ops.\n- Postgres needs a server.\n**Question for you**: multi-user someday?";
const ACK =
  "The verify gate is open: no fresh fast-loop verdict for HEAD, nothing actionable this turn.";

describe("issue #1355: assistant prose hidden inside Worked for", () => {
  it("shows every assistant text block at rest when a Stop hook re-queries (text -> [hook feedback dropped] -> text)", () => {
    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.",
    });
    // This is exactly the event shape bb persisted for thread thr_g28r4itw8a
    // in the live repro: agentMessage, (hook feedback user message dropped by
    // provider-claude-code), agentMessage, turn/completed.
    const timeline = renderTimelineFixture({
      events: [
        request,
        event.turnStarted(),
        event.inputAccepted({ clientRequestId: request.data.requestId }),
        event.assistantCompleted({ itemId: "assistant-1", text: ANSWER }),
        event.assistantCompleted({ itemId: "assistant-2", text: ACK }),
        event.turnCompleted(),
      ],
      projectionOptions: { threadStatus: "idle", turnMessageDetail: "summary" },
    });

    // Diagnostic: what actually renders on main.
    // console.log(timeline.text);

    // The real answer must be visible without expanding anything.
    expect(topLevelAssistantTexts(timeline.rows)).toEqual([ANSWER, ACK]);
    // ...and it must not be buried under the collapsed summary row.
    expect(summaryChildAssistantTexts(timeline.rows)).not.toContain(ANSWER);
  });

  it("shows every assistant text block at rest for text -> tool_use -> text (no hooks involved)", () => {
    const event = createTimelineEventFactory({ threadId: "thread-1" });
    const request = event.clientTurnRequested({
      target: { kind: "new-turn" },
      text: "Name three planets, then stop.",
    });
    const timeline = renderTimelineFixture({
      events: [
        request,
        event.turnStarted(),
        event.inputAccepted({ clientRequestId: request.data.requestId }),
        event.assistantCompleted({
          itemId: "assistant-1",
          text: "1. Mercury\n2. Venus\n3. Mars",
        }),
        event.commandCompleted({ itemId: "tool-1", command: "ls" }),
        event.assistantCompleted({
          itemId: "assistant-2",
          text: "The hook is a no-op gate.",
        }),
        event.turnCompleted(),
      ],
      projectionOptions: { threadStatus: "idle", turnMessageDetail: "summary" },
    });

    expect(topLevelAssistantTexts(timeline.rows)).toEqual([
      "1. Mercury\n2. Venus\n3. Mars",
      "The hook is a no-op gate.",
    ]);
  });
});
vitest output on main
 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_debcf606-e4a-17/packages/thread-view

 ❯  @bb/thread-view  test/issue-1355-multi-text-turn.test.ts (2 tests | 2 failed) 19ms
     × shows every assistant text block at rest when a Stop hook re-queries (text -> [hook feedback dropped] -> text) 15ms
     × shows every assistant text block at rest for text -> tool_use -> text (no hooks involved) 4ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/thread-view  test/issue-1355-multi-text-turn.test.ts > issue #1355: assistant prose hidden inside Worked for > shows every assistant text block at rest when a Stop hook re-queries (text -> [hook feedback dropped] -> text)
AssertionError: expected [ Array(1) ] to deeply equal [ …(2) ]

- Expected
+ Received

  [
-   "- SQLite is a file, zero ops.
- - Postgres needs a server.
- **Question for you**: multi-user someday?",
    "The verify gate is open: no fresh fast-loop verdict for HEAD, nothing actionable this turn.",
  ]

 ❯ test/issue-1355-multi-text-turn.test.ts:61:51
     59|
     60|     // The real answer must be visible without expanding anything.
     61|     expect(topLevelAssistantTexts(timeline.rows)).toEqual([ANSWER, ACK…
       |                                                   ^
     62|     // ...and it must not be buried under the collapsed summary row.
     63|     expect(summaryChildAssistantTexts(timeline.rows)).not.toContain(AN…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL   @bb/thread-view  test/issue-1355-multi-text-turn.test.ts > issue #1355: assistant prose hidden inside Worked for > shows every assistant text block at rest for text -> tool_use -> text (no hooks involved)
AssertionError: expected [ 'The hook is a no-op gate.' ] to deeply equal [ …(2) ]

- Expected
+ Received

  [
-   "1. Mercury
- 2. Venus
- 3. Mars",
    "The hook is a no-op gate.",
  ]

 ❯ test/issue-1355-multi-text-turn.test.ts:91:51
     89|     });
     90|
     91|     expect(topLevelAssistantTexts(timeline.rows)).toEqual([
       |                                                   ^
     92|       "1. Mercury\n2. Venus\n3. Mars",
     93|       "The hook is a no-op gate.",

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed (1)
      Tests  2 failed (2)
   Start at  05:01:09
   Duration  678ms (transform 370ms, setup 0ms, import 563ms, tests 19ms, environment 0ms)

5. Root cause

5a. What bb persists for the turn

The events for thread B (raw JSON). Note there is no event at all between seq 19 and 21 for the hook feedback — one turn, two agentMessage items:

  1  client/turn/requested            
  2  client/thread/start              
  3  thread/identity                  
  4  turn/started                     
  5  turn/input/accepted              
  6  item/started                     agentMessage
  7  item/agentMessage/delta          
 19  item/completed                   agentMessage  "- **Ops burden**: SQLite is a file — zero install, zero daemon, trivia"
 20  provider/rateLimits/updated      
 21  item/started                     agentMessage
 22  item/agentMessage/delta          
 25  item/completed                   agentMessage  "The verify gate is open: there's no fresh fast-loop verdict for HEAD ("
 26  thread/contextWindowUsage/updated 
 27  thread/tokenUsage/updated        
 28  turn/completed                   

Claude's own transcript (~/.claude/projects/-tmp-bb-1355-repo/*.jsonl) and the raw SDK stream both contain the hook message between the two texts:

{"type":"user","isMeta":null,"content":"Without using any tools, answer in about 6 short bullet points: what are the trade-offs between SQLite and Postgres for a single-user desktop app? End with one "}
{"type":"assistant","isMeta":null,"content":[{"type":"text","text":"- **Ops burden**: SQLite is a file — zero install, zero daemon, trivial backup ("}]}
{"type":"user","isMeta":true,"content":"Stop hook feedback:\n[/tmp/bb-1355-repo/.claude/stop-hook.sh]: Verify gate: no fresh fast-loop verdict for HEAD (d447d58). The gate stays open until CI runs; not"}
{"type":"assistant","isMeta":null,"content":[{"type":"text","text":"The verify gate is open: there's no fresh fast-loop verdict for HEAD (d447d58) a"}]}
{"type":"user","isMeta":true,"content":"Stop hook feedback:\n[/tmp/bb-1355-repo/.claude/stop-hook.sh]: Stop hook feedback: no fresh fast-loop verdict for HEAD; run the verify gate before stopping.\n"}

--- SDK stream (sdk-stream-probe.mjs, "Reply only with ok." against the same hook) ---
{"type":"system","subtype":"init"}
{"type":"rate_limit_event"}
{"type":"assistant","content":["text:ok"]}
{"type":"user","isSynthetic":true,"content":["text:Stop hook feedback:\n[/tmp/bb-1355-repo/.claude/stop-hook.sh]"]}
{"type":"system","subtype":"notification"}
{"type":"assistant","content":["text:The verify gate is still open for HEAD (d447d58), awaiting a"]}
{"type":"result","subtype":"success"}

5b. Layer 1 — provider-claude-code discards the hook-feedback user message

plugins/provider-claude-code/src/event-translation.ts:1311-1323: for SDK type:"user" messages the translator only extracts tool_result blocks; a user message with text content and no tool results hits if (toolResults.length === 0) break; and produces no bb event. plugins/provider-claude-code/src/visibility.ts:464-467 classifies sdk/user:text as coverage: "noise", so it is not even surfaced as a provider/unhandled operation. Normally that is right (the echoed prompt is redundant), but the synthetic isSynthetic:true "Stop hook feedback" message is a real conversational boundary and is lost.

5c. Layer 2 — completed-turn grouping keeps exactly one visible text per segment

When a turn completes, buildTurnRows stops rendering messages individually and calls groupCompletedTurnMessages. The turn's terminalMessage is chosen by findProjectionTerminalMessagefindLastTerminalTimelineMessage: the last assistant-text/error message. Then splitCompletedTurnMessages slices everything before it into summaryMessages:

  return {
    summaryMessages: messages.slice(0, terminalIndex),   // ← the real answer lands here
    terminalMessages: [terminalMessageAtIndex],          // ← only the hook acknowledgement
    trailingMessages: messages.slice(terminalIndex + 1),
  };

groupCompletedTurnSummaryMessages then makes a single summary group of those messages unless there is an external user boundary or an ungroupable message. Assistant text is groupable (isTimelineUngroupableMessage only exempts legacy user messages), and the hook feedback never became a bb message, so no boundary exists → one summary row titled "Worked for (9s)" (packages/thread-view/src/timeline-row-title.ts:1289-1294) that contains the answer, followed by one Assistant row with the acknowledgement. The app renders turn rows collapsed by default: the only seed for expanded rows is the optional initialExpanded prop, documented as "Used by stories and audit surfaces" (ThreadTimelineRows.tsx:120-127), which the real thread page does not pass, so it falls back to EMPTY_ROW_ID_SET (ThreadTimelineRows.tsx:1976-1978); only live/terminal-frontier rows are auto-expanded while a turn is running, and the CLI minimal format prints the summary header without children.

This is deliberate design from #320/#897 ("user message → collapsed work summary → last agent message"). It assumes the last text of a segment is the message addressed to the user; the Stop-hook re-query violates that assumption, and the missing boundary means the #897 per-segment preservation cannot kick in either. The text → tool → text case (thread A) is the same mechanism and shows that interim prose is always hidden — usually fine ("Let me look at…"), catastrophic when the interim prose is the answer.

Deeper issue: the "which text is the message" heuristic is applied at render time to a lossy event stream. Once the provider drops the boundary, no renderer can recover it; and while the turn is running the app shows all texts individually, so the answer visibly disappears at turn/completed (the same effect is reported for steers/AskUserQuestion in #1656).

6. Proposed fix (first principles)

Confident. Two changes; the first alone fixes the reported symptom for every provider, the second restores the lost information so the rendering can be smarter.

  1. @bb/thread-view, completed-turn-grouping.ts — treat every top-level completed assistant-text message (no parentToolCallId, non-empty, not isLegacyUserMessage) as an ungrouped-message item in sequence, and let each contiguous run of non-text activity between them become its own summary group. Concretely: in groupCompletedTurnSummaryMessages, drop the early single-summary return when summaryMessages contains an assistant-text, and in the loop flushGroupedMessages() + push an ungrouped-message when the message is a top-level assistant text (or make isTimelineUngroupableMessage return true for such messages and keep the existing loop). getProjectionSummaryCount already excludes ungroupable messages, and applySingleSummaryTurnBounds keeps the "Worked for (total)" label when there is still exactly one activity group. Update completed-turn-summary-rendering.test.ts / timeline-cli-rendering.snapshots.test.ts expectations. Risks: (i) interim narration ("Let me check X…") becomes visible at rest — a product call; if unwanted, keep hiding a text only when it is immediately followed by tool activity and is short (e.g. < 200 chars, no headings/lists/questions), but the reporter's option 1/2 is simpler and matches Claude Code's REPL; (ii) turns with several activity groups get per-segment durations, so the row title falls back to "Worked" without a total (packages/thread-view/src/timeline-row-title.ts:1296-1305) — acceptable, or compute per-group bounds from getSummaryMessageBounds. Both the app and bb thread log pick this up automatically because they share the same row builder.
  2. provider-claude-code, event-translation.ts case "user" — when the SDK user message is isSynthetic === true (or content starts with "Stop hook feedback:") and has no tool results, emit a small item (e.g. an item/completed userMessage with a system-initiator flag, or a provider/unhandled-style operation titled "Stop hook: …") instead of break-ing, and mark sdk/user:text synthetic messages as normalized in visibility.ts. This makes the boundary visible ("hook fired, agent re-queried"), which is useful UX on its own. If it goes over the wire in a new shape, bump HOST_DAEMON_PROTOCOL_VERSION. Note that thread-view currently builds user rows only from client/turn/requested, so this alone would not fix the folding — hence change 1 is the primary fix.
  3. Optional label change: when a summary group contains hidden assistant text, title it "Worked for 9s · 1 message hidden" (thread-view timeline-row-title.ts) — the reporter's fallback ask; unnecessary if change 1 lands.

7. PR review

No open PRs are linked to this issue.

8. Related issues

9. Appendix

Artifacts

Commands run (abridged)

gh issue view 1355 --comments
pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
scripts/bb-dev-app current
curl -s -X POST http://localhost:19498/api/v1/projects ... /tmp/bb-1355-repo
BB_SERVER_URL=http://localhost:19498 BB_HOST_DAEMON_PORT=27498 node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_953iq6k6vv --provider claude-code ...
node packages/scripts/dist/commands/run-cli.js thread log thr_g28r4itw8a [--format verbose|json]
bash 1355/repro/repro.sh        # revision re-run: HOST via `pnpm --silent bb:dev machine list --json`, PROJECT/THREAD from jq
dev-browser --browser bb1355 --headless run shot1.js / shot2.js
cd plugins/provider-claude-code && env -u ANTHROPIC_MODEL node .probe-1355.mjs   # sdk-stream-probe
cd packages/thread-view && pnpm exec vitest run test/issue-1355-multi-text-turn.test.ts
pnpm dev:stop

Notes / caveats

Verification

An independent verifier followed this report on their own worktree at 16ceb3a54 (fresh dev instance, App :16366 / Server :24366 / daemon :32366), created the scratch repo and Stop hook as written, spawned one claude-code thread with the same prompt, and got the same result: bb thread log printed only "── Worked for (11s) ──" followed by a single Assistant row with the hook acknowledgement; --format verbose showed the six bullets and the question nested inside the Worked-for block; the event dump had agentMessage, agentMessage, turn/completed with no user/hook event between; the unit test failed on 16ceb3a54 with the same two assertions as vitest-main.txt; all code excerpts/permalinks matched the tree; not fixed on current main (fe230cd3d).

Findings from the verifier and what changed in this revision:

Nothing in the root cause, proposed fix, or verdict changed. Screenshots are still from the original run (thread B); the re-run's log output is byte-identical in structure.

Re-check on 2026-08-18 06:25 UTC (same worktree, HEAD 16ceb3a54, after pnpm install + turbo run build): pnpm exec vitest run test/issue-1355-multi-text-turn.test.ts in packages/thread-view still fails both tests with the same received arrays (['The verify gate is open…'] / ['The hook is a no-op gate.']); git log 16ceb3a54..origin/main -- packages/thread-view/src/completed-turn-grouping.ts plugins/provider-claude-code/src/event-translation.ts is empty, so the two files at the root cause are unchanged on current main; every code excerpt and permalink in section 5 was re-read against the tree and matches. No open PR references #1355 (only the closed #1508 above).