← reports

#1656 · Replies to mid-turn messages disappear from the timeline when the turn completes

Bug Priority: Medium Effort: n/a (project fields not readable with this token) threads open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: #1657 (REQUEST CHANGES)

TL;DR

Plain-language framing. While an agent turn is running, bb's thread page shows every assistant message as its own row. The moment the turn ends (turn/completed), the timeline is re-rendered in "summary" mode: the turn's work is folded into a collapsible "Worked for …" row and only certain assistant messages stay visible outside it. When a user sends a message to a running thread (a steer, the default mode of bb thread tell and of the composer), the turn is split into segments at that user row, and each segment keeps exactly one assistant message outside the fold: its last one.

The reporter is right about what they see and I reproduced it live and in a unit test: the agent's direct answer to a steer ("Understood - read-only only.") is on screen while the turn runs, then vanishes into the "Worked for 34s" row as soon as the turn completes (screenshots below), because it was not the last assistant message of its segment. Answered AskUserQuestion cards and the message that asked the question fold in the same way, because those messages are neither ungroupable nor segment boundaries. Both are direct consequences of the completed-turn grouping rules in packages/thread-view/src/completed-turn-grouping.ts, deliberately introduced by #897 ("keep each segment's last assistant/error message"). This is a design limitation of the summary view rather than a coding slip, and it is the same mechanism as #1355. The issue's second bullet ("the final segment loses every message except the terminal because the final flush never preserves a terminal") is a misreading: the turn's terminal message is sliced out before grouping, so the last segment behaves exactly like an ordinary unsteered turn.

Linked PR #1657 makes the reply after a user boundary visible, but it also changes the rendering of every ordinary follow-up turn (the first assistant message and the second-to-last assistant message pop out of the fold), contradicting its own description; I verified that on the real event log of my repro thread. It should not merge as is.

Claims vs findings

