From 4c8c067af1f702b6bdbf76537d95669ac7765c94 Mon Sep 17 00:00:00 2001 From: Andrew Doucet <65342358+andydoucet@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:52:36 -0600 Subject: [PATCH] fix(server): absorb unknown Claude SDK messages instead of work-log warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude CLI >= ~2.1.2xx emits SDK message types (command_lifecycle, audit_event, ...) and system subtypes (background_tasks_changed, commands_changed, ... — 97 distinct subtypes in CLI 2.1.211) that the adapter does not model. Each one rendered a red runtime.warning row in the thread work log, reading as a failure on every turn. Unknown members now degrade gracefully per the contracts layer's forward-compat posture: no user-visible row, a debug log for diagnostics. The emitRuntimeWarning helper and the unknown-message preview helpers had no other consumers and are removed. Co-Authored-By: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 97 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 93 +++--------------- 2 files changed, 112 insertions(+), 78 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 17aeff2d0e3..d76df3da4bf 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -931,6 +931,103 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "absorbs unknown SDK message types and system subtypes without work-log warnings", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // Top-level SDK message type this build does not model (emitted by + // Claude CLI >= ~2.1.2xx). Must be absorbed silently. + harness.query.emit({ + type: "command_lifecycle", + command_uuid: "d5375ed5-741a-4371-83c4-ed59ad75ced3", + state: "running", + session_id: "sdk-session-1", + uuid: "lifecycle-1", + } as unknown as SDKMessage); + + // System message subtypes this build does not model. Same posture. + harness.query.emit({ + type: "system", + subtype: "background_tasks_changed", + session_id: "sdk-session-1", + uuid: "system-unknown-1", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "commands_changed", + session_id: "sdk-session-1", + uuid: "system-unknown-2", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + // The unknown messages contribute no events at all — in particular no + // "runtime.warning" work-log rows — and the turn still completes. + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "content.delta", + "item.completed", + "turn.completed", + ], + ); + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "completed"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..e88cac5c95b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1263,52 +1263,6 @@ function sdkNativeMethod(message: SDKMessage): string { return `claude/${message.type}`; } -// Discriminator/identity keys carry no human-readable content; everything else -// on an unmodeled SDK message is potentially worth surfacing in the work log. -const SDK_MESSAGE_NOISE_KEYS = new Set([ - "type", - "subtype", - "uuid", - "parent_uuid", - "session_id", - "parent_tool_use_id", - "request_id", -]); - -// Pull the salient scalar content out of a message the adapter doesn't model -// yet, so the work-log row shows what actually arrived (e.g. a notification's -// text) instead of an opaque "unhandled subtype" placeholder. Nested structures -// are left to the full payload retained in the event's `detail`. -function previewUnknownSdkContent(message: unknown): string | undefined { - if (!message || typeof message !== "object") { - return undefined; - } - const parts: string[] = []; - for (const [key, value] of Object.entries(message as Record)) { - if (SDK_MESSAGE_NOISE_KEYS.has(key)) { - continue; - } - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed.length > 0) { - parts.push(`${key}: ${trimmed}`); - } - } else if (typeof value === "number" || typeof value === "boolean") { - parts.push(`${key}: ${String(value)}`); - } - } - if (parts.length === 0) { - return undefined; - } - const joined = parts.join(" · "); - return joined.length > 280 ? `${joined.slice(0, 279)}…` : joined; -} - -function describeUnknownSdkMessage(kind: string, message: unknown): string { - const preview = previewUnknownSdkContent(message); - return preview ? `${kind} — ${preview}` : `${kind} (no displayable text content)`; -} - function sdkNativeItemId(message: SDKMessage): string | undefined { if (message.type === "assistant") { const maybeId = (message.message as { id?: unknown }).id; @@ -1707,28 +1661,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); - const emitRuntimeWarning = Effect.fn("emitRuntimeWarning")(function* ( - context: ClaudeSessionContext, - message: string, - detail?: unknown, - ) { - const turnState = context.turnState; - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "runtime.warning", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(turnState ? { turnId: asCanonicalTurnId(turnState.turnId) } : {}), - payload: { - message, - ...(detail !== undefined ? { detail } : {}), - }, - providerRefs: nativeProviderRefs(context), - }); - }); - const emitThreadTokenUsage = Effect.fn("emitThreadTokenUsage")(function* ( context: ClaudeSessionContext, usage: ThreadTokenUsageSnapshot | undefined, @@ -2761,11 +2693,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ); return; default: - yield* emitRuntimeWarning( - context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), - message, - ); + // Newer Claude CLI releases emit system subtypes this build does not + // know (97 distinct subtypes in CLI 2.1.211 vs the handful handled + // above). Unknown members degrade gracefully: no user-visible work-log + // row, just a debug log for diagnostics. + yield* Effect.logDebug("claude.sdk.system.unknown", { + subtype: message.subtype, + threadId: context.session.threadId, + }); return; } }); @@ -2875,11 +2810,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* handleSdkTelemetryMessage(context, message); return; default: - yield* emitRuntimeWarning( - context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), - message, - ); + // Same forward-compat posture as unknown system subtypes: newer CLIs + // add top-level message types (command_lifecycle, audit_event, ...); + // absorb them silently instead of alarming the work log. + yield* Effect.logDebug("claude.sdk.message.unknown", { + messageType: (message as { type: string }).type, + threadId: context.session.threadId, + }); return; } });