diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 3e883fa1cf5..2735b69a187 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => { ); }); + 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", + }); + + // 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", + subtype: "background_tasks_changed", + tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }], + 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", + key: "context", + text: "low priority note", + priority: "low", + session_id: "session", + uuid: "notif", + }, + ]) { + 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", + subtype: "api_retry", + attempt: 3, + max_retries: 10, + retry_delay_ms: 1000, + error_status: 502, + error: { type: "api_error" }, + session_id: "session", + uuid: "retry", + } as unknown as SDKMessage); + yield* Effect.yieldNow; + 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" && + typeof event.payload.reason === "string" && + event.payload.reason.startsWith("api_retry:"), + ); + assert.equal(heartbeat?.type, "session.state.changed"); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits thread token usage updates from Claude task progress", () => { 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 5ccd011ef09..757d7a00eb2 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({ @@ -2697,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, @@ -2741,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, @@ -2760,13 +2822,21 @@ 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). + message satisfies never; + const unknownMessage = message as never as { subtype: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), + describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), message, ); return; + } } }); @@ -2874,13 +2944,21 @@ 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. + message satisfies never; + const unknownMessage = message as never as { type: string }; yield* emitRuntimeWarning( context, - describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), + describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), message, ); return; + } } });