Record Pi extension messages as provider input - #2154
Merged
Merged
Conversation
SawyerHood
marked this pull request as ready for review
August 21, 2026 06:02
SawyerHood
force-pushed
the
bb/fix-1681-pi-notification-wake
branch
from
August 21, 2026 16:51
ce123f3 to
3158939
Compare
SawyerHood
added a commit
that referenced
this pull request
Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the source session, so the model held the whole conversation, but nothing in the fork path wrote that conversation into the fork's events, and the timeline is a projection of a thread's own events. The same path ignored `sourceSeqEnd` for the model: it only looked up which provider session was current at that sequence and never mapped it to the `providerCheckpointId` the bridge records on `turn/completed`, so pi, claude-code, and codex always cloned the tip regardless of the requested anchor. The server now resolves a fork point: the completed root turn that contains `sourceSeqEnd` (or the last one before it), its recorded checkpoint, and the sequence of its `turn/completed`. `thread.start.fork` carries `sourceProviderCheckpointId`, the runtime forwards it on `thread/fork`, and the bridge branches the session there. A visible fork then inherits the source's conversation rows through that same sequence before its own thread-start rows, so its timeline and its model describe the same history. Hidden forks (side chats) still clone the session but keep their own timeline empty. Anchors inside a running turn, before the first turn, or earlier than the tip on a tip-only provider fail with `fork_source_session_unavailable` instead of silently forking the tip. A tip fork of a source that is mid-turn cloned a session that already held the running turn's prompt while the inherited timeline stopped at the last completed turn. Providers that can branch at a checkpoint now clone through that completed turn so model context and timeline match; tip-only providers keep the tip clone. Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which takes 151). Fixes #1500 Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
marked this pull request as draft
August 21, 2026 17:05
SawyerHood
force-pushed
the
bb/fix-1681-pi-notification-wake
branch
from
August 21, 2026 17:26
3158939 to
bdf4738
Compare
SawyerHood
added a commit
that referenced
this pull request
Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the source session, so the model held the whole conversation, but nothing in the fork path wrote that conversation into the fork's events, and the timeline is a projection of a thread's own events. The same path ignored `sourceSeqEnd` for the model: it only looked up which provider session was current at that sequence and never mapped it to the `providerCheckpointId` the bridge records on `turn/completed`, so pi, claude-code, and codex always cloned the tip regardless of the requested anchor. The server now resolves a fork point: the completed root turn that contains `sourceSeqEnd` (or the last one before it), its recorded checkpoint, and the sequence of its `turn/completed`. `thread.start.fork` carries `sourceProviderCheckpointId`, the runtime forwards it on `thread/fork`, and the bridge branches the session there. A visible fork then inherits the source's conversation rows through that same sequence before its own thread-start rows, so its timeline and its model describe the same history. Hidden forks (side chats) still clone the session but keep their own timeline empty. Anchors inside a running turn, before the first turn, or earlier than the tip on a tip-only provider fail with `fork_source_session_unavailable` instead of silently forking the tip. A tip fork of a source that is mid-turn cloned a session that already held the running turn's prompt while the inherited timeline stopped at the last completed turn. Providers that can branch at a checkpoint now clone through that completed turn so model context and timeline match; tip-only providers keep the tip clone. Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which takes 151). Fixes #1500 Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
marked this pull request as ready for review
August 21, 2026 17:48
A Pi extension can inject a custom message and trigger a turn on its
own (`pi.sendMessage(..., { triggerTurn: true })`, which is how
pi-processes wakes a thread when a background command finishes). bb
translated the message's `message_start`/`message_end` boundaries as
`provider/unhandled` and never recorded the message itself, so the
extension-triggered turn showed an assistant answer with no input in
front of it, plus "Unhandled Pi event" rows where unhandled events are
visible.
Add an `input.provider` delta to the bridge grammar for input the
provider injected without a bb client request. The Pi translator emits
it for a displayed `role: "custom"` `message_start`; the assembler
records it as the `userMessage` item of the open turn and drops it when
no turn is open (idle context notes Pi appends without running the
agent). Custom boundaries are noise for the visibility fallback, so
`message_end` and hidden messages no longer surface as unhandled.
thread-view projects the item as a system-initiated accepted steer of
its turn, so the app, `bb thread log`, and the turn summary show what
the model was answering. It is a steer rather than a message on
purpose: the server pages the timeline on `message` rows backed by
stored `client/turn/requested` events, and a `message` row without one
made the latest page silently drop every earlier turn.
Bump HOST_DAEMON_PROTOCOL_VERSION: the daemon now emits a userMessage
item it never sent before.
Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
force-pushed
the
bb/fix-1681-pi-notification-wake
branch
from
August 21, 2026 22:06
bdf4738 to
9e632c3
Compare
SawyerHood
added a commit
that referenced
this pull request
Aug 21, 2026
A forked thread came up with an empty timeline. The provider cloned the source session, so the model held the whole conversation, but nothing in the fork path wrote that conversation into the fork's events, and the timeline is a projection of a thread's own events. The same path ignored `sourceSeqEnd` for the model: it only looked up which provider session was current at that sequence and never mapped it to the `providerCheckpointId` the bridge records on `turn/completed`, so pi, claude-code, and codex always cloned the tip regardless of the requested anchor. The server now resolves a fork point: the completed root turn that contains `sourceSeqEnd` (or the last one before it), its recorded checkpoint, and the sequence of its `turn/completed`. `thread.start.fork` carries `sourceProviderCheckpointId`, the runtime forwards it on `thread/fork`, and the bridge branches the session there. A visible fork then inherits the source's conversation rows through that same sequence before its own thread-start rows, so its timeline and its model describe the same history. Hidden forks (side chats) still clone the session but keep their own timeline empty. Anchors inside a running turn, before the first turn, or earlier than the tip on a tip-only provider fail with `fork_source_session_unavailable` instead of silently forking the tip. A tip fork of a source that is mid-turn cloned a session that already held the running turn's prompt while the inherited timeline stopped at the last completed turn. Providers that can branch at a checkpoint now clone through that completed turn so model context and timeline match; tip-only providers keep the tip clone. Bumps HOST_DAEMON_PROTOCOL_VERSION to 152 (stacked on #2154, which takes 151). Fixes #1500 Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
added a commit
that referenced
this pull request
Aug 21, 2026
## What was wrong A forked thread came up with an empty timeline (#1500, report: https://get-bb.github.io/reports/issues/1500.html). The provider cloned the source session, so the model held the whole conversation, but nothing in the fork path (`thread-fork.ts` -> `thread-create.ts` `resolveForkDescriptor` -> `thread.start{fork}`) ever wrote that conversation into the fork's events, and the timeline is a projection of a thread's own events. The same path also ignored `sourceSeqEnd` for the model: it only looked up which provider session was current at that sequence (`getProviderThreadIdAtOrBeforeSequence`) and never mapped it to the `providerCheckpointId` recorded on that turn's `turn/completed`. `thread.start.fork` had no checkpoint field, so pi, claude-code, and codex always cloned the session tip regardless of the requested anchor, and the app's per-message Fork button and `bb thread fork --source-seq-end` promised a branch point they did not deliver. ## What changed - `apps/server/src/services/threads/thread-fork-history.ts` (new): resolves a fork point. With `sourceSeqEnd` the anchor is the completed root turn that contains the sequence (or the last one before it, so a user-message row anchors before its own turn, like editing it). The point carries the session to clone, that turn's checkpoint (`resolveTurnProviderCheckpointId`, shared with edit-message including the legacy codex turn-id fallback), and the sequence of its `turn/completed`. It rejects with 400 `fork_source_session_unavailable` when the anchor is before the first turn, inside a turn that has not completed, has no checkpoint, or is earlier than the latest turn on a provider without `supportsSessionRewind` (tip-only, e.g. ACP) instead of silently forking the tip. - Tip forks (no `sourceSeqEnd`) inherit every completed root turn. When the source is idle the fork clones the session tip. When the source is mid-turn, its session tip already holds the running turn's prompt and partial output while the inherited timeline stops at the last completed turn, so a checkpoint-capable provider now clones through that last completed turn's checkpoint instead; tip-only providers keep the tip clone. This addresses the reviewer's point that model context and timeline diverged in that window. - `thread-create.ts`: a visible fork copies the source's conversation rows (`client/turn/requested` with its acceptance, `turn/started`, `turn/input/accepted`, `item/completed`, `item/backgroundTask/completed`, `turn/completed`, `thread/compacted`, `system/manager/user_message`) through that sequence into the fork before its own thread-start rows, so inherited history takes the lowest sequences. Only turns that completed inside the window come along. Copied rows keep their payload and `created_at`, take fresh ids and sequences, index search segments, and carry a null `provider_thread_id` column so the fork's live session stays the one its own `thread/identity` names. Hidden forks (side chats, plugin workers rendered by their owner next to the source) still clone the session but keep their own timeline empty; this also keeps the side-chat plugin's empty-fork sweep from seeing inherited user messages as user work. - Side-chat behavior change (plugin code untouched): the plugin passes the anchored message's `sourceSeqEnd` to `threads.fork`. On main that anchor only chose a provider session id, so pi/claude-code/codex side chats always cloned the tip. Now a side chat anchored on an earlier completed turn gets a checkpoint fork whose model context ends at that turn, which is what the plugin's own comments describe. An anchor inside a running turn, or an earlier turn on a tip-only (ACP) provider, returns `fork_source_session_unavailable`, which the plugin already handles by falling back to a tip fork (`plugins/side-chat/server.ts`, covered by its existing tests). - `packages/db/src/data/events.ts`: `copyStoredThreadEventsInTransaction`, `findLastRootStoredTurnStarted`, `findLastCompletedRootStoredTurn`; removes the now-unused `getStoredProviderThreadIdAtOrBeforeSequence`. The row copy reads the selected source rows into memory once (one bounded typed query per inherited type) because each copy is re-parsed to index search segments; the in-memory filter only drops the few rows of turns or queued requests still open at the window's end. Large source threads pay that memory and duplicate storage per fork. - Wire: `thread.start.fork.sourceProviderCheckpointId?: string` in `@bb/host-daemon-contract` (absent = tip, matching the bridge protocol's `thread/fork`), forwarded by `packages/agent-runtime/src/runtime.ts` to the adapter's `thread/fork`. **`HOST_DAEMON_PROTOCOL_VERSION` 152 -> 153** (146 -> 147, then 151 -> 152, before two rebases onto a moving main). An older daemon would strip the field and clone the tip. - `thread-edit-message.ts`: uses the shared `resolveTurnProviderCheckpointId` instead of its inline codex fallback (no behavior change). - CI guard (provider-literal ratchet): `resolveTurnProviderCheckpointId` stays in `thread-edit-message.ts` as an export and `thread-fork-history.ts` imports it from there, instead of moving it into `thread-events.ts`, which would have given that file a new `"codex"` literal; `scripts/provider-literal-baseline.json` is regenerated for the resulting decrease (`thread-edit-message.ts` 2 -> 1, total 148 -> 147). - Docs/CLI: `--source-seq-end` help text in `bb thread fork` / `bb thread spawn`, `bb-guide-threads.md`, the bb-cli SKILL, `docs/provider-bridge-protocol.md`, and the `forkThreadRequestSchema.sourceSeqEnd` doc now describe the turn-anchored semantics. Deviation from the issue: the issue also floats an `events.append` / "adopt session" SDK API (#1028). Not done here; copying at fork time covers the fork case with no new plugin surface. The choice to skip history for hidden forks is a server policy decision; an explicit request knob can be added later if a hidden fork ever needs inherited history. Revision after review: `copyForkSourceHistory` was declared `: number` with a bare `return;`, which failed `@bb/server` typecheck and the CI Checks job; it returns `void` now (its caller never read the count). The mid-turn tip fork behavior above is new in this revision. Rebased onto current main (resolved against #2140's dead-code removal in `@bb/db` and `thread-create.ts`). ## How you verified New tests in `apps/server/test/public/public-thread-fork.test.ts` ("fork branch point and inherited history": checkpoint + inherited conversation for an anchor on an assistant message, a user-message anchor branching before its turn, idle tip fork inheriting every completed turn, mid-turn tip fork branching at the last completed turn's checkpoint with nothing of the running turn, a mid-turn tip fork on a tip-only provider cloning the tip, hidden fork inheriting nothing, rejections for running-turn/pre-first-turn anchors, tip-only provider accepting only its latest turn) plus checkpoint assertions in `thread-create-seed-without-run.test.ts` and `public-thread-fork.test.ts`'s existing fork-point test. Fail before (the 14 non-test source files checked out from `origin/main`, new module removed, tests from this branch): 10 failed, e.g. ``` AssertionError: expected { Object (sourceProviderThreadId) } to deeply equal { …(2) } AssertionError: expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ] AssertionError: expected 201 to be 400 // Object.is equality ``` Pass after, on the rebased head: `pnpm exec turbo run typecheck` for server, agent-runtime, host-daemon-contract, db, host-daemon, sdk, cli, server-contract, templates green (12/12 tasks). `pnpm exec turbo run lint` green. `pnpm exec turbo run test --filter=@bb/server`: 1829 passed, 2 failed, both unrelated (the known local umask-only `internal-skill-trees` 0644-vs-0664 assertion, and a 5s timeout in `plugin-update.test.ts` under parallel load that passes alone, 27/27). `@bb/host-daemon` 551, `@bb/agent-runtime` 418, `@bb/cli` 453, `@bb/host-daemon-contract` 52, `@bb/server-contract` 58, `@bb/db` 405, `@bb/templates` 41, `bb-plugin-side-chat` 29, all passed. Manual repro on an own dev instance with the pi provider on the rebased head (two cheap turns "ok" / "second"; `turn/completed` at seq 15 recorded checkpoint `32261d5c`): - `bb thread fork <src> --workspace reuse` -> `bb thread log` shows all four inherited messages. - `bb thread fork <src> --workspace reuse --source-seq-end 13` (turn 1's assistant message) -> log shows "Reply only with ok." / "ok" only; the fork's pi session file has `parentSession=<src>` and only turn 1's user and assistant entries (checkpoint inclusive). - `--source-seq-end 3` -> `HTTP 400: Cannot fork at sequence 3: no turn has started at or before it`. - Mid-turn: started a third turn that runs `sleep 40`, forked at the tip while `turn/started`=3 and `turn/completed`=2. The fork's log shows the four completed messages; its pi session file has `parentSession=<src>` and only turns 1-2, while the source's session already held turn 3's prompt and its bash tool call. Fixes #1500 > AGENT GENERATED: by Claude Opus 5 ## Independent verification Verified round 2 at head `96289f260` (origin/main `f6fb434ab` is an ancestor; no rebase needed) in a separate worktree with its own dev instance (ports 13662/21662/29662). Commands: - `git checkout origin/main -- <11 non-test source files>` + remove `thread-fork-history.ts`, then `pnpm exec vitest run test/public/public-thread-fork.test.ts test/threads/thread-create-seed-without-run.test.ts` from `apps/server`: **10 failed / 30 passed**. Failing assertions: `expected { Object (sourceProviderThreadId) } to deeply equal { …(2) }`, `expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ]`, `expected 201 to be 400`, `expected undefined to be 'checkpoint-after-turn-1'`. - `git checkout HEAD -- <same files>`, same command: **40 passed**. - `pnpm exec turbo run typecheck` for `@bb/server`, `agent-runtime`, `host-daemon-contract`, `db`, `host-daemon`, `sdk`, `cli`, `server-contract`, `templates`, `app`: `Tasks: 13 successful, 13 total`. - `pnpm exec turbo run test --force` for `@bb/server`, `db`, `host-daemon-contract`, `agent-runtime`, `server-contract`, `cli`, `templates`, `host-daemon`, `bb-plugin-side-chat`: 14/15 tasks green; `@bb/server` 1830 passed / 1 failed, the failure being the known local umask-only `internal-skill-trees` mode 0644-vs-0664 assertion (unrelated, passes in CI). - CI (`gh pr checks 2142`): all jobs pass (Checks, Package Smoke x2, Tests app-1/2/3, integration, packages, server). Live repro on the fixed branch (pi provider, source thread with two turns "ok"/"second", turn 1 `turn/completed` at seq 15 with checkpoint `9f0ee844`): - `bb thread fork <src> --workspace reuse`: timeline has all four conversation rows; fork events are the 10 inherited rows (null `provider_thread_id` column, payload keeps the source session id) followed by the fork's own `client/turn/requested`, `client/thread/start`, `thread/identity`, provisioning. - `--source-seq-end 13` (turn 1's assistant message): timeline shows "Reply only with ok." / "ok" only; the fork's pi session file has `parentSession=<src>` and only turn 1's two entries. - `--source-seq-end 3`: `HTTP 400: Cannot fork at sequence 3: no turn has started at or before it`. - Mid-turn (turn 3 running `sleep 45`): tip fork inherits turns 1-2 only and its pi session holds turns 1-2 while the source's already holds turn 3's prompt; `sourceSeqEnd: 28` (inside the running turn) returns 400 `fork_source_session_unavailable`. - After turn 3 (with a `commandExecution` tool call) completed, a tip fork renders the inherited tool-call turn as a completed "Worked for" group in both `/timeline` and `bb thread log`; no crash on `item/completed` rows without `item/started`. - Browser (headless Chrome): the seq-13 fork page renders the inherited user/assistant rows plus the "Forked from" chip. Residual risks: wire change needs enrolled daemons on protocol 147 (older daemons would strip the checkpoint and clone the tip); checkpoint forks on claude-code/codex reuse the rewind path's checkpoint ids but were not live-tested here (pi was); side chats anchored on an earlier completed turn now get a checkpoint fork rather than a tip clone; `findLastCompletedRootStoredTurn` accepts an `atOrBeforeSequence` bound no caller passes (minor dead parameter). Hidden forks inherit no history by server policy. > AGENT GENERATED: by Claude Opus 5 ## Stack Layer 2/2 of GitHub stack #2217 (`gh stack`), depends on #2154 (layer 1/2, protocol 151). Base is `bb/fix-1681-pi-notification-wake`; `HOST_DAEMON_PROTOCOL_VERSION` 152. After #2154 merges, GitHub retargets this PR to `main`. Rebased onto #2154's head (itself on main at `75d6fc4d4`): conflicts were `protocol.ts`, `contract.test.ts`, and `docs/provider-bridge-protocol.md` (kept main's rewritten open-work note and this PR's fork note side by side); the two review-round commits are squashed into one. Re-verified on the new base: the fork tests fail with the 14 non-test source files checked out from the base (`9 failed`, e.g. `expected { Object (sourceProviderThreadId) } to deeply equal { …(2) }`, `expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ]`, `expected 201 to be 400`) and pass on this head; `turbo run typecheck test` for host-daemon-contract, host-daemon, agent-runtime, server, db, server-contract, cli and templates is green except the known local-only umask `internal-skill-trees` assertion. > AGENT GENERATED: by Claude Opus 5 ## Independent verification (guards) Re-verified head `d811c206c` (protocol 152, base `bb/fix-1681-pi-notification-wake` at `bdf47388f`, itself on `origin/main` `27d1017fe`) after the CI-guard amendments, in a separate worktree. - Guard fix is mechanical: `git range-diff e522476~1..e522476 origin/bb/fix-1681-pi-notification-wake..HEAD` shows only (a) `resolveTurnProviderCheckpointId` + `CODEX_NATIVE_TURN_ID_PATTERN` moved verbatim from `thread-events.ts` to an export in `thread-edit-message.ts`, (b) `thread-fork-history.ts` importing it from `./thread-edit-message.js`, and (c) `scripts/provider-literal-baseline.json` `thread-edit-message.ts` 2 -> 1, total 148 -> 147. The bottom layer's range-diff shows only the `@get-bb/plugin-sdk` 0.4.11 -> 0.4.12 bump (`packages/plugin-sdk/package.json`, `packages/domain/src/plugin-sdk-version.ts`). Everything else in `git diff e522476 HEAD --stat` is the four unrelated main commits picked up by the rebase. - No import cycle from the move: `thread-create.ts -> thread-fork-history.ts -> thread-edit-message.ts`, and none of `thread-edit-message.ts`'s local imports (`thread-send`, `thread-turn-dispatch`, `thread-lifecycle`, `thread-commands`, `thread-runtime-config`, `thread-data`, `thread-events`) import `thread-create` or `thread-fork*`. - `node scripts/check-provider-literal-ratchet.mjs`: `Provider-literal ratchet OK: 147 references across 40 core files.` - Fail-before: `git checkout origin/bb/fix-1681-pi-notification-wake -- <17 non-test source files>` + `thread-fork-history.ts` removed, then `vitest run test/public/public-thread-fork.test.ts test/threads/thread-create-seed-without-run.test.ts` in `@bb/server`: **10 failed / 30 passed** (`expected [] to deeply equal [ 'Reply only with ok.', 'ok', …(2) ]`, `expected { Object (sourceProviderThreadId) } to deeply equal { …(2) }`, `expected 201 to be 400`, `expected undefined to be 'checkpoint-after-turn-1'`). - Pass-after: `git checkout HEAD -- .`, same command: **40 passed**. - `pnpm exec turbo run typecheck --filter=@bb/server --filter=@bb/host-daemon-contract`: `Tasks: 5 successful, 5 total`; `@bb/host-daemon-contract` `contract.test.ts`: 38 passed. - CI: `gh pr checks 2142` all pass on run `32508211062` (head_sha `d811c206c`): Checks, Package Smoke x2, Tests app-1/2/3, integration, packages, server. `mergeable: MERGEABLE`, `mergeStateStatus: CLEAN`. #2154 (bottom layer) is also fully green and CLEAN on `main`. - Live repro was not re-run for this guard-only pass; the earlier round's pi-provider repro on the same logic stands (the moved helper's body is byte-identical). ## Rebase (2026-08-21, second) Rebased onto #2154's new head `9e632c399`. `main` took protocol **151** (#2242), which pushed #2154 to **152**, so this layer is renumbered **152 -> 153**: its comment block sits above #2154's 152 block and the lockstep assertion in `contract.test.ts` moves to `toBe(153)`. `protocol.ts` was the only conflict; the SDK version files no longer appear in the stack's diff, because both layers now adopt main's unpublished `0.4.13`. Re-verified on the new base: `node scripts/check-provider-literal-ratchet.mjs --base <#2154 head>` -> `OK: 147 references across 40 core files` (the helper stays in `thread-edit-message.ts`). `turbo run typecheck` for cli, server, agent-runtime, db, host-daemon-contract, server-contract, templates: `Tasks: 10 successful, 10 total`. `turbo run test` for the same set: server-contract 7/7 files, host-daemon-contract 3/3, db 28/28, agent-runtime 31/31, cli 48/48, templates 6/6, server 200 passed / 1 skipped with only the known local-only umask failure `internal-skill-trees` (0644 vs 0664 under umask 0002), which passes in CI. Stack #2217 is unchanged: base is still `bb/fix-1681-pi-notification-wake`, and GitHub retargets this PR to `main` when #2154 merges. > AGENT GENERATED: by Claude Opus 5 Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
A Pi extension can inject a custom message and trigger a turn on its own (
pi.sendMessage(..., { triggerTurn: true }); this is how@aliou/pi-processeswakes a thread when a background command finishes). Pi emitsagent_start, thenmessage_start/message_endwithrole: "custom"for that message. bb's Pi translator had no case for custom-role boundaries and its visibility metadata rated the roleunknown, so both envelopes surfaced asprovider/unhandled("Unhandled Pi event" rows in dev builds or with the setting on), and the message itself was never recorded. The extension-triggered turn therefore showed an assistant answer with no input in front of it, in the app and inbb thread log. bb also had no grammar for provider-originated input at all: the narrow-grammarthread/deltahas no delta for it, and nothing in@bb/thread-viewprojected auserMessageitem.The second half of the issue (Pi's
agent_end.messagescarrying string content, which stranded the turn as "Working...") already landed in #1663.Issue: #1681. Report: https://get-bb.github.io/reports/issues/1681.html
PR #1682 attacks the same gap but was written against
event-translation.ts, which #1834 replaced withdelta-translation.ts; it no longer applies to main. This PR implements the equivalent on the narrow-grammar path and keeps the genericrole: "custom"handling the report asked for.What changed
packages/provider-bridge-protocol/src/thread-delta.ts: newinput.providerdelta (text, optionalparentRef) for input the provider injected without a bb client request. Additive, so no bridge protocol version change; the G3 grammar guardrail snapshot (provider-bridge-grammar.v2.snapshot.json) gains the new kind.packages/provider-bridge-protocol/src/assembler/delta-assembler.ts(moved there from@bb/agent-runtimeon main):input.providerrecords anitem/completeduserMessageitem (assembler-minted id) in the open turn; with no turn open it is dropped, because Pi appends idleattention: contextnotes to its own context without running the agent and there is no bb turn to attach them to.packages/agent-runtime/src/pi/delta-translation.ts: parsesmessage_start/message_endforrole: "custom"(anycustomType, string or block-array content). A displayedmessage_startbecomesinput.provider;message_end, hidden messages, and empty text translate to nothing.packages/agent-runtime/src/pi/visibility.ts: custom-role boundaries arenoise, so the silent cases never reach the unhandled fallback.packages/thread-view/src/user-message-parsing.ts,build-event-projection.ts: project theuserMessageitem as a system-initiated accepted steer of its turn. It renders as the existing "System Message" row in the app (inside the turn's "Worked for" group in summary mode) and as aUserrow inbb thread log. It is asteer, not amessage, on purpose: the server pages the timeline onmessagerows backed by storedclient/turn/requestedevents (timelineSegmentAnchorConditionsin@bb/dbvsisTimelineSegmentAnchorRowintimeline-pagination.ts). With amessagerow here the latest page silently dropped every earlier turn and reportedhasOlderRows: false; I hit this live before switching.HOST_DAEMON_PROTOCOL_VERSION151 -> 152 (146 -> 147, then 150 -> 151, before two rebases onto a moving main): the daemon now sends auserMessageitem it never emitted before. The shape already existed in the shared schema, so the bump is for the semantic change and to roll the fix to enrolled daemons.CI guard: this PR no longer carries an
@get-bb/plugin-sdkversion change.thread-delta.tsand the assembler are bundled into the SDK's published provider-bridge entry points, so the npm version guard (check-npm-version-guard.mjs) needs an unpublished version;mainhas since moved the SDK to0.4.13, which npm has not published (npm latest is0.4.12), so this PR adoptsmain's version and the guard passes with no further bump.Not done, deliberately: the issue's "empty assistant output produces an explicit warning". Nothing in bb promises that today and #1682's version never fired on real Pi (it assumed
message_startbeforeagent_start).How you verified
New tests, all fail on
origin/mainsource and pass with the fix:packages/agent-runtime/src/pi/delta-translation.test.tsagent_start->message_start->message_end). On main:AssertionError: expected [ Array(1) ] to deeply equal [ { type: 'item/completed', ... } ]with aprovider/unhandled rawType: "sdk/message_start"received.expected [ Array(1) ] to deeply equal [].packages/thread-view/test/timeline-cli-rendering.snapshots.test.ts"shows provider-injected input as a system-initiated steer of its turn". On main:expected [ { initiator: 'user', ... } ] to deeply equal [ { initiator: 'user', ... }, ...(1) ](no provider row projected).apps/server/test/services/threads/timeline-provider-input.test.ts: latest page keeps the user's first turn,returnedSegmentCount: 1,hasOlderRows: false, provider input nested in turn 2. Fails if the row is projected as amessage(first turn dropped:expected [ ...(2) ] to deeply equal [ 'user:Reply only with ok.', ...(3) ]).Commands, run from the committed tree (
git status --porcelainempty):pnpm exec turbo run typecheck --filter=@bb/provider-bridge-protocol --filter=@bb/agent-runtime --filter=@bb/thread-view --filter=@bb/host-daemon-contract --filter=@bb/host-daemon --filter=@bb/server --filter=@bb/cli --filter=@bb/app --filter=bb-plugin-provider-acp --filter=bb-plugin-provider-codex --filter=bb-plugin-provider-claude-code->Tasks: 15 successful, 15 totalpnpm exec turbo run test --filter=@bb/provider-bridge-protocol --filter=@bb/agent-runtime --filter=@bb/thread-view --filter=@bb/host-daemon-contract --filter=bb-plugin-provider-acp --filter=bb-plugin-provider-codex --filter=bb-plugin-provider-claude-code->Tasks: 11 successful, 11 total(agent-runtime 31 files, thread-view 21 files, protocol 10 files)pnpm exec turbo run test --filter=@bb/server-> 195/196 files pass; the one failure isinternal-skill-trees.test.tsexpecting file mode 0644 on a umask 0002 machine (pre-existing local-only failure, passes in CI).timeline-provider-input.test.tspasses.Manual, on my own dev instance with the report's 30-line stand-in extension (same message shape as pi-processes 0.10.9,
PI_CODING_AGENT_DIRpointing at a trust-listed copy of the Pi agent dir), real Pi session, prompt "Reply only with ok.":No
provider/unhandledevents (main produced two forsdk/message_start/sdk/message_end).bb thread logshows the process event as aUserrow before theok. The app shows the first turn, then "Worked for 1s" containing a "System Message" row with the process event, thenok.Fixes #1681
Independent verification
Verified by a second agent on a fresh checkout (
git fetch origin bb/fix-1681-pi-notification-wake && git checkout -b verify-1681-r1 FETCH_HEAD, headce123f38e;origin/mainis an ancestor, and main is still at protocol 146 so the 147 bump does not collide).Fail-before / pass-after (checked out the
origin/mainversions of the 6 non-test source files, ran the new tests, then restored):packages/agent-runtimevitest run src/pi/delta-translation.test.ts -t "custom message": 3 failed on main. First assertion:AssertionError: expected [ Array(1) ] to deeply equal [ { type: 'item/completed', …(4) } ], received aprovider/unhandledwhoserawEvent.params.message.message.roleis"custom". Third:expected [ Array(1) ] to deeply equal []. All 3 pass on the PR tree.packages/thread-viewvitest run test/timeline-cli-rendering.snapshots.test.ts -t "provider-injected": fails on main withexpected [ { initiator: 'user', …(2) } ] to deeply equal [ { initiator: 'user', …(2) }, …(1) ]; passes on the PR tree.apps/servervitest run test/services/threads/timeline-provider-input.test.ts: fails on main (- "user:<process_event …>"missing from the page); passes on the PR tree. Also re-checked the guard: patchingparseProviderUserMessagetokind: "message"makes it fail with the first turn dropped (expected [ …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]).Turbo, from the committed tree:
turbo run typecheckfor provider-bridge-protocol, agent-runtime, thread-view, host-daemon-contract, host-daemon, server, cli, app, provider-acp, provider-codex, provider-claude-code:Tasks: 15 successful, 15 total.turbo run test --forcefor provider-bridge-protocol (10 files), agent-runtime (31), thread-view (21), host-daemon-contract (3), provider-acp (14), provider-codex (16), provider-claude-code (18):Tasks: 11 successful, 11 total.turbo run test --filter=@bb/server --force: 195/196 files; the one failure isinternal-skill-trees.test.ts(file mode 436 vs 420, local umask 0002; passes in CI).Repro on the fixed branch: own dev instance with
PI_CODING_AGENT_DIRpointing at a trust-listed copy of the Pi agent dir and the report's 30-line stand-in extension, real Pi session (thread spawn --provider pi --permission-mode full --prompt "Reply only with ok.", threadthr_byejnv6paz). Events:16 turn/started t2,17 item/completed userMessage <process_event …>, reasoning,agentMessage "ok",27 turn/completed status=completed, threadstatus=idle, zeroprovider/unhandled(main produced two, forsdk/message_startandsdk/message_end). The app shows the first turn intact, then "Worked for 3s" which expands to a "System Message" row with the process event, thenok.bb thread log --format verboseshows the nestedUserrow with the process event andsteer.CI at verification time: all ubuntu checks green (Checks, Package Smoke, Tests app-1/2/3, integration, packages, server); macOS Package Smoke pending.
Residual risks / notes for the reviewer:
bb thread login its defaultminimalformat prints an empty── Worked for (3s)header with no input row; only--format verboseshows it. The PR body's "bb thread logshows the process event as aUserrow" holds for verbose only. Making provider input ungrouped without turning it into a pagination anchor needs a product decision (a distinct initiator, or teaching the DB anchor query aboutuserMessageitems).attention: contextnotes (no open turn) are dropped, not persisted; image blocks in custom messages are ignored.mergeable=CONFLICTINGand editsevent-translation.ts, which Narrow-grammar provider bridge protocol: bridges emit semantic deltas, the runtime assembles the timeline (v2) #1834 deleted; it cannot land on main.Stack
Layer 1/2 of GitHub stack #2217 (
gh stack), lands first. Basemain,HOST_DAEMON_PROTOCOL_VERSION151. Rebased onto main at75d6fc4d4(protocol 150): the only conflicts wereprotocol.tsandcontract.test.ts; the rebase also regenerated the bridge grammar snapshot for the newinput.providerdelta kind. Re-verified on the new base: the three new test files fail with the six non-test source files checked out fromorigin/main(expected [ Array(1) ] to deeply equal [ { type: 'item/completed', …(4) } ],expected [ 'user:Reply only with ok.', …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]) and pass on this head;turbo run typecheck testfor host-daemon-contract, host-daemon, agent-runtime, provider-bridge-protocol, thread-view and server is green except the known local-only umaskinternal-skill-treesassertion. #2142 (layer 2/2, protocol 152) is stacked on this branch.Independent verification (guards)
Re-verified head
bdf47388f(rebased ontoorigin/main27d1017fe,@get-bb/plugin-sdk0.4.12) against the previously verified head3158939dfon a fresh checkout (verify-2154-g).git range-diff 75d6fc4d4..3158939df origin/main..bdf47388f: the single commit differs only inpackages/domain/src/plugin-sdk-version.ts(0.4.11->0.4.12) andpackages/plugin-sdk/package.json(0.4.11->0.4.12). Every other hunk is identical.origin/mainand npm (npm view @get-bb/plugin-sdk version) are both still at 0.4.11 and main is still at protocol 150, so neither bump collides.node packages/plugin-sdk/scripts/check-npm-version-guard.mjs->PASS — @get-bb/plugin-sdk@0.4.12 is not on npm yet;node scripts/check-provider-literal-ratchet.mjs->OK: 148 references across 40 core files.origin/maincopies of the 9 existing non-test source files and deleted the newdelta-translation.ts, rebuilt, ran, restored;git status --porcelainempty afterwards):agent-runtimedelta-translation.test.ts: cannot load on main (Cannot find module './delta-translation.js'); passes on the PR tree.thread-viewtimeline-cli-rendering.snapshots.test.ts:AssertionError: expected [ { initiator: 'user', …(2) } ] to deeply equal [ { initiator: 'user', …(2) }, …(1) ]on main; 50/50 pass on the PR tree.servertimeline-provider-input.test.ts:AssertionError: expected [ 'user:Reply only with ok.', …(2) ] to deeply equal [ 'user:Reply only with ok.', …(3) ]on main; passes on the PR tree.host-daemon-contractcontract.test.ts:expected 150 to be 151on main; 52/52 pass on the PR tree.turbo run typecheck test --forcefor agent-runtime, thread-view, host-daemon-contract, provider-bridge-protocol, domain, @get-bb/plugin-sdk:Tasks: 17 successful, 17 total(442 + 391 + 52 + 218 + 150 + 127 tests).turbo run typecheck --filter=@bb/server:Tasks: 4 successful, 4 total.bdf47388f(run 32508210319): Checks, Package Smoke (ubuntu + macOS), Tests app-1/2/3, integration, packages, server all pass;mergeable=MERGEABLE,mergeStateStatus=CLEANagainstmain.Rebase (2026-08-21, second)
Rebased onto
mainatd41d1abee. Two collisions, both frommainmoving under the PR:maintook protocol 151 (Prevent competing turns and false Codex session rebuilds #2242, the auto/steer turn-target re-resolution), so this PR's change is renumbered 151 -> 152. Its comment block now sits above main's 151 block, and the lockstep assertion incontract.test.tsmoves totoBe(152). No other file hardcodes the constant; every other consumer reads it symbolically.mainmoved the SDK to0.4.13, soplugin-sdk-version.tsandplugin-sdk/package.jsonresolve to main's values and drop out of this PR's diff.Re-verified on the new base:
check-npm-version-guard.mjs->PASS - @get-bb/plugin-sdk@0.4.13 is not on npm yet.turbo run typecheckfor server, agent-runtime, host-daemon-contract, provider-bridge-protocol, thread-view:Tasks: 8 successful, 8 total.turbo run testfor the same set: host-daemon-contract 3/3 files, provider-bridge-protocol 16/16, thread-view 23/23, agent-runtime 31/31, server 200 passed / 1 skipped with the single known local-only failureinternal-skill-trees(mode 420vs436, i.e. 0644 vs 0664 under this machine's umask 0002); it passes in CI and this PR does not touch skill trees.#2142 (layer 2/2) is renumbered to protocol 153 and rebased on this head.