authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-21 19:58:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-21 19:58:39-07:00
log6f34ad3e87eba2ffba66cac5593dae8b680e5b84
treee31a382790b0d5409caff6bfdb19e94a9c142650
parent62cf4617594c2a0d8c9c5dc4bbc2012d0ead802d
signature Signed by PGP key B5690EEEBB952194

fix(claude): handle all SDK stream messages; stop spurious work-log warning rows (#4244)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 209 insertions(+), 4 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+127
......@@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => {
17111711 );
17121712 });
17131713
1714 it.effect("consumes undeclared and UX-internal system subtypes without warning rows", () => {
1715 const harness = makeHarness();
1716 return Effect.gen(function* () {
1717 const adapter = yield* ClaudeAdapter;
1718 const runtimeEvents: Array<ProviderRuntimeEvent> = [];
1719 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1720 Effect.sync(() => runtimeEvents.push(event)),
1721 ).pipe(Effect.forkChild);
1722
1723 yield* adapter.startSession({
1724 threadId: THREAD_ID,
1725 provider: ProviderDriverKind.make("claudeAgent"),
1726 runtimeMode: "full-access",
1727 });
1728
1729 // Undeclared wire-only roster snapshot + every typed UX-internal
1730 // subtype and top-level type consumed silently: none may surface as
1731 // unknown-subtype warnings.
1732 for (const message of [
1733 {
1734 type: "system",
1735 subtype: "background_tasks_changed",
1736 tasks: [{ task_id: "t1", task_type: "local_agent", description: "Say hi" }],
1737 session_id: "session",
1738 uuid: "roster",
1739 },
1740 {
1741 type: "system",
1742 subtype: "task_updated",
1743 task_id: "t1",
1744 patch: { status: "running" },
1745 session_id: "session",
1746 uuid: "tu",
1747 },
1748 { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" },
1749 { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" },
1750 { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" },
1751 { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" },
1752 { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" },
1753 { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" },
1754 { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" },
1755 {
1756 type: "system",
1757 subtype: "notification",
1758 key: "context",
1759 text: "low priority note",
1760 priority: "low",
1761 session_id: "session",
1762 uuid: "notif",
1763 },
1764 ]) {
1765 harness.query.emit(message as unknown as SDKMessage);
1766 }
1767 // High-priority notifications DO surface as a warning row.
1768 harness.query.emit({
1769 type: "system",
1770 subtype: "notification",
1771 key: "limit",
1772 text: "context window nearly full",
1773 priority: "high",
1774 session_id: "session",
1775 uuid: "notif-high",
1776 } as unknown as SDKMessage);
1777 // session_state_changed maps to the matching session states.
1778 for (const [state, uuid] of [
1779 ["running", "ssc-run"],
1780 ["requires_action", "ssc-req"],
1781 ["idle", "ssc-idle"],
1782 ]) {
1783 harness.query.emit({
1784 type: "system",
1785 subtype: "session_state_changed",
1786 state,
1787 session_id: "session",
1788 uuid,
1789 } as unknown as SDKMessage);
1790 }
1791 // api_retry maps to a session heartbeat, not a warning row.
1792 harness.query.emit({
1793 type: "system",
1794 subtype: "api_retry",
1795 attempt: 3,
1796 max_retries: 10,
1797 retry_delay_ms: 1000,
1798 error_status: 502,
1799 error: { type: "api_error" },
1800 session_id: "session",
1801 uuid: "retry",
1802 } as unknown as SDKMessage);
1803 yield* Effect.yieldNow;
1804 yield* Effect.yieldNow;
1805
1806 const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning");
1807 // Exactly one warning: the high-priority notification. Nothing else.
1808 assert.deepEqual(
1809 warnings.map((event) => event.payload.message),
1810 ["context window nearly full"],
1811 );
1812 const sessionStates = runtimeEvents
1813 .filter((event) => event.type === "session.state.changed")
1814 .map((event) =>
1815 event.type === "session.state.changed"
1816 ? `${event.payload.state}:${event.payload.reason ?? ""}`
1817 : "",
1818 )
1819 .filter(
1820 (entry) => entry.startsWith("running:session_state") || entry.includes("session_state"),
1821 );
1822 assert.deepEqual(sessionStates, [
1823 "running:session_state:running",
1824 "waiting:session_state:requires_action",
1825 "ready:session_state:idle",
1826 ]);
1827 const heartbeat = runtimeEvents.find(
1828 (event) =>
1829 event.type === "session.state.changed" &&
1830 typeof event.payload.reason === "string" &&
1831 event.payload.reason.startsWith("api_retry:"),
1832 );
1833 assert.equal(heartbeat?.type, "session.state.changed");
1834 runtimeEventsFiber.interruptUnsafe();
1835 }).pipe(
1836 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1837 Effect.provide(harness.layer),
1838 );
1839 });
1840
17141841 it.effect("emits thread token usage updates from Claude task progress", () => {
17151842 const harness = makeHarness();
17161843 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+82-4
......@@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
25852585 },
25862586 };
25872587
2588 // Undeclared-but-real subtypes (absent from the SDK's union, so they can't
2589 // be switch cases): consumed intentionally without emitting, otherwise
2590 // they fall through to the unknown-subtype warning and surface as spurious
2591 // error rows in client work logs. `background_tasks_changed` is a roster
2592 // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the
2593 // authoritative per-agent data and the typed background_tasks control
2594 // request is the reconciliation source.
2595 if ((message.subtype as string) === "background_tasks_changed") {
2596 return;
2597 }
2598
25882599 switch (message.subtype) {
25892600 case "init":
25902601 yield* offerRuntimeEvent({
......@@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
26972708 },
26982709 });
26992710 return;
2711 // Task state patch (status/backgrounded/end_time). No runtime mapping
2712 // yet — the terminal task_notification reports the outcome — but it
2713 // must not surface as an unknown-subtype warning row.
2714 case "task_updated":
2715 return;
27002716 case "task_notification":
27012717 yield* emitThreadTokenUsage(
27022718 context,
......@@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27412757 return;
27422758 case "thinking_tokens":
27432759 return;
2760 case "api_retry":
2761 // Transport-level retry heartbeat. Surfacing each attempt as a
2762 // warning row spammed the work log (10 rows during a 502 storm);
2763 // the terminal result/error path reports the actual failure. Keep
2764 // the session visibly alive instead.
2765 yield* offerRuntimeEvent({
2766 ...base,
2767 type: "session.state.changed",
2768 payload: {
2769 state: "running",
2770 reason: `api_retry:${message.attempt}/${message.max_retries}`,
2771 },
2772 });
2773 return;
2774 case "session_state_changed":
2775 // Authoritative turn-over signal from the CLI.
2776 yield* offerRuntimeEvent({
2777 ...base,
2778 type: "session.state.changed",
2779 payload: {
2780 state:
2781 message.state === "running"
2782 ? "running"
2783 : message.state === "requires_action"
2784 ? "waiting"
2785 : "ready",
2786 reason: `session_state:${message.state}`,
2787 },
2788 });
2789 return;
2790 case "notification":
2791 // User-facing CLI notification (e.g. context-limit warnings). Only
2792 // high-priority ones warrant a work-log row.
2793 if (message.priority === "high" || message.priority === "immediate") {
2794 yield* emitRuntimeWarning(context, message.text, message);
2795 }
2796 return;
2797 // Inner protocol/UX details with no T3 surface today — consumed
2798 // deliberately so they don't masquerade as unknown-subtype warnings.
2799 case "model_refusal_fallback":
2800 case "local_command_output":
2801 case "plugin_install":
2802 case "commands_changed":
2803 case "memory_recall":
2804 case "elicitation_complete":
2805 return;
27442806 case "permission_denied":
27452807 yield* offerRuntimeEvent({
27462808 ...base,
......@@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27602822 message,
27612823 );
27622824 return;
2763 default:
2825 default: {
2826 // Exhaustiveness guard: every subtype in the SDK's typed union is
2827 // handled above, so `message` narrows to never here — a new SDK
2828 // release adding a subtype fails this typecheck instead of silently
2829 // warning at runtime. The runtime fallback still catches undeclared
2830 // wire-only subtypes (like background_tasks_changed used to be).
2831 message satisfies never;
2832 const unknownMessage = message as never as { subtype: string };
27642833 yield* emitRuntimeWarning(
27652834 context,
2766 describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message),
2835 describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message),
27672836 message,
27682837 );
27692838 return;
2839 }
27702840 }
27712841 });
27722842
......@@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28742944 case "rate_limit_event":
28752945 yield* handleSdkTelemetryMessage(context, message);
28762946 return;
2877 default:
2947 // Composer prompt suggestions have no T3 surface; consumed deliberately.
2948 case "prompt_suggestion":
2949 return;
2950 default: {
2951 // Exhaustiveness guard (see handleSystemMessage): new SDK top-level
2952 // message types fail typecheck here instead of warning at runtime.
2953 message satisfies never;
2954 const unknownMessage = message as never as { type: string };
28782955 yield* emitRuntimeWarning(
28792956 context,
2880 describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message),
2957 describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message),
28812958 message,
28822959 );
28832960 return;
2961 }
28842962 }
28852963 });
28862964