| author | |
| committer | |
| log | 6f34ad3e87eba2ffba66cac5593dae8b680e5b84 |
| tree | e31a382790b0d5409caff6bfdb19e94a9c142650 |
| parent | 62cf4617594c2a0d8c9c5dc4bbc2012d0ead802d |
| signature | Signed by PGP key B5690EEEBB952194 |
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", () => { | ... | @@ -1711,6 +1711,133 @@ describe("ClaudeAdapterLive", () => { |
| 1711 | ); | 1711 | ); |
| 1712 | }); | 1712 | }); |
| 1713 | 1713 | ||
| 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 | |||
| 1714 | it.effect("emits thread token usage updates from Claude task progress", () => { | 1841 | it.effect("emits thread token usage updates from Claude task progress", () => { |
| 1715 | const harness = makeHarness(); | 1842 | const harness = makeHarness(); |
| 1716 | return Effect.gen(function* () { | 1843 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+82-4| ... | @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2585,6 +2585,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2585 | }, | 2585 | }, |
| 2586 | }; | 2586 | }; |
| 2587 | 2587 | ||
| 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 | |||
| 2588 | switch (message.subtype) { | 2599 | switch (message.subtype) { |
| 2589 | case "init": | 2600 | case "init": |
| 2590 | yield* offerRuntimeEvent({ | 2601 | yield* offerRuntimeEvent({ |
| ... | @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2697 | }, | 2708 | }, |
| 2698 | }); | 2709 | }); |
| 2699 | return; | 2710 | 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; | ||
| 2700 | case "task_notification": | 2716 | case "task_notification": |
| 2701 | yield* emitThreadTokenUsage( | 2717 | yield* emitThreadTokenUsage( |
| 2702 | context, | 2718 | context, |
| ... | @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2741 | return; | 2757 | return; |
| 2742 | case "thinking_tokens": | 2758 | case "thinking_tokens": |
| 2743 | return; | 2759 | 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; | ||
| 2744 | case "permission_denied": | 2806 | case "permission_denied": |
| 2745 | yield* offerRuntimeEvent({ | 2807 | yield* offerRuntimeEvent({ |
| 2746 | ...base, | 2808 | ...base, |
| ... | @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2760 | message, | 2822 | message, |
| 2761 | ); | 2823 | ); |
| 2762 | return; | 2824 | 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 }; | ||
| 2764 | yield* emitRuntimeWarning( | 2833 | yield* emitRuntimeWarning( |
| 2765 | context, | 2834 | context, |
| 2766 | describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), | 2835 | describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), |
| 2767 | message, | 2836 | message, |
| 2768 | ); | 2837 | ); |
| 2769 | return; | 2838 | return; |
| 2839 | } | ||
| 2770 | } | 2840 | } |
| 2771 | }); | 2841 | }); |
| 2772 | 2842 | ||
| ... | @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2874 | case "rate_limit_event": | 2944 | case "rate_limit_event": |
| 2875 | yield* handleSdkTelemetryMessage(context, message); | 2945 | yield* handleSdkTelemetryMessage(context, message); |
| 2876 | return; | 2946 | 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 }; | ||
| 2878 | yield* emitRuntimeWarning( | 2955 | yield* emitRuntimeWarning( |
| 2879 | context, | 2956 | context, |
| 2880 | describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), | 2957 | describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), |
| 2881 | message, | 2958 | message, |
| 2882 | ); | 2959 | ); |
| 2883 | return; | 2960 | return; |
| 2961 | } | ||
| 2884 | } | 2962 | } |
| 2885 | }); | 2963 | }); |
| 2886 | 2964 |