ClaimStatusEvidence
While a turn runs, every assistant message is rendered individuallyVerifiedbuildTurnRows returns convertMessage for every message when the turn is not completed (build-thread-timeline.ts#L1163-L1176); screenshot "running" below shows the reply and the numbers as separate rows.
Direct reply to a mid-turn steer disappears from the top level on turn/completedVerifiedUnit repro (fails on main, assertion at line 72) and live thread thr_3qxpfum5zs: "Understood - read-only only." visible while running, folded into "Worked for 34s" after completion (screenshots 2 and 3).
Only the segment's last assistant message is preservedVerifiedflushGroupedMessages(true) keeps findLastTerminalTimelineMessage(segment) only (completed-turn-grouping.ts#L188-L213).
"Every assistant message in the final segment except the turn's terminal message vanishes; the final flush never preserves a terminal"Behavior true, diagnosis wrongIntermediate messages of the last segment do fold, but that is identical to an unsteered turn: splitCompletedTurnMessages removes the turn's terminal message before grouping (completed-turn-grouping.ts#L108-L143), so the final flushGroupedMessages() has nothing extra to preserve. Treating this as a separate defect leads PR #1657 to surface the penultimate message (finding 2 below).
Answered AskUserQuestion cards and the report preceding them fold into the summaryVerified (unit level)Scenario B on main: running view shows assistant: Audit complete. Which changes… and work(question); completed view is user, turn-summary(count=6), assistant: Final runbook. (scenarios-main.log). user-question-lifecycle is not in isTimelineUngroupableMessage (timeline-message-helpers.ts#L19-L29). Not exercised with a live provider.
Content is still reachable by expanding the summary rowVerifiedScreenshot 4: expanding "Worked for 34s" reveals "Understood - read-only only." as its first child.
Deterministic; depends only on the reply not being the last message of its segmentVerifiedScenario C (steer answered by a single message after tools) keeps the reply visible; the repro fixture (reply followed by more assistant text) hides it. Same in three live attempts.
Sending a message to a running thread defaults to steerVerifiedbb thread tell --help: --mode <mode> Message mode: steer (default), queue, or auto.

Environment

Minimal reproduction

A. Unit-level (deterministic, no provider needed)

The test builds one provider turn with the shared timeline harness: user prompt → "Starting the audit." → tool → steer → "Understood - read-only only." → tool → "Login works." → "Audit complete. Nothing was changed." → "Final runbook.", renders it once without and once with turn/completed, and asserts the steer reply is a top-level row in both. File: 1656/repro/issue-1656-repro.test.ts (copy to packages/thread-view/test/).

import type { ThreadEventRow } from "@bb/domain";
import { describe, expect, it } from "vitest";
import {
  createTimelineEventFactory,
  renderTimelineFixture,
} from "./timeline-test-harness.js";

// Repro for get-bb/bb#1656: after `turn/completed`, the direct reply to a
// mid-turn steer ("Understood - read-only only.") is collapsed into the
// "Worked for …" summary even though it was rendered as its own row while the
// turn was running.

function topLevel(rows: ReturnType<typeof renderTimelineFixture>["rows"]) {
  return rows.map((row) => {
    if (row.kind === "conversation") return `${row.role}: ${row.text}`;
    if (row.kind === "turn") return `turn-summary(count=${row.summaryCount})`;
    return row.kind;
  });
}

function build(completed: boolean) {
  const event = createTimelineEventFactory({ threadId: "thread-1" });
  const request = event.clientTurnRequested({
    target: { kind: "new-turn" },
    text: "Check my router setup",
  });
  const steer = event.clientTurnRequested({
    target: { kind: "steer", expectedTurnId: "turn-1" },
    source: "tell",
    text: "explore but do not apply changes",
    requestId: "creq_steersteer" as never,
  });
  const events: ThreadEventRow[] = [
    request,
    event.turnStarted(),
    event.inputAccepted({ clientRequestId: request.data.requestId }),
    event.assistantCompleted({ itemId: "a1", text: "Starting the audit." }),
    event.commandCompleted({ itemId: "tool-0", command: "ip route" }),
    steer,
    event.inputAccepted({ clientRequestId: steer.data.requestId }),
    event.assistantCompleted({
      itemId: "a2",
      text: "Understood - read-only only.",
    }),
    event.commandCompleted({ itemId: "tool-1", command: "ssh router" }),
    event.assistantCompleted({ itemId: "a3", text: "Login works." }),
    event.assistantCompleted({
      itemId: "a4",
      text: "Audit complete. Nothing was changed.",
    }),
    event.assistantCompleted({ itemId: "a5", text: "Final runbook." }),
  ];
  if (completed) events.push(event.turnCompleted());
  return renderTimelineFixture({
    events,
    projectionOptions: {
      threadStatus: completed ? "idle" : "active",
      turnMessageDetail: "summary",
    },
  });
}

describe("issue #1656", () => {
  it("shows the same assistant rows before and after turn/completed", () => {
    const running = topLevel(build(false).rows);
    const done = topLevel(build(true).rows);
    console.log("RUNNING:\n  " + running.join("\n  "));
    console.log("COMPLETED:\n  " + done.join("\n  "));
    // The direct reply to the steer was on screen while running…
    expect(running).toContain("assistant: Understood - read-only only.");
    // …and is expected to survive turn completion. On main it does not.
    expect(done).toContain("assistant: Understood - read-only only.");
  });
});
$ cp /tmp/bb-reports/issues/1656/repro/issue-1656-repro.test.ts packages/thread-view/test/
$ cd packages/thread-view && pnpm exec vitest run test/issue-1656-repro.test.ts --disableConsoleIntercept
RUNNING:
  user: Check my router setup
  assistant: Starting the audit.
  work
  user: explore but do not apply changes
  assistant: Understood - read-only only.
  work
  assistant: Login works.
  assistant: Audit complete. Nothing was changed.
  assistant: Final runbook.
COMPLETED:
  user: Check my router setup
  turn-summary(count=1)
  assistant: Starting the audit.
  user: explore but do not apply changes
  turn-summary(count=4)
  assistant: Final runbook.
     × shows the same assistant rows before and after turn/completed 19ms
AssertionError: expected [ 'user: Check my router setup', …(5) ] to include 'assistant: Understood - read-only onl…'
     72|     expect(done).toContain("assistant: Understood - read-only only.");

Expected: the reply to the steer stays a top-level row after completion. Actual: it is one of the four messages inside turn-summary(count=4). Full log: repro-main.log. Note also that segment 0 renders summary then "Starting the audit." although the assistant said that before running the tool: preserved terminals are always emitted after their segment's summary.

B. Live, in the app (claude-code)

  1. scripts/bb-dev-app current, then export BB_SERVER_URL=<Server URL>; unset BB_THREAD_ID/BB_PROJECT_ID if you run inside a bb thread (otherwise tell fails with HTTP 400: Sender thread is invalid).
  2. Create a scratch repo and project: git init /tmp/bb-1656-qa (+ one commit); 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-1656-qa","hostId":"<bb machine list id>"}}'.
  3. Spawn a turn that emits many assistant messages slowly:
    node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_haaz26xrzy --provider claude-code \
      --permission-mode full --title "issue 1656 repro 4" --json --prompt "Count from 1 to 12. For each number: first run the shell command python3 -c 'import time; time.sleep(4)' in the foreground, then send a separate assistant text message containing only the number. If I send you a message while you are counting, acknowledge it in its own assistant text message and then keep counting until 12. After 12, reply with exactly: Final runbook."
  4. About 30 s later, while it is counting, steer it (default mode):
    node packages/scripts/dist/commands/run-cli.js thread tell thr_3qxpfum5zs "Explore only, do not apply changes. Acknowledge with exactly: Understood - read-only only." --mode steer
    Thread thr_3qxpfum5zs steered
  5. Open http://localhost:11579/projects/proj_haaz26xrzy/threads/thr_3qxpfum5zs and watch the reply appear, then watch it disappear when the turn completes.
Running turn right after the steer
1. Turn still running (stop button, "Working…"): the steer row, the agent's reply "Understood - read-only only." and "6" are all separate top-level rows.
Running turn a few seconds later
2. A few seconds later, still running: the reply remains visible above the continued counting (7…10).
Completed turn: reply folded into Worked for 34s
3. The same thread after turn/completed: the turn is now "Worked for 35s / 5 / Steer / Worked for 34s / 12 Final runbook." The reply "Understood - read-only only." is gone from the top level; only the last message of each segment ("5" and "12 / Final runbook.") survived. Look at the gap between the steer bubble and "Worked for 34s".
Expanded Worked for row shows the reply
4. Expanding "Worked for 34s" (expand-summary.js) shows the reply as the first child of the fold, followed by 6…11 and the tool runs. Nothing is lost, it is just hidden.

The CLI shares the row builder, so bb thread log shows the same shape (── Worked for (34s) ── directly after the steer). Event log of the thread: issue-1656-live-events.json; replaying it through the harness (issue-1656-live-replay.test.ts) prints on main:

LIVE COMPLETED:
  user: Count from 1 to 12. For each number: first run the shell com
  turn-summary(count=11)
  assistant: 5
  user: Explore only, do not apply changes. Acknowledge with exactly
  turn-summary(count=12)
  assistant: 12 Final runbook.
  user: Follow-up task. Send these as SEPARATE assistant text messag
  turn-summary(count=2)
  assistant: Middle note. Follow-up done.

(The third block is a later plain follow-up turn used to test PR #1657, see below.)

Root cause

Where the fold is decided. For a completed turn, buildTurnRows calls groupCompletedTurnMessages (build-thread-timeline.ts#L1183-L1198). That function first slices the turn's single terminal message (the last assistant-text/error of the whole turn, computed in applyTurnMessageDetail, apply-turn-message-detail.ts#L107-L110) out of the list (completed-turn-grouping.ts#L108-L143), and passes the rest to groupCompletedTurnSummaryMessages (completed-turn-grouping.ts#L145-L249).

Segments and the one-survivor rule. Inside that function every ungroupable message (a user message that is not an agent/system steer, a legacy user message, a debug raw event; timeline-message-helpers.ts#L19-L29) is emitted as its own row and closes the current segment. When the closer is a human message, the segment is flushed with preserveLastTerminalMessage = true, which pulls exactly one message, findLastTerminalTimelineMessage(segment), out of the summary group; every other assistant message stays inside sourceMessages of the summary item:

function flushGroupedMessages(preserveLastTerminalMessage = false): void {
  …
  // Human follow-ups split one provider turn into multiple visible exchange
  // segments. Keep each segment's last assistant/error message beside the
  // user row instead of burying it inside that segment's collapsed summary.
  const terminalMessage = preserveLastTerminalMessage
    ? findLastTerminalTimelineMessage(sourceMessages)
    : undefined;
  if (!terminalMessage) { appendSummaryGroup(sourceMessages); return; }
  appendSummaryGroup(sourceMessages.filter((message) => message.id !== terminalMessage.id));
  items.push({ kind: "ungrouped-message", message: terminalMessage });
}
…
for (const message of summaryMessages) {
  flushExternalBoundariesBefore(message);
  if (isTimelineUngroupableMessage(message)) {
    flushGroupedMessages(message.kind === "user" && message.initiator === "user");
    items.push({ kind: "ungrouped-message", message });
    continue;
  }
  groupedMessages.push(message);
}
…
flushGroupedMessages();   // last segment: its terminal was already sliced out above

(completed-turn-grouping.ts#L188-L247.) So for the steer segment [Understood, tool, Login works, Audit complete] the survivor is "Audit complete"; "Understood" is grouped. That rule was introduced on purpose by #897 ("Preserve agent messages around timeline steers"), whose description states the intended shape: user message → collapsed work summary → last agent message → next user message. The issue is therefore a limitation of that design, not a regression: the fold rule optimises for "one final answer per exchange" and has no notion of "the reply the user already read".

Why AskUserQuestion folds too. user-question-lifecycle messages are groupable (not in isTimelineUngroupableMessage) and answering a question is not a segment boundary, so the question card and the report before it are ordinary summary members (scenario B: turn-summary(count=6)).

Why the symptom is jarring. The running view uses no grouping at all (buildTurnRows early return), so the transition running → completed can remove rows that were on screen seconds earlier. Same mechanism as #1355 (text → tool → text turns show only the second text).

Deeper issue found while testing. The message that starts a turn is not always inside the turn's message list: for the first (spawn) turn of my live threads the user prompt is a separate projected-message entry (turn messages start with the first assistant text), whereas for a follow-up turn started with bb thread tell on an idle thread the user message is the first element of turn.messages (user(initiator=user), assistant-text, command, assistant-text; live-replay-main.log, "LIVE turn messages"). Any grouping rule keyed on "user input boundary" therefore behaves differently for the first turn and for follow-ups; the fast path at completed-turn-grouping.ts#L149-L164 ("no external boundaries and no ungroupable messages → one summary") is not taken for follow-up turns. This is what trips PR #1657.

Proposed fix (first principles)

The mechanism is clear (confidence high); which rule to adopt is a product decision, so two options, in order of preference:

  1. Make the fold collapse only work, never conversation. In groupCompletedTurnSummaryMessages, emit every top-level assistant-text/error message (not legacy, no parent tool call) as ungrouped-message in source order and let each run of tool/operation activity between them be its own summary group; treat answered user-question-lifecycle messages as ungrouped rows as well. This makes the running and completed views agree, fixes this issue and #1355 with one rule, and removes the segment-boundary special-casing (and the summary-before-terminal ordering artefact). Risks: turns with many short status texts (Codex emits them) get more rows; closed PR #1508 tried this behind a preference and its review flagged a 1500-row / 461 KB timeline page and the 200-row completed-timeline cache limit, so cap it (e.g. still fold assistant texts shorter than N chars that are immediately followed by tool activity, or page). Consumers to update: completed-turn-summary-rendering.test.ts, CLI bb thread log snapshots, app row tests. No server↔daemon wire change (thread-view runs on server and app; no HOST_DAEMON_PROTOCOL_VERSION bump), but the timeline row cache keyed by shape may need invalidation.
  2. Minimal, rule-preserving variant (if the one-answer-per-exchange shape must stay): additionally preserve the first assistant/error message that follows a mid-turn user input (accepted steer, external human boundary, answered question) — i.e. what #1657 intends — but (a) do not arm it for the message that started the turn (it must not fire for the initial user row of a follow-up turn), (b) do not change the final flushGroupedMessages() (the turn terminal is already preserved), and (c) cover with fixtures shaped like real follow-up turns (user message inside turn.messages) and like spawn turns (user message outside). Also make answered questions ungroupable boundaries so the report before them survives.

PR review

#1657 · Keep mid-turn user exchanges visible after a turn completes · verdict: REQUEST CHANGES

What it changes (pr1657.diff, 304 lines, packages/thread-view only): (1) a preserveNextTerminalMessage flag, armed after every user-input boundary (external boundary, user message, answered question), that pops the next assistant/error message out of the fold; (2) the final flush becomes flushGroupedMessages(true); (3) answered user-question-lifecycle messages become ungroupable and boundaries (isTimelineUserInputBoundaryMessage); (4) one existing expectation rewritten and a new test file. Applied cleanly on 16ceb3a54; turbo run test --filter=@bb/thread-view: 24 files / 383 tests pass (log); typecheck passes (log). It does fix the reported symptom: my repro test passes with it (repro-pr1657.log).

Does it address the root cause? Partly. It patches the one-survivor rule with a second survivor rather than reconsidering the rule, and it does so on the wrong premise for point (2) (see claim table: the last segment's terminal is already preserved). It also changes rendering far beyond turns with mid-turn input.

#SeverityWhereFinding
1Highcompleted-turn-grouping.ts lines 234–246 and 264 (PR numbering)Regresses every ordinary follow-up turn. The PR description says "Turns without mid-turn user input keep the existing single-summary collapse", but the initial user message of a tell-started turn is inside turn.messages and is a user-input boundary, so the flag arms and the first assistant message pops out; the final flushGroupedMessages(true) then also pops the penultimate one. Real data (turn 3 of thr_3qxpfum5zs, no steer): main renders user, turn-summary(count=2), assistant: Middle note. Follow-up done.; with the PR: user, assistant: Starting follow-up., turn-summary(count=1), assistant: Middle note. Follow-up done. (main vs PR). Synthetic scenario A (unsteered, 4 assistant texts): main user, turn-summary(count=4), Final runbook.; PR user, Starting the audit., turn-summary(count=2), Audit complete., Final runbook. (main / PR). No test in the PR covers an unsteered turn; the new file only asserts on the steered/questioned fixtures.
2Mediumcompleted-turn-grouping.ts line 264 (flushGroupedMessages(true))Built on the refuted claim. Because the turn terminal is sliced first, preserving "the last terminal of the remaining messages" surfaces the second-to-last assistant message, producing two consecutive answer rows at the end of steered turns ("Audit complete." + "Final runbook.", and "11" + "12 Final runbook." on the live log) while unsteered turns keep one. The rewritten expectation in completed-turn-grouping.test.ts (legacy-user-message case) enshrines this.
3Mediumtimeline-message-helpers.ts lines 28–44Answered questions become ungroupable, which also drops them from summaryCount (isTimelineSummaryCountedMessage) and from the "Worked for" duration bounds; permission-grant-lifecycle, the sibling user interaction, is left grouped, so a turn with a permission prompt and a turn with a question now render differently. Reasonable direction, but it should be a deliberate, symmetric decision with a test on the summary count/duration.
4LowtestsThe steer test asserts not.toContain("Login works.") but does not assert the fate of "Starting the audit." (which the PR pops out — the fixture is follow-up-shaped) nor that an unsteered turn is unchanged; the description's central claim is untested. The behavior split between spawn turns and follow-up turns (user message outside vs inside turn.messages) is not acknowledged.
5InfoNo casts, no boundary crossing, no daemon protocol impact (thread-view is server/app only). Prettier/format not checked.

Tests run: PR applied to worktree; pnpm exec turbo run test --filter=@bb/thread-view --force (383 pass); pnpm exec turbo run typecheck --filter=@bb/thread-view; my repro test (passes with PR); scenario tests A–D and the live-log replay on both main and PR (logs linked above).

Verdict: REQUEST CHANGES. Keep the intent (reply after mid-turn input stays visible; answered questions are boundaries) but arm the flag only for mid-turn boundaries, drop the flushGroupedMessages(true) final flush, add an unsteered-follow-up-turn regression test, and state the intended rule in the PR description; or replace with option 1 above.

Related issues

Appendix

Commands run

gh issue view 1656 --repo get-bb/bb --json title,labels,body,comments
gh pr view 1657; gh pr diff 1657 > /tmp/bb-reports/issues/1656/pr1657.diff
git fetch origin main; git log 16ceb3a54..origin/main --oneline -- packages/thread-view/src/completed-turn-grouping.ts packages/thread-view/src/timeline-message-helpers.ts   # empty
pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build
cp /tmp/bb-reports/issues/1656/repro/*.test.ts packages/thread-view/test/
cd packages/thread-view && pnpm exec vitest run test/issue-1656-repro.test.ts --disableConsoleIntercept        # fails on main
cd packages/thread-view && pnpm exec vitest run test/issue-1656-scenarios.test.ts --disableConsoleIntercept    # scenarios A–D
git apply /tmp/bb-reports/issues/1656/pr1657.diff   # PR on; re-run both; then git stash to go back to main
pnpm exec turbo run test --filter=@bb/thread-view --force; pnpm exec turbo run typecheck --filter=@bb/thread-view
scripts/bb-dev-app current; export BB_SERVER_URL=http://localhost:19579; unset BB_THREAD_ID BB_PROJECT_ID BB_ENVIRONMENT_ID
node packages/scripts/dist/commands/run-cli.js machine list
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-1656-qa","hostId":"host_p8dftbfcbg"}}'
node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_haaz26xrzy --provider claude-code --permission-mode full --title "issue 1656 repro 4" --json --prompt "Count from 1 to 12 …"
node packages/scripts/dist/commands/run-cli.js thread tell thr_3qxpfum5zs "Explore only, do not apply changes. Acknowledge with exactly: Understood - read-only only." --mode steer
dev-browser --browser bb1656 --headless run /tmp/bb-reports/issues/1656/repro/watch-thread.js   # 2 s polling screenshots while running
dev-browser --browser bb1656 --headless run /tmp/bb-reports/issues/1656/repro/shot-thread.js    # completed view
dev-browser --browser bb1656 --headless run /tmp/bb-reports/issues/1656/repro/expand-summary.js # expanded fold
curl -s "$BB_SERVER_URL/api/v1/threads/thr_3qxpfum5zs/events?limit=300" > /tmp/bb-reports/issues/1656/thread-thr_3qxpfum5zs-events.json
node packages/scripts/dist/commands/run-cli.js thread tell thr_3qxpfum5zs "Follow-up task. Send these as SEPARATE assistant text messages …"   # idle → plain follow-up turn
pnpm dev:stop

Scenario outputs on main (scenarios-main.log)

A COMPLETED (unsteered):
  user: Check my router setup
  turn-summary(count=4)
  assistant: Final runbook.
B RUNNING:
  user: Audit the router
  assistant: Let me look.
  work(command)
  assistant: Audit complete. Which changes should I include?
  work(question)
  work(command)
  assistant: Applied all.
  assistant: Final runbook.
B COMPLETED:
  user: Audit the router
  turn-summary(count=6)
  assistant: Final runbook.
C COMPLETED:
  user: Check my router setup
  turn-summary(count=1)
  user: explore but do not apply changes
  turn-summary(count=1)
  assistant: Only reply.
  work(command)
D turn messages: user(initiator=user), assistant-text, command, assistant-text

Same scenarios with PR #1657 applied (scenarios-pr1657.log)

A COMPLETED (unsteered):
  user: Check my router setup
  assistant: Starting the audit.
  turn-summary(count=2)
  assistant: Audit complete.
  assistant: Final runbook.
B COMPLETED:
  user: Audit the router
  assistant: Let me look.
  turn-summary(count=1)
  assistant: Audit complete. Which changes should I include?
  work(question)
  turn-summary(count=1)
  assistant: Applied all.
  assistant: Final runbook.
C COMPLETED:
  user: Check my router setup
  turn-summary(count=1)
  user: explore but do not apply changes
  turn-summary(count=1)
  assistant: Only reply.
  work(command)

Live log replay with PR #1657 (live-replay-pr1657.log)

LIVE COMPLETED:
  user: Count from 1 to 12. For each number: first run the shell com
  turn-summary(count=11)
  assistant: 5
  user: Explore only, do not apply changes. Acknowledge with exactly
  assistant: Understood - read-only only. 6
  turn-summary(count=10)
  assistant: 11
  assistant: 12 Final runbook.
  user: Follow-up task. Send these as SEPARATE assistant text messag
  assistant: Starting follow-up.
  turn-summary(count=1)
  assistant: Middle note. Follow-up done.
LIVE projected-message: user
LIVE turn messages: assistant-text, command, … user(initiator=user), assistant-text, command, … assistant-text
LIVE turn messages: user(initiator=user), assistant-text, command, assistant-text

Event skeleton of the live repro turn (seq / type / turn)

45 item/completed btfadef95e-4-1  agentMessage "5"
47 client/turn/requested          source=tell initiator=user  (the steer)
48 turn/input/accepted btfadef95e-4-1 clientRequestId=creq_kv5t9t6w3y
51 item/completed btfadef95e-4-1  commandExecution python3 -c 'import time; time.sleep(4)'
55 item/completed btfadef95e-4-1  agentMessage "Understood - read-only only.\n\n6"
59 … 94                            alternating commandExecution / agentMessage "7" … "11"
98 item/completed btfadef95e-4-1  agentMessage "12\n\nFinal runbook."
101 turn/completed btfadef95e-4-1 status=completed

Other artifacts