← reports

#2224 · Claude Code provider: sdk/system background_tasks_changed falls into assertNever and surfaces as provider/unhandled debug rows

Bug Priority: Low Effort: Low providers provider-claude-code open on GitHub 2026-08-24 · base 494f66526

Verdict: REPRODUCED (symptom and mechanism confirmed live; the issue's "throws in assertNever" claim is wrong) · Root-cause confidence: high

1. TL;DR

When a Claude Code thread starts or finishes a background task (a Bash call with run_in_background, a backgrounded subagent, a kill), the Claude Code CLI (2.1.241 on this machine; absent from the 2.1.197 CLI bundled with the pinned @anthropic-ai/claude-agent-sdk@0.3.197) emits a system message with subtype background_tasks_changed carrying the full set of live background tasks. bb's Claude provider plugin has a hand-maintained allow-list of system subtypes in plugins/provider-claude-code/src/visibility.ts; anything not on it is collapsed to "unknown" and described with coverage "unknown". The delta translator turns every coverage-unknown SDK message it did not otherwise consume into an unhandled delta, which the runtime persists as a provider/unhandled thread event. Two such rows were persisted for a single sleep 2 background task (one when it started, one when it finished) and the UI renders each as an "Unhandled Claude Code event" row whenever debug rows are enabled (always in dev; behind the Show unhandled provider events setting in the packaged app). The fix is a one-line classification: add the subtype to the known list as noise (the task_started/task_updated/task_notification bookends already drive bb's backgroundTask items). Contrary to the issue text, nothing throws: the assertNever default is unreachable because the parser maps unknown subtypes to "unknown" first.

2. Claims vs findings

Claim from the issueStatusEvidence
Claude Code sessions emit system/background_tasks_changed whenever the set of backgrounded tasks changesVerifiedLive session with CLI 2.1.241 emitted two such messages for one backgrounded sleep 2 (tasks=[…] then tasks=[]); see provider→bridge.ndjson seq 43 and 64. The CLI's own emitter fires on every change of the live-task id list (binary excerpt).
bb has no case for it in its sdk/system subtype classificationVerifiedClaudeSystemSubtype and toClaudeSystemSubtype in visibility.ts L43–68 / L212–244 do not list it. git grep on base and origin/main finds the string only in plans/narrow-grammar-protocol.md.
…so it falls into the assertNever default branch and the default branch throws Unhandled Claude visibility value: background_tasks_changedRefutedtoClaudeSystemSubtype returns "unknown" for any unlisted subtype (L241–243); describeParsedClaudeRawEvent handles case "unknown" explicitly (L526–527) and returns { kind: "sdk/system", coverage: "unknown" }. The assertNever at L529 is unreachable. No throw in the live run, no error in dev.log; the unit repro prints coverage: "unknown", not an exception. The code at tag desktop-v0.39.0 is identical in this respect.
…and the event is emitted as provider/unhandled with rawType: "sdk/system"VerifiedPersisted rows seq 12 and 21 in events-thr_e99xj3nin5.json: {"type":"provider/unhandled","rawType":"sdk/system","rawEvent":{…"subtype":"background_tasks_changed"…}}. Timeline API and UI show "Unhandled Claude Code event".
In the installed 0.39.0 bundle the classifier handles compact_boundary, status, model_fallback*, task_*, permission_denied, plus a noise list (init, commands_changed, hook_*, …)Verifiedgit show desktop-v0.39.0:plugins/provider-claude-code/src/visibility.ts has the same lists as base (L475–530).
Log noise on every background-task change, multiple times per sessionVerifiedTwo rows per task lifecycle (start + settle). The maintainers' own parity table already noted "+2 benign provider/unhandled background_tasks_changed" (plans/narrow-grammar-protocol.md:21).
Any future unmapped SDK subtype takes the same (throwing) pathPartially verifiedSame path, but it is the coverage-unknown → provider/unhandled path, not a throw. CLI 2.1.241 already carries more unlisted subtypes (feedback_draft_queued, task_summary, post_turn_summary, control_request_progress, code_change_published, vcs_state_changed, worker_shutting_down, informational); none were emitted in this tiny session, so their behaviour is inferred from code, not observed.
Suggested fix: classify as noise or fold into the task mappingVerified viableApplied the 7-line noise classification (proposed-fix.diff): repro test passes, 80 existing tests in visibility/delta-translation/task-translation suites pass, plugin typecheck passes.

