From 8a9ebf451931ec64a1b2c5da30749b1d22fe979a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 18:50:24 -0700 Subject: [PATCH 1/4] fix(claude): consume undeclared background_tasks_changed silently The SDK stream emits this roster snapshot but its subtype is absent from the typed union, so it fell through to the unknown-subtype warning path and rendered as a spurious error row ('no displayable text content') in client work logs. The task_* lifecycle events carry the authoritative per-agent data; the typed background_tasks control request remains the reconciliation source. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3e883fa1cf5..a0df1307c0a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1705,6 +1705,24 @@ describe("ClaudeAdapterLive", () => { ); assert.equal(progressEvent.payload.description, "Running background teammate"); } + + // The undeclared background_tasks_changed roster snapshot is consumed + // silently — it must not surface as an unknown-subtype warning row. + harness.query.emit({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "workflow-1", task_type: "local_workflow", description: "Run checks" }], + session_id: "session", + uuid: "roster", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + const rosterWarnings = runtimeEvents.filter( + (event) => + event.type === "runtime.warning" && + typeof event.payload.message === "string" && + event.payload.message.includes("background_tasks_changed"), + ); + assert.equal(rosterWarnings.length, 0); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5ccd011ef09..93a34f9c732 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }; + // Undeclared-but-real subtypes (absent from the SDK's union, so they can't + // be switch cases): consumed intentionally without emitting, otherwise + // they fall through to the unknown-subtype warning and surface as spurious + // error rows in client work logs. `background_tasks_changed` is a roster + // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the + // authoritative per-agent data and the typed background_tasks control + // request is the reconciliation source. + if ((message.subtype as string) === "background_tasks_changed") { + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ From 45251ac4f748df929f719589bfb3ea6eed3ed74a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 19:00:07 -0700 Subject: [PATCH 2/4] feat(claude): full SDK message coverage with type-level exhaustiveness guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log-driven audit of real captured traffic (verification runs + wire probes) found api_retry storms rendering as 10 warning rows per 502 and several union subtypes falling into the unknown-subtype warning path. - api_retry → session.state.changed heartbeat (terminal error path already reports the real failure) - session_state_changed → authoritative session.state.changed mapping - notification → warning row only for high/immediate priority - model_refusal_fallback, local_command_output, plugin_install, commands_changed, memory_recall, elicitation_complete, prompt_suggestion → deliberately consumed, no spurious rows - default branches now narrow to never: a new SDK message type or subtype fails typecheck instead of silently warning at runtime; the runtime fallback still catches undeclared wire-only subtypes Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 74 ++++++++++++++++--- .../src/provider/Layers/ClaudeAdapter.ts | 73 +++++++++++++++++- 2 files changed, 133 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a0df1307c0a..75b99614a95 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1705,24 +1705,78 @@ describe("ClaudeAdapterLive", () => { ); assert.equal(progressEvent.payload.description, "Running background teammate"); } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEvents: Array = []; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => runtimeEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); - // The undeclared background_tasks_changed roster snapshot is consumed - // silently — it must not surface as an unknown-subtype warning row. + // Undeclared wire-only roster snapshot + typed UX-internal subtypes: + // all consumed silently, none may surface as unknown-subtype warnings. + for (const message of [ + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }], + session_id: "session", + uuid: "roster", + }, + { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, + { + type: "system", + subtype: "notification", + key: "context", + text: "low priority note", + priority: "low", + session_id: "session", + uuid: "notif", + }, + ]) { + harness.query.emit(message as unknown as SDKMessage); + } + // api_retry maps to a session heartbeat, not a warning row. harness.query.emit({ type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: "workflow-1", task_type: "local_workflow", description: "Run checks" }], + subtype: "api_retry", + attempt: 3, + max_retries: 10, + retry_delay_ms: 1000, + error_status: 502, + error: { type: "api_error" }, session_id: "session", - uuid: "roster", + uuid: "retry", } as unknown as SDKMessage); yield* Effect.yieldNow; - const rosterWarnings = runtimeEvents.filter( + yield* Effect.yieldNow; + + const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + assert.deepEqual( + warnings.map((event) => event.payload.message), + [], + ); + const heartbeat = runtimeEvents.find( (event) => - event.type === "runtime.warning" && - typeof event.payload.message === "string" && - event.payload.message.includes("background_tasks_changed"), + event.type === "session.state.changed" && + typeof event.payload.reason === "string" && + event.payload.reason.startsWith("api_retry:"), ); - assert.equal(rosterWarnings.length, 0); + assert.equal(heartbeat?.type, "session.state.changed"); + runtimeEventsFiber.interruptUnsafe(); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 93a34f9c732..2fa0f509f39 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2708,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, }); return; + // Task state patch (status/backgrounded/end_time). No runtime mapping + // yet — the terminal task_notification reports the outcome — but it + // must not surface as an unknown-subtype warning row. + case "task_updated": + return; case "task_notification": yield* emitThreadTokenUsage( context, @@ -2752,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; case "thinking_tokens": return; + case "api_retry": + // Transport-level retry heartbeat. Surfacing each attempt as a + // warning row spammed the work log (10 rows during a 502 storm); + // the terminal result/error path reports the actual failure. Keep + // the session visibly alive instead. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: "running", + reason: `api_retry:${message.attempt}/${message.max_retries}`, + }, + }); + return; + case "session_state_changed": + // Authoritative turn-over signal from the CLI. + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: + message.state === "running" + ? "running" + : message.state === "requires_action" + ? "waiting" + : "ready", + reason: `session_state:${message.state}`, + }, + }); + return; + case "notification": + // User-facing CLI notification (e.g. context-limit warnings). Only + // high-priority ones warrant a work-log row. + if (message.priority === "high" || message.priority === "immediate") { + yield* emitRuntimeWarning(context, message.text, message); + } + return; + // Inner protocol/UX details with no T3 surface today — consumed + // deliberately so they don't masquerade as unknown-subtype warnings. + case "model_refusal_fallback": + case "local_command_output": + case "plugin_install": + case "commands_changed": + case "memory_recall": + case "elicitation_complete": + return; case "permission_denied": yield* offerRuntimeEvent({ ...base, @@ -2771,13 +2822,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( message, ); return; - default: + default: { + // Exhaustiveness guard: every subtype in the SDK's typed union is + // handled above, so `message` narrows to never here — a new SDK + // release adding a subtype fails this typecheck instead of silently + // warning at runtime. The runtime fallback still catches undeclared + // wire-only subtypes (like background_tasks_changed used to be). + const unknownMessage: { subtype: string } = message satisfies never; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), + describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), message, ); return; + } } }); @@ -2885,13 +2943,20 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( case "rate_limit_event": yield* handleSdkTelemetryMessage(context, message); return; - default: + // Composer prompt suggestions have no T3 surface; consumed deliberately. + case "prompt_suggestion": + return; + default: { + // Exhaustiveness guard (see handleSystemMessage): new SDK top-level + // message types fail typecheck here instead of warning at runtime. + const unknownMessage: { type: string } = message satisfies never; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), + describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), message, ); return; + } } }); From 057a5c832cd8cf2e8336f27c7fa03ab18f351c06 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 19:40:00 -0700 Subject: [PATCH 3/4] test(claude): cover all new dispatch branches per review Table-driven emission of every silent subtype, task_updated, prompt_suggestion, high-priority notification (asserts exactly one warning row), and all three session_state_changed mappings. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 75b99614a95..2735b69a187 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1726,8 +1726,9 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", }); - // Undeclared wire-only roster snapshot + typed UX-internal subtypes: - // all consumed silently, none may surface as unknown-subtype warnings. + // Undeclared wire-only roster snapshot + every typed UX-internal + // subtype and top-level type consumed silently: none may surface as + // unknown-subtype warnings. for (const message of [ { type: "system", @@ -1736,7 +1737,21 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "roster", }, + { + type: "system", + subtype: "task_updated", + task_id: "t1", + patch: { status: "running" }, + session_id: "session", + uuid: "tu", + }, { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, + { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, + { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, + { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, + { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, + { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, { type: "system", subtype: "notification", @@ -1749,6 +1764,30 @@ describe("ClaudeAdapterLive", () => { ]) { harness.query.emit(message as unknown as SDKMessage); } + // High-priority notifications DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "notification", + key: "limit", + text: "context window nearly full", + priority: "high", + session_id: "session", + uuid: "notif-high", + } as unknown as SDKMessage); + // session_state_changed maps to the matching session states. + for (const [state, uuid] of [ + ["running", "ssc-run"], + ["requires_action", "ssc-req"], + ["idle", "ssc-idle"], + ]) { + harness.query.emit({ + type: "system", + subtype: "session_state_changed", + state, + session_id: "session", + uuid, + } as unknown as SDKMessage); + } // api_retry maps to a session heartbeat, not a warning row. harness.query.emit({ type: "system", @@ -1765,10 +1804,26 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); + // Exactly one warning: the high-priority notification. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), - [], + ["context window nearly full"], ); + const sessionStates = runtimeEvents + .filter((event) => event.type === "session.state.changed") + .map((event) => + event.type === "session.state.changed" + ? `${event.payload.state}:${event.payload.reason ?? ""}` + : "", + ) + .filter( + (entry) => entry.startsWith("running:session_state") || entry.includes("session_state"), + ); + assert.deepEqual(sessionStates, [ + "running:session_state:running", + "waiting:session_state:requires_action", + "ready:session_state:idle", + ]); const heartbeat = runtimeEvents.find( (event) => event.type === "session.state.changed" && From 6a5718d55cdf3a633e6937c77a0beb9692a782f9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 21 Jul 2026 19:50:37 -0700 Subject: [PATCH 4/4] style(claude): match decider.ts exhaustiveness-guard form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert (message satisfies never) and cast as separate statements, mirroring orchestration/decider.ts:778 — same compile-time guarantee, house style. Co-Authored-By: Claude Fable 5 --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2fa0f509f39..757d7a00eb2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2828,7 +2828,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // release adding a subtype fails this typecheck instead of silently // warning at runtime. The runtime fallback still catches undeclared // wire-only subtypes (like background_tasks_changed used to be). - const unknownMessage: { subtype: string } = message satisfies never; + message satisfies never; + const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( context, describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), @@ -2949,7 +2950,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( default: { // Exhaustiveness guard (see handleSystemMessage): new SDK top-level // message types fail typecheck here instead of warning at runtime. - const unknownMessage: { type: string } = message satisfies never; + message satisfies never; + const unknownMessage = message as never as { type: string }; yield* emitRuntimeWarning( context, describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message),