| 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 | 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 | 1841 | it.effect("emits thread token usage updates from Claude task progress", () => { |
| 1715 | 1842 | const harness = makeHarness(); |
| 1716 | 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 | 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 | 2599 | switch (message.subtype) { |
| 2589 | 2600 | case "init": |
| 2590 | 2601 | yield* offerRuntimeEvent({ |
| ... | ... | @@ -2697,6 +2708,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2697 | 2708 | }, |
| 2698 | 2709 | }); |
| 2699 | 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 | 2716 | case "task_notification": |
| 2701 | 2717 | yield* emitThreadTokenUsage( |
| 2702 | 2718 | context, |
| ... | ... | @@ -2741,6 +2757,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2741 | 2757 | return; |
| 2742 | 2758 | case "thinking_tokens": |
| 2743 | 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 | 2806 | case "permission_denied": |
| 2745 | 2807 | yield* offerRuntimeEvent({ |
| 2746 | 2808 | ...base, |
| ... | ... | @@ -2760,13 +2822,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2760 | 2822 | message, |
| 2761 | 2823 | ); |
| 2762 | 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 | 2833 | yield* emitRuntimeWarning( |
| 2765 | 2834 | context, |
| 2766 | describeUnknownSdkMessage(`Claude system message '${message.subtype}'`, message), | |
| 2835 | describeUnknownSdkMessage(`Claude system message '${unknownMessage.subtype}'`, message), | |
| 2767 | 2836 | message, |
| 2768 | 2837 | ); |
| 2769 | 2838 | return; |
| 2839 | } | |
| 2770 | 2840 | } |
| 2771 | 2841 | }); |
| 2772 | 2842 | |
| ... | ... | @@ -2874,13 +2944,21 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2874 | 2944 | case "rate_limit_event": |
| 2875 | 2945 | yield* handleSdkTelemetryMessage(context, message); |
| 2876 | 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 | 2955 | yield* emitRuntimeWarning( |
| 2879 | 2956 | context, |
| 2880 | describeUnknownSdkMessage(`Claude SDK message '${message.type}'`, message), | |
| 2957 | describeUnknownSdkMessage(`Claude SDK message '${unknownMessage.type}'`, message), | |
| 2881 | 2958 | message, |
| 2882 | 2959 | ); |
| 2883 | 2960 | return; |
| 2961 | } | |
| 2884 | 2962 | } |
| 2885 | 2963 | }); |
| 2886 | 2964 |