3. Environment

4. Minimal reproduction

4a. Unit-level (no account needed, ~1 s)

  1. Save the test below as plugins/provider-claude-code/src/issue-2224-background-tasks-changed.test.ts (copy in 2224/repro/). The payload is the CLI's own schema for the message.
  2. Run it from the plugin directory:
    cd plugins/provider-claude-code
    pnpm exec vitest run src/issue-2224-background-tasks-changed.test.ts
  3. Expected: both tests pass (the subtype is classified and nothing is persisted). Actual on 494f66526 (full output):
    × is classified (not coverage=unknown) by the visibility metadata
      AssertionError: expected 'unknown' not to be 'unknown'
    × does not surface as a provider/unhandled thread event
      AssertionError: expected [ Array(1) ] to deeply equal []
      + {
      +   "providerId": "claude-code",
      +   "rawType": "sdk/system",
      +   "rawEvent": { "jsonrpc": "2.0", "method": "sdk/message", "params": { "message": {
      +       "type": "system", "subtype": "background_tasks_changed",
      +       "tasks": [ { "task_id": "b3f1c2", "task_type": "local_bash", "description": "sleep 3" } ],
      +       "uuid": "message-1", "session_id": "session-1" }, "threadId": "bb-thread-1" } },
      +   "scope": { "kind": "thread" },
      +   "type": "provider/unhandled",
      + }
    Test Files  1 failed (1)   Tests  2 failed (2)
    Note that rawType is the bare "sdk/system": the subtype is discarded by the parser before the description is built, so the debug row's title cannot even say which subtype it was.
/**
 * Repro for get-bb/bb#2224: Claude Code (CLI >= ~2.1.2xx) emits a
 * `system` message with subtype `background_tasks_changed` every time the
 * set of live background tasks changes. bb's classifier does not know the
 * subtype, so it is described with coverage "unknown" and the delta
 * translator surfaces it as a `provider/unhandled` thread event.
 *
 * The payload shape below is the CLI's own zod schema, extracted from the
 * claude 2.1.241 binary:
 *   { type: "system", subtype: "background_tasks_changed",
 *     tasks: [{ task_id, task_type, description }], uuid, session_id }
 *
 * Both tests FAIL on 494f66526 (the bug) and should pass once the subtype is
 * classified (as noise, or folded into the task translation).
 */
import { describe, expect, it } from "vitest";
import { claudeCodeVisibilityMetadata } from "./visibility.js";
import { createClaudeDeltaHarness } from "./delta-test-harness.js";

const backgroundTasksChanged = {
  type: "system",
  subtype: "background_tasks_changed",
  tasks: [
    {
      task_id: "b3f1c2",
      task_type: "local_bash",
      description: "sleep 3",
    },
  ],
  uuid: "message-1",
  session_id: "session-1",
};

describe("issue #2224: sdk/system background_tasks_changed", () => {
  it("is classified (not coverage=unknown) by the visibility metadata", () => {
    const description = claudeCodeVisibilityMetadata.describeRawEvent({
      jsonrpc: "2.0",
      method: "sdk/message",
      params: { threadId: "thread-1", message: backgroundTasksChanged },
    });
    // Bug: on 494f66526 this is { kind: "sdk/system", coverage: "unknown" }.
    // Note the subtype is even lost from `kind` because toClaudeSystemSubtype
    // collapses every unlisted subtype to "unknown".
    expect(description.coverage).not.toBe("unknown");
  });

  it("does not surface as a provider/unhandled thread event", () => {
    const harness = createClaudeDeltaHarness();
    const events = harness.translate({
      jsonrpc: "2.0",
      method: "sdk/message",
      params: { threadId: "bb-thread-1", message: backgroundTasksChanged },
    });
    // Bug: on 494f66526 this yields one provider/unhandled with
    // rawType "sdk/system" and the full raw payload attached.
    expect(events.filter((e) => e.type === "provider/unhandled")).toEqual([]);
  });
});

4b. Live, end to end (needs a Claude Code login; one tiny turn)

  1. Start an isolated dev instance with bridge recording on, from the bb checkout:
    mkdir -p /tmp/bb-2224-rec
    BB_PROVIDER_BRIDGE_RECORD_DIR=/tmp/bb-2224-rec scripts/bb-dev-app current
    eval "$(scripts/bb-dev-app env)"       # exports BB_SERVER_URL etc.
  2. Create a scratch repo and a project pointing at it (host id from pnpm bb:dev machine list):
    mkdir -p /tmp/bb-2224-qa && cd /tmp/bb-2224-qa && git init -q && echo "# qa" > README.md && git add . && git commit -qm init
    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-2224-qa","hostId":"host_6bxgnaw8fe"}}'
    # → {"id":"proj_zfkugf8iv7", ...}
  3. Spawn a Claude Code thread that backgrounds one command:
    pnpm bb:dev thread spawn --project proj_zfkugf8iv7 --provider claude-code --permission-mode full \
      --title "issue-2224 repro" \
      --prompt "Use the Bash tool with run_in_background set to true to run the command: sleep 2. After it has been started, reply only with ok." --json
    # → {"id":"thr_e99xj3nin5", "status":"starting", ...}
    Wait until GET $BB_SERVER_URL/api/v1/threads/thr_e99xj3nin5 reports "status":"idle" (~10 s).
  4. Look at what the CLI sent and what bb persisted:
    grep -h background_tasks_changed /tmp/bb-2224-rec/claude-code/thr_e99xj3nin5/provider→bridge.ndjson
    sqlite3 -json "$(scripts/bb-dev-app status | sed -n 's/^Data dir: //p')/bb.db" \
      "SELECT sequence, scope_kind, data FROM events WHERE thread_id='thr_e99xj3nin5' AND type='provider/unhandled'"
    Expected: no provider/unhandled rows. Actual (verbatim, trimmed):
    {"ts":1787596311268,"seq":43,"dir":"provider→bridge","line":"{\"type\":\"system\",\"subtype\":\"background_tasks_changed\",\"tasks\":[{\"task_id\":\"bemunthqx\",\"task_type\":\"local_bash\",\"description\":\"Sleep 2 seconds in background\"}],\"uuid\":\"b5ad017a-…\",\"session_id\":\"2b02a856-…\"}"}
    {"ts":1787596313413,"seq":64,"dir":"provider→bridge","line":"{\"type\":\"system\",\"subtype\":\"background_tasks_changed\",\"tasks\":[],\"uuid\":\"d93d03ea-…\",\"session_id\":\"2b02a856-…\"}"}
    
    [{"sequence":12,"scope_kind":"turn",  "data":"{\"providerId\":\"claude-code\",\"rawType\":\"sdk/system\",\"rawEvent\":{…\"subtype\":\"background_tasks_changed\",\"tasks\":[{\"task_id\":\"bemunthqx\",…}]…}}"},
     {"sequence":21,"scope_kind":"thread","data":"{\"providerId\":\"claude-code\",\"rawType\":\"sdk/system\",\"rawEvent\":{…\"subtype\":\"background_tasks_changed\",\"tasks\":[]…}}"}]
    Full event list: events-thr_e99xj3nin5.json; bridge-side translation: bridge→runtime.ndjson seq 44 and 66 ({"kind":"unhandled","rawType":"sdk/system",…}).
  5. GET /api/v1/threads/thr_e99xj3nin5/timeline (saved) contains a system row operationKind: "provider-unhandled", title Unhandled Claude Code event, detail starting SDK System / Raw event: sdk/system / Payload: {…"subtype": "background_tasks_changed"…}. In the browser:
bb thread timeline showing an 'Unhandled Claude Code event' row between the agent's 'ok' reply and the background-task completion message
Thread thr_e99xj3nin5 in the dev app right after the turn. Look at the grey "Unhandled Claude Code event" row between "ok" and "The background sleep 2 completed": that is the thread-scoped background_tasks_changed (tasks=[]) row. The first (turn-scoped, seq 12) one is folded inside the collapsed "Worked for 1s" turn. Dev builds always show these rows; packaged builds show them when Settings → "Show unhandled provider events" is on, and persist them either way.

Repro files: 2224/repro/ (test, vitest output before/after fix, fix diff, redacted wire recording, persisted events, timeline JSON, CLI binary evidence, doobie scripts).

5. Root cause

Mechanism. The provider plugin classifies every raw SDK message through claudeCodeVisibilityMetadata in visibility.ts#L212-L244. toClaudeSystemSubtype is a closed allow-list; any subtype it does not name becomes the literal "unknown":

function toClaudeSystemSubtype(subtype: string | undefined): ClaudeSystemSubtype {
  switch (subtype) {
    case "commands_changed":
    case "compact_boundary":
    …
    case "thinking_tokens":
      return subtype;
    default:
      return "unknown";          // ← background_tasks_changed lands here
  }
}

describeParsedClaudeRawEvent then maps that to coverage "unknown" (visibility.ts#L475-L530):

    case "sdk/system":
      switch (event.subtype) {
        …
        case "unknown":
          return { kind: "sdk/system", coverage: "unknown" };   // ← reached
        default:
          return assertNever(event.subtype);                    // ← unreachable
      }

On the translation side, translate() in delta-translation.ts#L1369-L1392 dispatches type: "system" to translateSystemMessage, which tries the api_retry, status, compact_boundary, model_fallback, permission_denied and task schemas in turn and returns [] when none match (#L874-L884). An empty result sends the envelope to unhandledDeltas (#L711-L728), whose only gate is the visibility coverage:

  /** Visibility classification: only unknown coverage becomes an `unhandled`. */
  function unhandledDeltas(rawEvent, parentRef) {
    const description = claudeCodeVisibilityMetadata.describeRawEvent(rawEvent);
    if (description.coverage !== "unknown") {
      return [];
    }
    return [{ kind: "unhandled", raw: toRawEvent(rawEvent), rawType: description.kind, vouchedTurn: true, … }];
  }

The shared assembler turns the unhandled delta into a provider/unhandled thread event (delta-assembler.ts#L2265-L2280), the server persists it, and parse-operation-message.ts projects it as the "Unhandled <provider> event" system row when includeProviderUnhandledOperations is true (parse-operation-message.ts#L475-L488; the gate is config.isDevelopment || appSettings.showUnhandledProviderEvents in routes/threads/data.ts#L333-L335).

Why the CLI sends it. Claude Code added a level signal for background tasks: the CLI's schema describes it as "The full set of live background tasks, emitted whenever membership changes (start, completion, kill, a foreground agent being backgrounded). A level signal, unlike the task_started/task_notification bookends", with "REPLACE semantics". It fires from the app-state change handler whenever the list of live task ids differs (binary excerpt), hence exactly one message per task start and one per task settle. It is present in CLI 2.1.241 and absent from 2.1.197 (the SDK-bundled CLI at bb's pinned @anthropic-ai/claude-agent-sdk@0.3.197), so bb's SDK typings never told anyone about it; bb prefers the user's PATH claude (session-options.ts#L291-L324), so the newer CLI's messages reach a classifier written against the older one.

Why the symptom follows. bb already builds backgroundTask items from task_started/task_updated/task_notification (seq 13 and 23 in the event list), so the level signal carries no information bb needs; but because its coverage is "unknown" rather than "noise", each one is persisted as a debug event and rendered as a row. This is purely cosmetic/log noise: no turn state, item state, or task state is affected.

Deeper issue. The allow-list is the only defence and it drifts every time the CLI adds a subtype. CLI 2.1.241 already carries feedback_draft_queued, task_summary, post_turn_summary, control_request_progress, code_change_published, vcs_state_changed, worker_shutting_down and informational, none of which bb lists; each will produce the same rows when the CLI emits it. A second, smaller defect: toClaudeSystemSubtype throws the subtype away, so the persisted rawType is the bare "sdk/system" and the UI title cannot distinguish one unknown subtype from another (the issue author had to read the payload to find it).

6. Proposed fix (first principles)

Classify background_tasks_changed as noise in visibility.ts — three one-line additions (type union, parser case, noise case). Verified diff: proposed-fix.diff.

--- a/plugins/provider-claude-code/src/visibility.ts
+++ b/plugins/provider-claude-code/src/visibility.ts
@@ type ClaudeSystemSubtype =
+  | "background_tasks_changed"
   | "commands_changed"
@@ function toClaudeSystemSubtype(
   switch (subtype) {
+    case "background_tasks_changed":
     case "commands_changed":
@@ describeParsedClaudeRawEvent … case "sdk/system":
         // so they never surface as provider/unhandled debug rows.
+        // background_tasks_changed is the CLI's level signal (the full live
+        // background-task set, REPLACE semantics); the task_started /
+        // task_updated / task_notification bookends already drive the
+        // backgroundTask items, so it is noise here.
+        case "background_tasks_changed":
         case "commands_changed":

With this applied the repro test passes and the existing visibility, delta-translation and task-translation suites (80 tests) pass; pnpm exec turbo run typecheck --filter=bb-plugin-provider-claude-code passes (vitest, typecheck). Keep the repro test (or fold its two cases into visibility.test.ts / delta-translation.test.ts) so the classification is guarded. No wire shape between server and host daemon changes, so no HOST_DAEMON_PROTOCOL_VERSION bump is needed; the plugin ships inside the host-side bundle.

Optional, worth doing in the same change: (a) also add the other subtypes already present in CLI 2.1.241 listed above as noise, so the next CLI update does not reopen this issue; (b) preserve the raw subtype in the "unknown" description (e.g. kind: "sdk/system:" + rawSubtype) so a future unhandled row at least names what it is. (c) Not recommended now: folding the level signal into task-translation.ts to reconcile tasks bb believes are open but the CLI no longer lists. It could settle stale items when a task_notification is lost, but it would need care around ordering (the level message can arrive before the task_started bookend or after turn/completed, as seq 12 vs 13 and 21 vs 23 show) and would change persisted task semantics — a separate, tested change rather than this noise fix.

What could go wrong: nothing observable — "noise" only suppresses the debug row; the task items keep coming from the bookends. If someone later relies on the debug row to detect background activity, that was never the intended surface.

7. PR review

No open pull request is linked to this issue.

8. Related issues

9. Appendix

Commands run (in order)

gh api repos/get-bb/bb/issues/2224 ; gh api repos/get-bb/bb/issues/2224/comments   # 0 comments
git checkout -q 494f66526 ; pnpm install --frozen-lockfile --prefer-offline ; pnpm exec turbo run build
git fetch origin main ; git log --oneline 494f66526..origin/main -- plugins/provider-claude-code   # empty
git fetch origin tag desktop-v0.39.0 ; git show desktop-v0.39.0:plugins/provider-claude-code/src/visibility.ts | grep -n 'unknown\|assertNever'
grep -n "subtype: '" plugins/provider-claude-code/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts   # no background_tasks_changed
strings ~/.local/share/claude/versions/2.1.241 | grep -c background_tasks_changed   # 20
strings node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-darwin-arm64@0.3.197/…/claude | grep -c background_tasks_changed   # 0
python3 (byte-offset context extraction of the 2.1.241 binary) → 2224/repro/claude-2.1.241-binary-evidence.txt
cd plugins/provider-claude-code && pnpm exec vitest run src/issue-2224-background-tasks-changed.test.ts   # 2 failed (bug)
BB_PROVIDER_BRIDGE_RECORD_DIR=/tmp/bb-reports-2224-rec scripts/bb-dev-app current
curl -s -X POST http://localhost:19776/api/v1/projects … (local_path /tmp/bb-reports-2224-qa, host_6bxgnaw8fe)
BB_SERVER_URL=http://localhost:19776 BB_HOST_DAEMON_PORT=27776 pnpm bb:dev thread spawn --project proj_zfkugf8iv7 --provider claude-code --permission-mode full --prompt "…sleep 2…" --json
bash 2224/repro/poll-thread.sh http://localhost:19776 thr_e99xj3nin5   # idle
sqlite3 -json <data-dir>/bb.db "SELECT … FROM events WHERE thread_id='thr_e99xj3nin5'" → 2224/repro/events-thr_e99xj3nin5.json
curl -s http://localhost:19776/api/v1/threads/thr_e99xj3nin5/timeline → 2224/repro/timeline-thr_e99xj3nin5.json
node scripts/provider-recordings/redact.mjs /tmp/bb-reports-2224-rec 2224/repro/recording-redacted
doobie --headless < 2224/repro/doobie-thread2.js   # screenshot assets/2224-thread-timeline.png
python3 (apply the 3-site edit to visibility.ts) ; git diff > 2224/repro/proposed-fix.diff
pnpm exec vitest run src/issue-2224-… src/visibility.test.ts src/delta-translation.test.ts src/task-translation.test.ts   # 80 passed
pnpm exec turbo run typecheck --filter=bb-plugin-provider-claude-code   # ok
git checkout -- plugins/provider-claude-code/src/visibility.ts
pnpm dev:stop ; data dir and /tmp scratch dirs deleted ; ports verified free

Persisted event sequence for the repro thread (type · scope)

 7 thread/identity · thread
 8 turn/started · turn
 9 turn/input/accepted · turn
10 item/started commandExecution "sleep 2" · turn
11 provider/rateLimits/updated · thread
12 provider/unhandled rawType=sdk/system subtype=background_tasks_changed tasks=[bemunthqx] · turn   ← bug
13 item/started backgroundTask familyId=bemunthqx taskType=local_bash · turn
14 item/completed commandExecution · turn
15-17 agentMessage "ok" · turn
18-19 context/token usage · turn
20 turn/completed · turn
21 provider/unhandled rawType=sdk/system subtype=background_tasks_changed tasks=[] · thread   ← bug
23 item/backgroundTask/completed familyId=bemunthqx · thread
24-31 second (auto-continued) turn: "The background sleep 2 completed (exit code 0)."

Timeline API row (dev instance, includeProviderUnhandledOperations=true)

systemKind: "operation", operationKind: "provider-unhandled", title: "Unhandled Claude Code event"
detail:
SDK System
Raw event: sdk/system
Payload:
{ "jsonrpc": "2.0", "method": "sdk/message", "params": { "message": {
    "type": "system", "subtype": "background_tasks_changed", "tasks": [],
    "uuid": "d93d03ea-c661-4b27-9ee0-eed35c851758", "session_id": "2b02a856-aa2e-4590-beff-41ff98607146" },
  "threadId": "thr_e99xj3nin5" } }

Notes on the issue text

The quoted error string Unhandled Claude visibility value: background_tasks_changed is the message of the assertNever helper, but that helper is never called for system subtypes: the reporter appears to have read the code rather than observed a throw. Had it actually thrown, the whole translate() call would have failed rather than producing a tidy provider/unhandled row. The symptom described (rows in the log) is exactly what the non-throwing path produces.