@snoglobe/helios 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/dist/__tests__/db-helper.d.ts +8 -0
  2. package/dist/__tests__/db-helper.d.ts.map +1 -0
  3. package/dist/__tests__/db-helper.js +15 -0
  4. package/dist/__tests__/db-helper.js.map +1 -0
  5. package/dist/config/project.test.d.ts +2 -0
  6. package/dist/config/project.test.d.ts.map +1 -0
  7. package/dist/config/project.test.js +219 -0
  8. package/dist/config/project.test.js.map +1 -0
  9. package/dist/core/orchestrator-integration.test.d.ts +2 -0
  10. package/dist/core/orchestrator-integration.test.d.ts.map +1 -0
  11. package/dist/core/orchestrator-integration.test.js +674 -0
  12. package/dist/core/orchestrator-integration.test.js.map +1 -0
  13. package/dist/core/orchestrator.d.ts +1 -0
  14. package/dist/core/orchestrator.d.ts.map +1 -1
  15. package/dist/core/orchestrator.js +81 -37
  16. package/dist/core/orchestrator.js.map +1 -1
  17. package/dist/core/orchestrator.test.d.ts +2 -0
  18. package/dist/core/orchestrator.test.d.ts.map +1 -0
  19. package/dist/core/orchestrator.test.js +982 -0
  20. package/dist/core/orchestrator.test.js.map +1 -0
  21. package/dist/core/state-machine.d.ts.map +1 -1
  22. package/dist/core/state-machine.js +3 -0
  23. package/dist/core/state-machine.js.map +1 -1
  24. package/dist/core/state-machine.test.d.ts +2 -0
  25. package/dist/core/state-machine.test.d.ts.map +1 -0
  26. package/dist/core/state-machine.test.js +95 -0
  27. package/dist/core/state-machine.test.js.map +1 -0
  28. package/dist/core/stickies.d.ts.map +1 -1
  29. package/dist/core/stickies.js +3 -0
  30. package/dist/core/stickies.js.map +1 -1
  31. package/dist/core/stickies.test.d.ts +2 -0
  32. package/dist/core/stickies.test.d.ts.map +1 -0
  33. package/dist/core/stickies.test.js +63 -0
  34. package/dist/core/stickies.test.js.map +1 -0
  35. package/dist/core/task-poller.d.ts.map +1 -1
  36. package/dist/core/task-poller.js +2 -1
  37. package/dist/core/task-poller.js.map +1 -1
  38. package/dist/init.d.ts +2 -0
  39. package/dist/init.d.ts.map +1 -1
  40. package/dist/init.js +19 -0
  41. package/dist/init.js.map +1 -1
  42. package/dist/memory/context-gate.test.d.ts +2 -0
  43. package/dist/memory/context-gate.test.d.ts.map +1 -0
  44. package/dist/memory/context-gate.test.js +318 -0
  45. package/dist/memory/context-gate.test.js.map +1 -0
  46. package/dist/memory/experiment-tracker.test.d.ts +2 -0
  47. package/dist/memory/experiment-tracker.test.d.ts.map +1 -0
  48. package/dist/memory/experiment-tracker.test.js +325 -0
  49. package/dist/memory/experiment-tracker.test.js.map +1 -0
  50. package/dist/memory/memory-store.d.ts +2 -0
  51. package/dist/memory/memory-store.d.ts.map +1 -1
  52. package/dist/memory/memory-store.js +35 -28
  53. package/dist/memory/memory-store.js.map +1 -1
  54. package/dist/memory/memory-store.test.d.ts +2 -0
  55. package/dist/memory/memory-store.test.d.ts.map +1 -0
  56. package/dist/memory/memory-store.test.js +144 -0
  57. package/dist/memory/memory-store.test.js.map +1 -0
  58. package/dist/memory/token-estimator.test.d.ts +2 -0
  59. package/dist/memory/token-estimator.test.d.ts.map +1 -0
  60. package/dist/memory/token-estimator.test.js +42 -0
  61. package/dist/memory/token-estimator.test.js.map +1 -0
  62. package/dist/metrics/analyzer.test.d.ts +2 -0
  63. package/dist/metrics/analyzer.test.d.ts.map +1 -0
  64. package/dist/metrics/analyzer.test.js +65 -0
  65. package/dist/metrics/analyzer.test.js.map +1 -0
  66. package/dist/metrics/parser.test.d.ts +2 -0
  67. package/dist/metrics/parser.test.d.ts.map +1 -0
  68. package/dist/metrics/parser.test.js +106 -0
  69. package/dist/metrics/parser.test.js.map +1 -0
  70. package/dist/metrics/store.d.ts +2 -0
  71. package/dist/metrics/store.d.ts.map +1 -1
  72. package/dist/metrics/store.js +26 -50
  73. package/dist/metrics/store.js.map +1 -1
  74. package/dist/metrics/store.test.d.ts +2 -0
  75. package/dist/metrics/store.test.d.ts.map +1 -0
  76. package/dist/metrics/store.test.js +148 -0
  77. package/dist/metrics/store.test.js.map +1 -0
  78. package/dist/paths.js +5 -5
  79. package/dist/paths.js.map +1 -1
  80. package/dist/providers/auth/auth-manager.d.ts +1 -0
  81. package/dist/providers/auth/auth-manager.d.ts.map +1 -1
  82. package/dist/providers/auth/auth-manager.js +8 -1
  83. package/dist/providers/auth/auth-manager.js.map +1 -1
  84. package/dist/providers/auth/auth-manager.test.d.ts +2 -0
  85. package/dist/providers/auth/auth-manager.test.d.ts.map +1 -0
  86. package/dist/providers/auth/auth-manager.test.js +364 -0
  87. package/dist/providers/auth/auth-manager.test.js.map +1 -0
  88. package/dist/providers/auth/token-store.js +2 -2
  89. package/dist/providers/auth/token-store.js.map +1 -1
  90. package/dist/providers/claude/history.test.d.ts +2 -0
  91. package/dist/providers/claude/history.test.d.ts.map +1 -0
  92. package/dist/providers/claude/history.test.js +755 -0
  93. package/dist/providers/claude/history.test.js.map +1 -0
  94. package/dist/providers/claude/provider.d.ts +3 -1
  95. package/dist/providers/claude/provider.d.ts.map +1 -1
  96. package/dist/providers/claude/provider.js +33 -19
  97. package/dist/providers/claude/provider.js.map +1 -1
  98. package/dist/providers/claude/provider.test.d.ts +2 -0
  99. package/dist/providers/claude/provider.test.d.ts.map +1 -0
  100. package/dist/providers/claude/provider.test.js +1167 -0
  101. package/dist/providers/claude/provider.test.js.map +1 -0
  102. package/dist/providers/openai/history.test.d.ts +2 -0
  103. package/dist/providers/openai/history.test.d.ts.map +1 -0
  104. package/dist/providers/openai/history.test.js +655 -0
  105. package/dist/providers/openai/history.test.js.map +1 -0
  106. package/dist/providers/openai/provider.d.ts +2 -1
  107. package/dist/providers/openai/provider.d.ts.map +1 -1
  108. package/dist/providers/openai/provider.js +14 -8
  109. package/dist/providers/openai/provider.js.map +1 -1
  110. package/dist/providers/openai/provider.test.d.ts +2 -0
  111. package/dist/providers/openai/provider.test.d.ts.map +1 -0
  112. package/dist/providers/openai/provider.test.js +1089 -0
  113. package/dist/providers/openai/provider.test.js.map +1 -0
  114. package/dist/providers/retry.test.d.ts +2 -0
  115. package/dist/providers/retry.test.d.ts.map +1 -0
  116. package/dist/providers/retry.test.js +194 -0
  117. package/dist/providers/retry.test.js.map +1 -0
  118. package/dist/providers/sse.d.ts +1 -0
  119. package/dist/providers/sse.d.ts.map +1 -1
  120. package/dist/providers/sse.js +29 -20
  121. package/dist/providers/sse.js.map +1 -1
  122. package/dist/providers/sse.test.d.ts +2 -0
  123. package/dist/providers/sse.test.d.ts.map +1 -0
  124. package/dist/providers/sse.test.js +79 -0
  125. package/dist/providers/sse.test.js.map +1 -0
  126. package/dist/providers/types.test.d.ts +2 -0
  127. package/dist/providers/types.test.d.ts.map +1 -0
  128. package/dist/providers/types.test.js +112 -0
  129. package/dist/providers/types.test.js.map +1 -0
  130. package/dist/remote/connection-pool.d.ts.map +1 -1
  131. package/dist/remote/connection-pool.js +24 -3
  132. package/dist/remote/connection-pool.js.map +1 -1
  133. package/dist/remote/file-sync.d.ts.map +1 -1
  134. package/dist/remote/file-sync.js +4 -3
  135. package/dist/remote/file-sync.js.map +1 -1
  136. package/dist/scheduler/sleep-manager.d.ts.map +1 -1
  137. package/dist/scheduler/sleep-manager.js +8 -1
  138. package/dist/scheduler/sleep-manager.js.map +1 -1
  139. package/dist/scheduler/sleep-manager.test.d.ts +2 -0
  140. package/dist/scheduler/sleep-manager.test.d.ts.map +1 -0
  141. package/dist/scheduler/sleep-manager.test.js +491 -0
  142. package/dist/scheduler/sleep-manager.test.js.map +1 -0
  143. package/dist/scheduler/ssh-batcher.d.ts.map +1 -1
  144. package/dist/scheduler/ssh-batcher.js +6 -4
  145. package/dist/scheduler/ssh-batcher.js.map +1 -1
  146. package/dist/scheduler/ssh-batcher.test.d.ts +2 -0
  147. package/dist/scheduler/ssh-batcher.test.d.ts.map +1 -0
  148. package/dist/scheduler/ssh-batcher.test.js +76 -0
  149. package/dist/scheduler/ssh-batcher.test.js.map +1 -0
  150. package/dist/scheduler/state-store.d.ts.map +1 -1
  151. package/dist/scheduler/state-store.js +1 -2
  152. package/dist/scheduler/state-store.js.map +1 -1
  153. package/dist/scheduler/trigger-scheduler.d.ts.map +1 -1
  154. package/dist/scheduler/trigger-scheduler.js +59 -36
  155. package/dist/scheduler/trigger-scheduler.js.map +1 -1
  156. package/dist/scheduler/trigger-scheduler.test.d.ts +2 -0
  157. package/dist/scheduler/trigger-scheduler.test.d.ts.map +1 -0
  158. package/dist/scheduler/trigger-scheduler.test.js +483 -0
  159. package/dist/scheduler/trigger-scheduler.test.js.map +1 -0
  160. package/dist/scheduler/triggers/file.d.ts.map +1 -1
  161. package/dist/scheduler/triggers/file.js +12 -3
  162. package/dist/scheduler/triggers/file.js.map +1 -1
  163. package/dist/scheduler/triggers/file.test.d.ts +2 -0
  164. package/dist/scheduler/triggers/file.test.d.ts.map +1 -0
  165. package/dist/scheduler/triggers/file.test.js +294 -0
  166. package/dist/scheduler/triggers/file.test.js.map +1 -0
  167. package/dist/scheduler/triggers/metric.d.ts +3 -1
  168. package/dist/scheduler/triggers/metric.d.ts.map +1 -1
  169. package/dist/scheduler/triggers/metric.js +12 -8
  170. package/dist/scheduler/triggers/metric.js.map +1 -1
  171. package/dist/scheduler/triggers/metric.test.d.ts +2 -0
  172. package/dist/scheduler/triggers/metric.test.d.ts.map +1 -0
  173. package/dist/scheduler/triggers/metric.test.js +533 -0
  174. package/dist/scheduler/triggers/metric.test.js.map +1 -0
  175. package/dist/scheduler/triggers/process-exit.d.ts.map +1 -1
  176. package/dist/scheduler/triggers/process-exit.js +2 -1
  177. package/dist/scheduler/triggers/process-exit.js.map +1 -1
  178. package/dist/scheduler/triggers/process-exit.test.d.ts +2 -0
  179. package/dist/scheduler/triggers/process-exit.test.d.ts.map +1 -0
  180. package/dist/scheduler/triggers/process-exit.test.js +118 -0
  181. package/dist/scheduler/triggers/process-exit.test.js.map +1 -0
  182. package/dist/scheduler/triggers/resource.d.ts.map +1 -1
  183. package/dist/scheduler/triggers/resource.js +2 -10
  184. package/dist/scheduler/triggers/resource.js.map +1 -1
  185. package/dist/scheduler/triggers/resource.test.d.ts +2 -0
  186. package/dist/scheduler/triggers/resource.test.d.ts.map +1 -0
  187. package/dist/scheduler/triggers/resource.test.js +225 -0
  188. package/dist/scheduler/triggers/resource.test.js.map +1 -0
  189. package/dist/scheduler/triggers/timer.test.d.ts +2 -0
  190. package/dist/scheduler/triggers/timer.test.d.ts.map +1 -0
  191. package/dist/scheduler/triggers/timer.test.js +56 -0
  192. package/dist/scheduler/triggers/timer.test.js.map +1 -0
  193. package/dist/scheduler/triggers/types.d.ts +4 -2
  194. package/dist/scheduler/triggers/types.d.ts.map +1 -1
  195. package/dist/scheduler/triggers/types.js +0 -1
  196. package/dist/scheduler/triggers/types.js.map +1 -1
  197. package/dist/skills/executor.d.ts.map +1 -1
  198. package/dist/skills/executor.js +13 -15
  199. package/dist/skills/executor.js.map +1 -1
  200. package/dist/store/database.d.ts +5 -0
  201. package/dist/store/database.d.ts.map +1 -1
  202. package/dist/store/database.js +17 -1
  203. package/dist/store/database.js.map +1 -1
  204. package/dist/store/migrations.test.d.ts +2 -0
  205. package/dist/store/migrations.test.d.ts.map +1 -0
  206. package/dist/store/migrations.test.js +278 -0
  207. package/dist/store/migrations.test.js.map +1 -0
  208. package/dist/store/session-store-edge.test.d.ts +2 -0
  209. package/dist/store/session-store-edge.test.d.ts.map +1 -0
  210. package/dist/store/session-store-edge.test.js +522 -0
  211. package/dist/store/session-store-edge.test.js.map +1 -0
  212. package/dist/store/session-store.d.ts +6 -0
  213. package/dist/store/session-store.d.ts.map +1 -1
  214. package/dist/store/session-store.js +25 -24
  215. package/dist/store/session-store.js.map +1 -1
  216. package/dist/store/session-store.test.d.ts +2 -0
  217. package/dist/store/session-store.test.d.ts.map +1 -0
  218. package/dist/store/session-store.test.js +125 -0
  219. package/dist/store/session-store.test.js.map +1 -0
  220. package/dist/subagent/executor.d.ts +21 -0
  221. package/dist/subagent/executor.d.ts.map +1 -0
  222. package/dist/subagent/executor.js +136 -0
  223. package/dist/subagent/executor.js.map +1 -0
  224. package/dist/subagent/manager.d.ts +20 -0
  225. package/dist/subagent/manager.d.ts.map +1 -0
  226. package/dist/subagent/manager.js +98 -0
  227. package/dist/subagent/manager.js.map +1 -0
  228. package/dist/subagent/scoped-memory.d.ts +28 -0
  229. package/dist/subagent/scoped-memory.d.ts.map +1 -0
  230. package/dist/subagent/scoped-memory.js +122 -0
  231. package/dist/subagent/scoped-memory.js.map +1 -0
  232. package/dist/subagent/types.d.ts +27 -0
  233. package/dist/subagent/types.d.ts.map +1 -0
  234. package/dist/subagent/types.js +2 -0
  235. package/dist/subagent/types.js.map +1 -0
  236. package/dist/tools/file-ops.d.ts.map +1 -1
  237. package/dist/tools/file-ops.js +23 -13
  238. package/dist/tools/file-ops.js.map +1 -1
  239. package/dist/tools/file-ops.test.d.ts +2 -0
  240. package/dist/tools/file-ops.test.d.ts.map +1 -0
  241. package/dist/tools/file-ops.test.js +656 -0
  242. package/dist/tools/file-ops.test.js.map +1 -0
  243. package/dist/tools/memory-tools.test.d.ts +2 -0
  244. package/dist/tools/memory-tools.test.d.ts.map +1 -0
  245. package/dist/tools/memory-tools.test.js +273 -0
  246. package/dist/tools/memory-tools.test.js.map +1 -0
  247. package/dist/tools/subagent.d.ts +10 -0
  248. package/dist/tools/subagent.d.ts.map +1 -0
  249. package/dist/tools/subagent.js +164 -0
  250. package/dist/tools/subagent.js.map +1 -0
  251. package/dist/tools/task-output.d.ts.map +1 -1
  252. package/dist/tools/task-output.js +13 -2
  253. package/dist/tools/task-output.js.map +1 -1
  254. package/dist/ui/components/input-bar.d.ts +1 -1
  255. package/dist/ui/components/input-bar.d.ts.map +1 -1
  256. package/dist/ui/components/input-bar.js +3 -3
  257. package/dist/ui/components/input-bar.js.map +1 -1
  258. package/dist/ui/components/input-bar.test.d.ts +2 -0
  259. package/dist/ui/components/input-bar.test.d.ts.map +1 -0
  260. package/dist/ui/components/input-bar.test.js +380 -0
  261. package/dist/ui/components/input-bar.test.js.map +1 -0
  262. package/dist/ui/components/key-hint-rule.d.ts +2 -1
  263. package/dist/ui/components/key-hint-rule.d.ts.map +1 -1
  264. package/dist/ui/components/key-hint-rule.js +3 -3
  265. package/dist/ui/components/key-hint-rule.js.map +1 -1
  266. package/dist/ui/components/status-bar.d.ts +1 -1
  267. package/dist/ui/components/status-bar.d.ts.map +1 -1
  268. package/dist/ui/components/status-bar.js +11 -10
  269. package/dist/ui/components/status-bar.js.map +1 -1
  270. package/dist/ui/components/status-bar.test.d.ts +2 -0
  271. package/dist/ui/components/status-bar.test.d.ts.map +1 -0
  272. package/dist/ui/components/status-bar.test.js +206 -0
  273. package/dist/ui/components/status-bar.test.js.map +1 -0
  274. package/dist/ui/format.d.ts +9 -0
  275. package/dist/ui/format.d.ts.map +1 -1
  276. package/dist/ui/format.js +21 -0
  277. package/dist/ui/format.js.map +1 -1
  278. package/dist/ui/format.test.d.ts +2 -0
  279. package/dist/ui/format.test.d.ts.map +1 -0
  280. package/dist/ui/format.test.js +122 -0
  281. package/dist/ui/format.test.js.map +1 -0
  282. package/dist/ui/layout.d.ts.map +1 -1
  283. package/dist/ui/layout.js +63 -11
  284. package/dist/ui/layout.js.map +1 -1
  285. package/dist/ui/markdown.test.d.ts +2 -0
  286. package/dist/ui/markdown.test.d.ts.map +1 -0
  287. package/dist/ui/markdown.test.js +133 -0
  288. package/dist/ui/markdown.test.js.map +1 -0
  289. package/dist/ui/mouse-filter.test.d.ts +2 -0
  290. package/dist/ui/mouse-filter.test.d.ts.map +1 -0
  291. package/dist/ui/mouse-filter.test.js +231 -0
  292. package/dist/ui/mouse-filter.test.js.map +1 -0
  293. package/dist/ui/overlays/metrics-overlay.test.d.ts +2 -0
  294. package/dist/ui/overlays/metrics-overlay.test.d.ts.map +1 -0
  295. package/dist/ui/overlays/metrics-overlay.test.js +248 -0
  296. package/dist/ui/overlays/metrics-overlay.test.js.map +1 -0
  297. package/dist/ui/overlays/task-overlay.test.d.ts +2 -0
  298. package/dist/ui/overlays/task-overlay.test.d.ts.map +1 -0
  299. package/dist/ui/overlays/task-overlay.test.js +238 -0
  300. package/dist/ui/overlays/task-overlay.test.js.map +1 -0
  301. package/dist/ui/panels/conversation.d.ts.map +1 -1
  302. package/dist/ui/panels/conversation.js +60 -5
  303. package/dist/ui/panels/conversation.js.map +1 -1
  304. package/dist/ui/panels/conversation.test.d.ts +2 -0
  305. package/dist/ui/panels/conversation.test.d.ts.map +1 -0
  306. package/dist/ui/panels/conversation.test.js +381 -0
  307. package/dist/ui/panels/conversation.test.js.map +1 -0
  308. package/dist/ui/panels/metrics-dashboard.test.d.ts +2 -0
  309. package/dist/ui/panels/metrics-dashboard.test.d.ts.map +1 -0
  310. package/dist/ui/panels/metrics-dashboard.test.js +191 -0
  311. package/dist/ui/panels/metrics-dashboard.test.js.map +1 -0
  312. package/dist/ui/panels/sleep-panel.test.d.ts +2 -0
  313. package/dist/ui/panels/sleep-panel.test.d.ts.map +1 -0
  314. package/dist/ui/panels/sleep-panel.test.js +376 -0
  315. package/dist/ui/panels/sleep-panel.test.js.map +1 -0
  316. package/dist/ui/panels/task-list.d.ts.map +1 -1
  317. package/dist/ui/panels/task-list.js +3 -2
  318. package/dist/ui/panels/task-list.js.map +1 -1
  319. package/dist/ui/panels/task-list.test.d.ts +2 -0
  320. package/dist/ui/panels/task-list.test.d.ts.map +1 -0
  321. package/dist/ui/panels/task-list.test.js +210 -0
  322. package/dist/ui/panels/task-list.test.js.map +1 -0
  323. package/dist/ui/theme.test.d.ts +2 -0
  324. package/dist/ui/theme.test.d.ts.map +1 -0
  325. package/dist/ui/theme.test.js +159 -0
  326. package/dist/ui/theme.test.js.map +1 -0
  327. package/dist/ui/types.d.ts +3 -0
  328. package/dist/ui/types.d.ts.map +1 -1
  329. package/package.json +6 -2
@@ -0,0 +1,1167 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import { createTestDb } from "../../__tests__/db-helper.js";
3
+ // ─── Mocks ───────────────────────────────────────────
4
+ const mockDb = { current: createTestDb() };
5
+ vi.mock("../../store/database.js", () => {
6
+ const getDb = () => mockDb.current;
7
+ class StmtCache {
8
+ cache = new Map();
9
+ stmt(sql) {
10
+ let s = this.cache.get(sql);
11
+ if (!s) {
12
+ s = getDb().prepare(sql);
13
+ this.cache.set(sql, s);
14
+ }
15
+ return s;
16
+ }
17
+ }
18
+ return { getDb, StmtCache, getHeliosDir: () => "/tmp/helios-test" };
19
+ });
20
+ // Mock child_process so isClaudeCliAvailable() returns false — we test raw API mode only
21
+ vi.mock("node:child_process", () => ({
22
+ execSync: vi.fn(() => {
23
+ throw new Error("not found");
24
+ }),
25
+ }));
26
+ // Mock the Agent SDK (not used in API-key mode)
27
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
28
+ query: vi.fn(),
29
+ createSdkMcpServer: vi.fn(),
30
+ tool: vi.fn(),
31
+ }));
32
+ vi.mock("../../paths.js", () => ({
33
+ WEB_SEARCH_TOOL: "web_search",
34
+ debugLog: vi.fn(),
35
+ isDebug: vi.fn(() => false),
36
+ }));
37
+ // ─── Imports (dynamic because of mocks) ──────────────
38
+ const { SessionStore } = await import("../../store/session-store.js");
39
+ const { ClaudeProvider } = await import("./provider.js");
40
+ const { TransientError } = await import("../retry.js");
41
+ const { CHECKPOINT_ACK } = await import("../types.js");
42
+ // ─── Helpers ─────────────────────────────────────────
43
+ function mockAuthManager(creds = { method: "api_key", provider: "claude", apiKey: "sk-test" }) {
44
+ return {
45
+ getCredentials: vi.fn().mockResolvedValue(creds),
46
+ setApiKey: vi.fn(),
47
+ setOAuthTokens: vi.fn(),
48
+ isAuthenticated: vi.fn().mockReturnValue(true),
49
+ tokenStore: {
50
+ isExpired: vi.fn().mockReturnValue(false),
51
+ needsRefresh: vi.fn().mockReturnValue(false),
52
+ },
53
+ registerRefreshHandler: vi.fn(),
54
+ };
55
+ }
56
+ function mockSSEResponse(events, status = 200) {
57
+ const encoder = new TextEncoder();
58
+ const body = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
59
+ const stream = new ReadableStream({
60
+ start(controller) {
61
+ controller.enqueue(encoder.encode(body));
62
+ controller.close();
63
+ },
64
+ });
65
+ return new Response(stream, {
66
+ status,
67
+ headers: { "content-type": "text/event-stream" },
68
+ });
69
+ }
70
+ /** Build a minimal SSE response that produces a text reply. */
71
+ function textSSEResponse(text, usage = { input_tokens: 10, output_tokens: 5 }) {
72
+ return mockSSEResponse([
73
+ { type: "message_start", message: { usage: { input_tokens: usage.input_tokens } } },
74
+ { type: "content_block_start", index: 0, content_block: { type: "text" } },
75
+ { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
76
+ { type: "content_block_stop", index: 0 },
77
+ { type: "message_delta", usage: { output_tokens: usage.output_tokens } },
78
+ { type: "message_stop" },
79
+ ]);
80
+ }
81
+ /** Build a SSE response that includes tool_use blocks. */
82
+ function toolCallSSEResponse(toolCalls, text = "") {
83
+ const events = [
84
+ { type: "message_start", message: { usage: { input_tokens: 10 } } },
85
+ ];
86
+ let idx = 0;
87
+ if (text) {
88
+ events.push({ type: "content_block_start", index: idx, content_block: { type: "text" } }, { type: "content_block_delta", index: idx, delta: { type: "text_delta", text } }, { type: "content_block_stop", index: idx });
89
+ idx++;
90
+ }
91
+ for (const tc of toolCalls) {
92
+ events.push({
93
+ type: "content_block_start",
94
+ index: idx,
95
+ content_block: { type: "tool_use", id: tc.id, name: tc.name },
96
+ }, {
97
+ type: "content_block_delta",
98
+ index: idx,
99
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(tc.args) },
100
+ }, { type: "content_block_stop", index: idx });
101
+ idx++;
102
+ }
103
+ events.push({ type: "message_delta", usage: { output_tokens: 20 } }, { type: "message_stop" });
104
+ return mockSSEResponse(events);
105
+ }
106
+ /** Collect all events from an async generator. */
107
+ async function collect(gen) {
108
+ const events = [];
109
+ for await (const e of gen)
110
+ events.push(e);
111
+ return events;
112
+ }
113
+ /** Create a simple tool definition. */
114
+ function makeTool(name, exec) {
115
+ return {
116
+ name,
117
+ description: `Test tool ${name}`,
118
+ parameters: { type: "object", properties: { input: { type: "string" } }, required: ["input"] },
119
+ execute: exec ?? vi.fn().mockResolvedValue("tool-result"),
120
+ };
121
+ }
122
+ function makeSession(id = "sess-1") {
123
+ return { id, providerId: "claude", createdAt: Date.now(), lastActiveAt: Date.now() };
124
+ }
125
+ // ─── Tests ───────────────────────────────────────────
126
+ describe("ClaudeProvider", () => {
127
+ let store;
128
+ let auth;
129
+ let provider;
130
+ let mockFetch;
131
+ beforeEach(() => {
132
+ mockDb.current = createTestDb();
133
+ store = new SessionStore();
134
+ auth = mockAuthManager();
135
+ provider = new ClaudeProvider(auth, "api", store);
136
+ // Force auth mode since authenticate() reads env
137
+ provider.authMode = "api_key";
138
+ // Clear the CLI-available cache so the mock takes effect
139
+ provider._cliAvailable = null;
140
+ mockFetch = vi.fn().mockResolvedValue(textSSEResponse("Hello"));
141
+ vi.stubGlobal("fetch", mockFetch);
142
+ });
143
+ afterEach(() => {
144
+ vi.unstubAllGlobals();
145
+ });
146
+ // ========== Session Management ==========
147
+ describe("Session Management", () => {
148
+ it("createSession stores system prompt and initializes empty history", async () => {
149
+ const session = await provider.createSession({
150
+ systemPrompt: "You are a researcher",
151
+ });
152
+ expect(session.id).toBeTruthy();
153
+ expect(provider.systemPrompts.get(session.id)).toBe("You are a researcher");
154
+ expect(provider.conversationHistory.get(session.id)).toEqual([]);
155
+ });
156
+ it("createSession with ephemeral flag creates eph- prefixed session", async () => {
157
+ const session = await provider.createSession({ ephemeral: true });
158
+ expect(session.id).toMatch(/^eph-/);
159
+ });
160
+ it("createSession without ephemeral persists to DB", async () => {
161
+ const session = await provider.createSession({});
162
+ const retrieved = store.getSession(session.id);
163
+ expect(retrieved).not.toBeNull();
164
+ expect(retrieved.providerId).toBe("claude");
165
+ });
166
+ it("createSession uses custom model", async () => {
167
+ const session = await provider.createSession({ model: "claude-sonnet-4-6" });
168
+ const retrieved = store.getSession(session.id);
169
+ expect(retrieved).not.toBeNull();
170
+ });
171
+ it("resumeSession loads session from DB", async () => {
172
+ const created = await provider.createSession({});
173
+ // Clear in-memory state to simulate fresh process
174
+ provider.conversationHistory.delete(created.id);
175
+ const resumed = await provider.resumeSession(created.id);
176
+ expect(resumed.id).toBe(created.id);
177
+ });
178
+ it("resumeSession restores conversation history from stored messages", async () => {
179
+ const created = await provider.createSession({});
180
+ store.addMessage(created.id, "user", "Hello");
181
+ store.addMessage(created.id, "assistant", "Hi there");
182
+ // Clear in-memory state
183
+ provider.conversationHistory.delete(created.id);
184
+ await provider.resumeSession(created.id);
185
+ const history = provider.conversationHistory.get(created.id);
186
+ expect(history).toHaveLength(2);
187
+ expect(history[0]).toEqual({ role: "user", content: "Hello" });
188
+ expect(history[1]).toEqual({ role: "assistant", content: "Hi there" });
189
+ });
190
+ it("resumeSession with empty history initializes empty array", async () => {
191
+ const created = await provider.createSession({});
192
+ provider.conversationHistory.delete(created.id);
193
+ await provider.resumeSession(created.id);
194
+ const history = provider.conversationHistory.get(created.id);
195
+ expect(history).toEqual([]);
196
+ });
197
+ it("resumeSession throws for unknown session ID", async () => {
198
+ await expect(provider.resumeSession("nonexistent")).rejects.toThrow("Session nonexistent not found");
199
+ });
200
+ it("resumeSession restores system prompt", async () => {
201
+ const created = await provider.createSession({});
202
+ provider.conversationHistory.delete(created.id);
203
+ await provider.resumeSession(created.id, "You are a scientist");
204
+ expect(provider.systemPrompts.get(created.id)).toBe("You are a scientist");
205
+ });
206
+ it("resumeSession restores SDK session ID mapping", async () => {
207
+ const created = store.createSession("claude");
208
+ store.updateProviderSessionId(created.id, "sdk-abc");
209
+ await provider.resumeSession(created.id);
210
+ expect(provider.sdkSessionIds.get(created.id)).toBe("sdk-abc");
211
+ });
212
+ it("resumeSession does not overwrite existing in-memory history", async () => {
213
+ const created = await provider.createSession({});
214
+ const existingHistory = [{ role: "user", content: "already loaded" }];
215
+ provider.conversationHistory.set(created.id, existingHistory);
216
+ store.addMessage(created.id, "user", "from DB");
217
+ await provider.resumeSession(created.id);
218
+ const history = provider.conversationHistory.get(created.id);
219
+ expect(history).toHaveLength(1);
220
+ expect(history[0].content).toBe("already loaded");
221
+ });
222
+ it("closeSession clears all internal state", async () => {
223
+ const session = await provider.createSession({ systemPrompt: "test" });
224
+ provider.sdkSessionIds.set(session.id, "sdk-1");
225
+ await provider.closeSession(session);
226
+ expect(provider.conversationHistory.has(session.id)).toBe(false);
227
+ expect(provider.systemPrompts.has(session.id)).toBe(false);
228
+ expect(provider.sdkSessionIds.has(session.id)).toBe(false);
229
+ });
230
+ it("fetchModels returns Claude models", async () => {
231
+ const models = await provider.fetchModels();
232
+ expect(models).toHaveLength(2);
233
+ expect(models[0].id).toBe("claude-opus-4-6");
234
+ expect(models[1].id).toBe("claude-sonnet-4-6");
235
+ });
236
+ });
237
+ // ========== History Management (CRITICAL BUG AREA) ==========
238
+ describe("History Management", () => {
239
+ it("after send(), conversation history includes the user message", async () => {
240
+ const session = await provider.createSession({});
241
+ await collect(provider.send(session, "What is ML?", []));
242
+ const history = provider.conversationHistory.get(session.id);
243
+ expect(history.some((m) => m.role === "user" && m.content === "What is ML?")).toBe(true);
244
+ });
245
+ it("after send() with text response, history includes assistant message", async () => {
246
+ const session = await provider.createSession({});
247
+ await collect(provider.send(session, "Hi", []));
248
+ const history = provider.conversationHistory.get(session.id);
249
+ expect(history.some((m) => m.role === "assistant" && m.content === "Hello")).toBe(true);
250
+ });
251
+ it("after send() with tool calls, history includes tool_use and tool_result blocks", async () => {
252
+ const tool = makeTool("remote_exec");
253
+ mockFetch
254
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "remote_exec", args: { input: "ls" } }]))
255
+ .mockResolvedValueOnce(textSSEResponse("Done"));
256
+ const session = await provider.createSession({});
257
+ await collect(provider.send(session, "Run ls", [tool]));
258
+ const history = provider.conversationHistory.get(session.id);
259
+ // Should have: user "Run ls", assistant [tool_use], user [tool_result], assistant "Done"
260
+ expect(history.length).toBeGreaterThanOrEqual(4);
261
+ const assistantWithToolUse = history.find((m) => m.role === "assistant" &&
262
+ Array.isArray(m.content) &&
263
+ m.content.some((b) => b.type === "tool_use"));
264
+ expect(assistantWithToolUse).toBeDefined();
265
+ const userWithToolResult = history.find((m) => m.role === "user" &&
266
+ Array.isArray(m.content) &&
267
+ m.content.some((b) => b.type === "tool_result"));
268
+ expect(userWithToolResult).toBeDefined();
269
+ });
270
+ it("resume then send: the API request includes previous messages (THE BUG TEST)", async () => {
271
+ // Simulate a session that was previously used, with stored messages in the DB
272
+ const created = await provider.createSession({});
273
+ store.addMessage(created.id, "user", "First question");
274
+ store.addMessage(created.id, "assistant", "First answer");
275
+ // Simulate a fresh process — clear in-memory state
276
+ provider.conversationHistory.delete(created.id);
277
+ await provider.resumeSession(created.id);
278
+ // Now send a new message
279
+ await collect(provider.send(created, "Second question", []));
280
+ // Verify the fetch call body contains the restored history
281
+ expect(mockFetch).toHaveBeenCalled();
282
+ const lastCall = mockFetch.mock.calls[0];
283
+ const requestBody = JSON.parse(lastCall[1].body);
284
+ // The messages array should contain: restored user, restored assistant, new user
285
+ expect(requestBody.messages).toHaveLength(3);
286
+ expect(requestBody.messages[0]).toEqual({ role: "user", content: "First question" });
287
+ expect(requestBody.messages[1]).toEqual({ role: "assistant", content: "First answer" });
288
+ expect(requestBody.messages[2]).toEqual({ role: "user", content: "Second question" });
289
+ });
290
+ it("resume then send: fetch body includes ALL restored messages in order", async () => {
291
+ const created = await provider.createSession({});
292
+ store.addMessage(created.id, "user", "msg-1");
293
+ store.addMessage(created.id, "assistant", "reply-1");
294
+ store.addMessage(created.id, "user", "msg-2");
295
+ store.addMessage(created.id, "assistant", "reply-2");
296
+ store.addMessage(created.id, "user", "msg-3");
297
+ store.addMessage(created.id, "assistant", "reply-3");
298
+ provider.conversationHistory.delete(created.id);
299
+ await provider.resumeSession(created.id);
300
+ await collect(provider.send(created, "msg-4", []));
301
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
302
+ expect(requestBody.messages).toHaveLength(7); // 6 restored + 1 new
303
+ expect(requestBody.messages[0].content).toBe("msg-1");
304
+ expect(requestBody.messages[5].content).toBe("reply-3");
305
+ expect(requestBody.messages[6].content).toBe("msg-4");
306
+ });
307
+ it("multiple resume/send cycles maintain correct history", async () => {
308
+ // First session: create and send
309
+ const created = await provider.createSession({});
310
+ store.addMessage(created.id, "user", "Turn 1");
311
+ store.addMessage(created.id, "assistant", "Reply 1");
312
+ // First resume
313
+ provider.conversationHistory.delete(created.id);
314
+ await provider.resumeSession(created.id);
315
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 2"));
316
+ await collect(provider.send(created, "Turn 2", []));
317
+ // Verify first resume+send
318
+ let requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
319
+ expect(requestBody.messages).toHaveLength(3);
320
+ // After send, in-memory history should have: Turn1, Reply1, Turn2, Reply2 (from Hello response)
321
+ // Actually the text response is "Reply 2" from our mock
322
+ const historyAfterFirst = provider.conversationHistory.get(created.id);
323
+ expect(historyAfterFirst).toHaveLength(4);
324
+ // Simulate persisting and resuming again (without clearing)
325
+ // Send again — should have accumulated history
326
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 3"));
327
+ await collect(provider.send(created, "Turn 3", []));
328
+ requestBody = JSON.parse(mockFetch.mock.calls[1][1].body);
329
+ // Should include: Turn1, Reply1, Turn2, Reply2, Turn3
330
+ expect(requestBody.messages).toHaveLength(5);
331
+ expect(requestBody.messages[4].content).toBe("Turn 3");
332
+ });
333
+ it("resume with tool message history filters to user/assistant only", async () => {
334
+ const created = await provider.createSession({});
335
+ store.addMessage(created.id, "user", "Run command");
336
+ store.addMessage(created.id, "assistant", "Running...");
337
+ store.addMessage(created.id, "tool", "tool output"); // should be filtered
338
+ store.addMessage(created.id, "system", "system msg"); // should be filtered
339
+ provider.conversationHistory.delete(created.id);
340
+ await provider.resumeSession(created.id);
341
+ const history = provider.conversationHistory.get(created.id);
342
+ expect(history).toHaveLength(2);
343
+ expect(history[0].role).toBe("user");
344
+ expect(history[1].role).toBe("assistant");
345
+ });
346
+ it("multiple sends accumulate in history", async () => {
347
+ const session = await provider.createSession({});
348
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 1"));
349
+ await collect(provider.send(session, "msg 1", []));
350
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 2"));
351
+ await collect(provider.send(session, "msg 2", []));
352
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Reply 3"));
353
+ await collect(provider.send(session, "msg 3", []));
354
+ const history = provider.conversationHistory.get(session.id);
355
+ expect(history).toHaveLength(6); // 3 user + 3 assistant
356
+ expect(history[0].content).toBe("msg 1");
357
+ expect(history[1].content).toBe("Reply 1");
358
+ expect(history[4].content).toBe("msg 3");
359
+ expect(history[5].content).toBe("Reply 3");
360
+ });
361
+ it("resetHistory replaces entire history with briefing + ACK", async () => {
362
+ const session = await provider.createSession({});
363
+ mockFetch.mockResolvedValueOnce(textSSEResponse("reply"));
364
+ await collect(provider.send(session, "old message", []));
365
+ provider.resetHistory(session, "Here is your memory checkpoint");
366
+ const history = provider.conversationHistory.get(session.id);
367
+ expect(history).toHaveLength(2);
368
+ expect(history[0]).toEqual({ role: "user", content: "Here is your memory checkpoint" });
369
+ expect(history[1]).toEqual({ role: "assistant", content: CHECKPOINT_ACK });
370
+ });
371
+ it("after resetHistory + send, only briefing + ACK + new message in history", async () => {
372
+ const session = await provider.createSession({});
373
+ // Send some messages first
374
+ mockFetch.mockResolvedValueOnce(textSSEResponse("old reply"));
375
+ await collect(provider.send(session, "old msg", []));
376
+ provider.resetHistory(session, "Briefing");
377
+ mockFetch.mockResolvedValueOnce(textSSEResponse("new reply"));
378
+ await collect(provider.send(session, "new msg", []));
379
+ const requestBody = JSON.parse(mockFetch.mock.calls[1][1].body);
380
+ expect(requestBody.messages).toHaveLength(3);
381
+ expect(requestBody.messages[0].content).toBe("Briefing");
382
+ expect(requestBody.messages[1].content).toBe(CHECKPOINT_ACK);
383
+ expect(requestBody.messages[2].content).toBe("new msg");
384
+ });
385
+ it("attachment data is stripped from history after send", async () => {
386
+ const session = await provider.createSession({});
387
+ const longData = "A".repeat(200);
388
+ const attachments = [
389
+ { filename: "img.png", mediaType: "image/png", data: longData },
390
+ ];
391
+ await collect(provider.send(session, "Look at this", [], attachments));
392
+ const history = provider.conversationHistory.get(session.id);
393
+ // The first user message should have had its image block stripped
394
+ const firstUser = history[0];
395
+ expect(Array.isArray(firstUser.content)).toBe(true);
396
+ const imageBlock = firstUser.content.find((b) => b.type === "image");
397
+ // Should be replaced with text placeholder
398
+ expect(imageBlock).toBeUndefined();
399
+ const textPlaceholder = firstUser.content.find((b) => typeof b.text === "string" && b.text.includes("stripped"));
400
+ expect(textPlaceholder).toBeDefined();
401
+ });
402
+ it("image attachments create multimodal content blocks in user message", async () => {
403
+ const session = await provider.createSession({});
404
+ const attachments = [
405
+ { filename: "img.png", mediaType: "image/png", data: "abc" },
406
+ ];
407
+ await collect(provider.send(session, "describe this", [], attachments));
408
+ // Check the fetch request body
409
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
410
+ const userMsg = requestBody.messages[0];
411
+ expect(Array.isArray(userMsg.content)).toBe(true);
412
+ expect(userMsg.content[0].type).toBe("image");
413
+ expect(userMsg.content[0].source.type).toBe("base64");
414
+ expect(userMsg.content[1].type).toBe("text");
415
+ expect(userMsg.content[1].text).toBe("describe this");
416
+ });
417
+ it("PDF attachments create document content blocks", async () => {
418
+ const session = await provider.createSession({});
419
+ const attachments = [
420
+ { filename: "doc.pdf", mediaType: "application/pdf", data: "pdf-data" },
421
+ ];
422
+ await collect(provider.send(session, "read this", [], attachments));
423
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
424
+ const userMsg = requestBody.messages[0];
425
+ expect(Array.isArray(userMsg.content)).toBe(true);
426
+ expect(userMsg.content[0].type).toBe("document");
427
+ expect(userMsg.content[0].source.media_type).toBe("application/pdf");
428
+ });
429
+ it("resumed session history is correctly sent on first API call after resume", async () => {
430
+ // This is a regression test for the critical bug:
431
+ // When resuming, the history was not being sent with the API request.
432
+ const created = await provider.createSession({});
433
+ // Simulate 5 turns of conversation stored in DB
434
+ for (let i = 1; i <= 5; i++) {
435
+ store.addMessage(created.id, "user", `User turn ${i}`);
436
+ store.addMessage(created.id, "assistant", `Assistant turn ${i}`);
437
+ }
438
+ // Fresh process resume
439
+ provider.conversationHistory.delete(created.id);
440
+ await provider.resumeSession(created.id, "System prompt");
441
+ // Send a new message
442
+ await collect(provider.send(created, "New question", []));
443
+ // The API call must include all 10 restored messages + the new one
444
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
445
+ expect(requestBody.messages).toHaveLength(11);
446
+ expect(requestBody.messages[0]).toEqual({ role: "user", content: "User turn 1" });
447
+ expect(requestBody.messages[9]).toEqual({ role: "assistant", content: "Assistant turn 5" });
448
+ expect(requestBody.messages[10]).toEqual({ role: "user", content: "New question" });
449
+ expect(requestBody.system).toBe("System prompt");
450
+ });
451
+ it("tool result multimodal content is handled correctly", async () => {
452
+ const multimodalResult = JSON.stringify({
453
+ __multimodal: true,
454
+ text: "Here is the chart",
455
+ attachments: [{ mediaType: "image/png", data: "chartdata" }],
456
+ });
457
+ const tool = makeTool("plot", vi.fn().mockResolvedValue(multimodalResult));
458
+ mockFetch
459
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "plot", args: {} }]))
460
+ .mockResolvedValueOnce(textSSEResponse("Done"));
461
+ const session = await provider.createSession({});
462
+ await collect(provider.send(session, "Plot data", [tool]));
463
+ const history = provider.conversationHistory.get(session.id);
464
+ // Find the tool_result with multimodal content
465
+ const toolResultMsg = history.find((m) => m.role === "user" &&
466
+ Array.isArray(m.content) &&
467
+ m.content.some((b) => b.type === "tool_result"));
468
+ expect(toolResultMsg).toBeDefined();
469
+ const trBlock = toolResultMsg.content.find((b) => b.type === "tool_result");
470
+ expect(Array.isArray(trBlock.content)).toBe(true);
471
+ // Image block data should be stripped since it's > 100 chars... well "chartdata" is only 9 chars
472
+ // The content should have image + text blocks
473
+ expect(trBlock.content.some((b) => b.type === "image")).toBe(true);
474
+ });
475
+ });
476
+ // ========== Send / Streaming ==========
477
+ describe("Send / Streaming", () => {
478
+ it("send() yields text events for streaming deltas", async () => {
479
+ const session = await provider.createSession({});
480
+ const events = await collect(provider.send(session, "hi", []));
481
+ const textEvents = events.filter((e) => e.type === "text");
482
+ expect(textEvents.length).toBeGreaterThanOrEqual(1);
483
+ expect(textEvents[0].delta).toBe("Hello");
484
+ });
485
+ it("send() yields done event with usage stats", async () => {
486
+ const session = await provider.createSession({});
487
+ const events = await collect(provider.send(session, "hi", []));
488
+ const doneEvent = events.find((e) => e.type === "done");
489
+ expect(doneEvent).toBeDefined();
490
+ expect(doneEvent.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
491
+ });
492
+ it("send() yields tool_call events for tool_use blocks", async () => {
493
+ const tool = makeTool("my_tool");
494
+ mockFetch
495
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "my_tool", args: { input: "x" } }]))
496
+ .mockResolvedValueOnce(textSSEResponse("Final"));
497
+ const session = await provider.createSession({});
498
+ const events = await collect(provider.send(session, "use tool", [tool]));
499
+ const toolCallEvents = events.filter((e) => e.type === "tool_call");
500
+ expect(toolCallEvents).toHaveLength(1);
501
+ expect(toolCallEvents[0].name).toBe("my_tool");
502
+ expect(toolCallEvents[0].args).toEqual({ input: "x" });
503
+ });
504
+ it("send() executes tools and yields tool_result events", async () => {
505
+ const tool = makeTool("my_tool", vi.fn().mockResolvedValue("executed!"));
506
+ mockFetch
507
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "my_tool", args: {} }]))
508
+ .mockResolvedValueOnce(textSSEResponse("Ok"));
509
+ const session = await provider.createSession({});
510
+ const events = await collect(provider.send(session, "run", [tool]));
511
+ const resultEvents = events.filter((e) => e.type === "tool_result");
512
+ expect(resultEvents.length).toBeGreaterThanOrEqual(1);
513
+ expect(resultEvents[0].result).toBe("executed!");
514
+ expect(resultEvents[0].callId).toBe("tc-1");
515
+ });
516
+ it("unknown tool returns error result", async () => {
517
+ mockFetch
518
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "nonexistent", args: {} }]))
519
+ .mockResolvedValueOnce(textSSEResponse("Ok"));
520
+ const session = await provider.createSession({});
521
+ const events = await collect(provider.send(session, "run", []));
522
+ const resultEvents = events.filter((e) => e.type === "tool_result");
523
+ expect(resultEvents[0].result).toBe("Unknown tool: nonexistent");
524
+ expect(resultEvents[0].isError).toBe(true);
525
+ });
526
+ it("tool execution error is caught and returned as error result", async () => {
527
+ const tool = makeTool("broken", vi.fn().mockRejectedValue(new Error("tool broke")));
528
+ mockFetch
529
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "broken", args: {} }]))
530
+ .mockResolvedValueOnce(textSSEResponse("Ok"));
531
+ const session = await provider.createSession({});
532
+ const events = await collect(provider.send(session, "run", [tool]));
533
+ const resultEvents = events.filter((e) => e.type === "tool_result");
534
+ expect(resultEvents[0].result).toContain("Error:");
535
+ expect(resultEvents[0].isError).toBe(true);
536
+ });
537
+ it("tool execution timeout after 300s", async () => {
538
+ vi.useFakeTimers();
539
+ const neverResolve = new Promise(() => { });
540
+ const tool = makeTool("slow", vi.fn().mockReturnValue(neverResolve));
541
+ mockFetch
542
+ .mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "slow", args: {} }]))
543
+ .mockResolvedValueOnce(textSSEResponse("Ok"));
544
+ const session = await provider.createSession({});
545
+ const genPromise = collect(provider.send(session, "run", [tool]));
546
+ // Advance past the 300s timeout
547
+ await vi.advanceTimersByTimeAsync(300_001);
548
+ const events = await genPromise;
549
+ const resultEvents = events.filter((e) => e.type === "tool_result");
550
+ expect(resultEvents[0].result).toContain("timed out");
551
+ expect(resultEvents[0].isError).toBe(true);
552
+ vi.useRealTimers();
553
+ });
554
+ it("server tool calls (web_search) yield events", async () => {
555
+ const events = [
556
+ { type: "message_start", message: { usage: { input_tokens: 10 } } },
557
+ {
558
+ type: "content_block_start",
559
+ index: 0,
560
+ content_block: { type: "server_tool_use", id: "st-1", name: "web_search" },
561
+ },
562
+ {
563
+ type: "content_block_delta",
564
+ index: 0,
565
+ delta: { type: "input_json_delta", partial_json: '{"query":"test"}' },
566
+ },
567
+ { type: "content_block_stop", index: 0 },
568
+ {
569
+ type: "content_block_start",
570
+ index: 1,
571
+ content_block: { type: "web_search_tool_result" },
572
+ },
573
+ { type: "content_block_stop", index: 1 },
574
+ {
575
+ type: "content_block_start",
576
+ index: 2,
577
+ content_block: { type: "text" },
578
+ },
579
+ { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Search result" } },
580
+ { type: "content_block_stop", index: 2 },
581
+ { type: "message_delta", usage: { output_tokens: 5 } },
582
+ { type: "message_stop" },
583
+ ];
584
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(events));
585
+ const session = await provider.createSession({});
586
+ const webTool = { name: "web_search", description: "search", parameters: { type: "object", properties: {}, required: [] }, execute: vi.fn() };
587
+ const result = await collect(provider.send(session, "search for X", [webTool]));
588
+ const toolCalls = result.filter((e) => e.type === "tool_call");
589
+ expect(toolCalls).toHaveLength(1);
590
+ expect(toolCalls[0].name).toBe("web_search");
591
+ const toolResults = result.filter((e) => e.type === "tool_result");
592
+ expect(toolResults[0].result).toBe("(server-executed)");
593
+ });
594
+ it("retry on transient error (429)", async () => {
595
+ const error429 = new Response("rate limited", { status: 429 });
596
+ mockFetch
597
+ .mockResolvedValueOnce(error429)
598
+ .mockResolvedValueOnce(textSSEResponse("OK after retry"));
599
+ vi.useFakeTimers();
600
+ const session = await provider.createSession({});
601
+ const genPromise = collect(provider.send(session, "hi", []));
602
+ await vi.advanceTimersByTimeAsync(2000);
603
+ const events = await genPromise;
604
+ vi.useRealTimers();
605
+ const doneEvent = events.find((e) => e.type === "done");
606
+ expect(doneEvent).toBeDefined();
607
+ expect(mockFetch).toHaveBeenCalledTimes(2);
608
+ });
609
+ it("retry on transient error (500)", async () => {
610
+ const error500 = new Response("server error", { status: 500 });
611
+ mockFetch
612
+ .mockResolvedValueOnce(error500)
613
+ .mockResolvedValueOnce(textSSEResponse("OK"));
614
+ vi.useFakeTimers();
615
+ const session = await provider.createSession({});
616
+ const genPromise = collect(provider.send(session, "hi", []));
617
+ await vi.advanceTimersByTimeAsync(2000);
618
+ const events = await genPromise;
619
+ vi.useRealTimers();
620
+ expect(mockFetch).toHaveBeenCalledTimes(2);
621
+ expect(events.some((e) => e.type === "done")).toBe(true);
622
+ });
623
+ it("non-transient error (400) throws immediately", async () => {
624
+ mockFetch.mockResolvedValueOnce(new Response("bad request", { status: 400 }));
625
+ const session = await provider.createSession({});
626
+ await expect(collect(provider.send(session, "hi", []))).rejects.toThrow("400");
627
+ expect(mockFetch).toHaveBeenCalledTimes(1);
628
+ });
629
+ it("overloaded SSE error is transient and retried", async () => {
630
+ const overloadedResponse = mockSSEResponse([
631
+ { type: "error", error: { type: "overloaded_error", message: "Overloaded" } },
632
+ ]);
633
+ mockFetch
634
+ .mockResolvedValueOnce(overloadedResponse)
635
+ .mockResolvedValueOnce(textSSEResponse("OK"));
636
+ vi.useFakeTimers();
637
+ const session = await provider.createSession({});
638
+ const genPromise = collect(provider.send(session, "hi", []));
639
+ await vi.advanceTimersByTimeAsync(2000);
640
+ const events = await genPromise;
641
+ vi.useRealTimers();
642
+ expect(events.some((e) => e.type === "done")).toBe(true);
643
+ });
644
+ it("empty response yields done with no usage", async () => {
645
+ // An empty SSE stream still produces a complete event from streamRawApi
646
+ // but with empty text and no usage — the provider yields done with undefined usage
647
+ const emptyStream = new ReadableStream({
648
+ start(controller) {
649
+ controller.close();
650
+ },
651
+ });
652
+ mockFetch.mockResolvedValueOnce(new Response(emptyStream, { status: 200, headers: { "content-type": "text/event-stream" } }));
653
+ const session = await provider.createSession({});
654
+ const events = await collect(provider.send(session, "hi", []));
655
+ const doneEvent = events.find((e) => e.type === "done");
656
+ expect(doneEvent).toBeDefined();
657
+ expect(doneEvent.usage).toBeUndefined();
658
+ });
659
+ it("null body response throws error", async () => {
660
+ // A response with null body causes parseSSELines to throw "No response body"
661
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 200, headers: { "content-type": "text/event-stream" } }));
662
+ const session = await provider.createSession({});
663
+ await expect(collect(provider.send(session, "hi", []))).rejects.toThrow("No response body");
664
+ });
665
+ it("multiple tool calls in single response", async () => {
666
+ const tool1 = makeTool("tool_a", vi.fn().mockResolvedValue("result-a"));
667
+ const tool2 = makeTool("tool_b", vi.fn().mockResolvedValue("result-b"));
668
+ mockFetch
669
+ .mockResolvedValueOnce(toolCallSSEResponse([
670
+ { id: "tc-1", name: "tool_a", args: { input: "1" } },
671
+ { id: "tc-2", name: "tool_b", args: { input: "2" } },
672
+ ]))
673
+ .mockResolvedValueOnce(textSSEResponse("Both done"));
674
+ const session = await provider.createSession({});
675
+ const events = await collect(provider.send(session, "run both", [tool1, tool2]));
676
+ const toolCalls = events.filter((e) => e.type === "tool_call");
677
+ expect(toolCalls).toHaveLength(2);
678
+ expect(toolCalls[0].name).toBe("tool_a");
679
+ expect(toolCalls[1].name).toBe("tool_b");
680
+ const toolResults = events.filter((e) => e.type === "tool_result");
681
+ expect(toolResults).toHaveLength(2);
682
+ });
683
+ it("tool loop: tool calls -> results -> continue -> text -> done", async () => {
684
+ const tool = makeTool("read_file", vi.fn().mockResolvedValue("file content"));
685
+ // First response: tool call
686
+ mockFetch.mockResolvedValueOnce(toolCallSSEResponse([{ id: "tc-1", name: "read_file", args: { input: "main.py" } }]));
687
+ // Second response: final text
688
+ mockFetch.mockResolvedValueOnce(textSSEResponse("Here is what I found"));
689
+ const session = await provider.createSession({});
690
+ const events = await collect(provider.send(session, "read main.py", [tool]));
691
+ // Verify the sequence of events
692
+ const types = events.map((e) => e.type);
693
+ expect(types).toContain("tool_call");
694
+ expect(types).toContain("tool_result");
695
+ expect(types).toContain("text");
696
+ expect(types).toContain("done");
697
+ // Done should be last
698
+ expect(types[types.length - 1]).toBe("done");
699
+ });
700
+ it("send includes system prompt in request body", async () => {
701
+ const session = await provider.createSession({ systemPrompt: "Be helpful" });
702
+ await collect(provider.send(session, "hi", []));
703
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
704
+ expect(requestBody.system).toBe("Be helpful");
705
+ });
706
+ it("send includes thinking budget based on reasoningEffort", async () => {
707
+ provider.reasoningEffort = "high";
708
+ const session = await provider.createSession({});
709
+ await collect(provider.send(session, "hi", []));
710
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
711
+ expect(requestBody.thinking).toEqual({ type: "enabled", budget_tokens: 50000 });
712
+ });
713
+ it("send includes tool definitions in request body", async () => {
714
+ const tool = makeTool("my_tool");
715
+ const session = await provider.createSession({});
716
+ await collect(provider.send(session, "hi", [tool]));
717
+ const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
718
+ expect(requestBody.tools.some((t) => t.name === "my_tool")).toBe(true);
719
+ });
720
+ it("send sets correct headers", async () => {
721
+ const session = await provider.createSession({});
722
+ await collect(provider.send(session, "hi", []));
723
+ const headers = mockFetch.mock.calls[0][1].headers;
724
+ expect(headers["x-api-key"]).toBe("sk-test");
725
+ expect(headers["Content-Type"]).toBe("application/json");
726
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
727
+ });
728
+ it("send handles thinking content blocks silently", async () => {
729
+ const events = [
730
+ { type: "message_start", message: { usage: { input_tokens: 10 } } },
731
+ { type: "content_block_start", index: 0, content_block: { type: "thinking" } },
732
+ { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Let me think..." } },
733
+ { type: "content_block_stop", index: 0 },
734
+ { type: "content_block_start", index: 1, content_block: { type: "text" } },
735
+ { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Result" } },
736
+ { type: "content_block_stop", index: 1 },
737
+ { type: "message_delta", usage: { output_tokens: 5 } },
738
+ { type: "message_stop" },
739
+ ];
740
+ mockFetch.mockResolvedValueOnce(mockSSEResponse(events));
741
+ const session = await provider.createSession({});
742
+ const result = await collect(provider.send(session, "think", []));
743
+ const textEvents = result.filter((e) => e.type === "text");
744
+ expect(textEvents).toHaveLength(1);
745
+ expect(textEvents[0].text).toBe("Result");
746
+ });
747
+ });
748
+ // ========== Auth ==========
749
+ describe("Auth", () => {
750
+ it("isAuthenticated returns true when API key exists", async () => {
751
+ const result = await provider.isAuthenticated();
752
+ // CLI not available, falls through to authManager.isAuthenticated
753
+ expect(result).toBe(true);
754
+ expect(auth.isAuthenticated).toHaveBeenCalledWith("claude");
755
+ });
756
+ it("isAuthenticated returns false when no credentials", async () => {
757
+ auth.isAuthenticated.mockReturnValue(false);
758
+ const result = await provider.isAuthenticated();
759
+ expect(result).toBe(false);
760
+ });
761
+ it("authenticate with preferred mode api sets api_key mode", async () => {
762
+ const p = new ClaudeProvider(auth, "api", store);
763
+ process.env.ANTHROPIC_API_KEY = "test-key";
764
+ try {
765
+ await p.authenticate();
766
+ expect(p.currentAuthMode).toBe("api_key");
767
+ expect(auth.setApiKey).toHaveBeenCalledWith("claude", "test-key");
768
+ }
769
+ finally {
770
+ delete process.env.ANTHROPIC_API_KEY;
771
+ }
772
+ });
773
+ it("authenticate with preferred mode api throws if no ANTHROPIC_API_KEY", async () => {
774
+ const p = new ClaudeProvider(auth, "api", store);
775
+ const saved = process.env.ANTHROPIC_API_KEY;
776
+ delete process.env.ANTHROPIC_API_KEY;
777
+ try {
778
+ await expect(p.authenticate()).rejects.toThrow("ANTHROPIC_API_KEY not set");
779
+ }
780
+ finally {
781
+ if (saved)
782
+ process.env.ANTHROPIC_API_KEY = saved;
783
+ }
784
+ });
785
+ it("authenticate auto-detects API key from env", async () => {
786
+ const p = new ClaudeProvider(auth, undefined, store);
787
+ // Force CLI unavailable
788
+ p._cliAvailable = false;
789
+ process.env.ANTHROPIC_API_KEY = "env-key";
790
+ try {
791
+ await p.authenticate();
792
+ expect(p.currentAuthMode).toBe("api_key");
793
+ }
794
+ finally {
795
+ delete process.env.ANTHROPIC_API_KEY;
796
+ }
797
+ });
798
+ it("authenticate throws when no method available", async () => {
799
+ const p = new ClaudeProvider(auth, undefined, store);
800
+ p._cliAvailable = false;
801
+ const saved = process.env.ANTHROPIC_API_KEY;
802
+ delete process.env.ANTHROPIC_API_KEY;
803
+ try {
804
+ await expect(p.authenticate()).rejects.toThrow("Claude auth required");
805
+ }
806
+ finally {
807
+ if (saved)
808
+ process.env.ANTHROPIC_API_KEY = saved;
809
+ }
810
+ });
811
+ it("send throws if no API key available", async () => {
812
+ const noKeyAuth = mockAuthManager({ method: "api_key", provider: "claude", apiKey: null });
813
+ const p = new ClaudeProvider(noKeyAuth, "api", store);
814
+ p.authMode = "api_key";
815
+ const session = await p.createSession({});
816
+ await expect(collect(p.send(session, "hi", []))).rejects.toThrow("No API key");
817
+ });
818
+ it("setPreferredAuthMode updates preferred mode", () => {
819
+ provider.setPreferredAuthMode("cli");
820
+ expect(provider.preferredAuthMode).toBe("cli");
821
+ provider.setPreferredAuthMode("api");
822
+ expect(provider.preferredAuthMode).toBe("api_key");
823
+ });
824
+ it("authenticate with preferred mode cli throws if not available", async () => {
825
+ const p = new ClaudeProvider(auth, "cli", store);
826
+ p._cliAvailable = false;
827
+ await expect(p.authenticate()).rejects.toThrow("Claude CLI mode requested but `claude` binary not found");
828
+ });
829
+ });
830
+ // ========== Utility Methods ==========
831
+ describe("Utility Methods", () => {
832
+ it("stripMcpPrefix removes mcp__helios__ prefix", () => {
833
+ const result = provider.stripMcpPrefix("mcp__helios__remote_exec");
834
+ expect(result).toBe("remote_exec");
835
+ });
836
+ it("stripMcpPrefix leaves non-prefixed names unchanged", () => {
837
+ const result = provider.stripMcpPrefix("remote_exec");
838
+ expect(result).toBe("remote_exec");
839
+ });
840
+ it("stripMcpPrefix handles empty string", () => {
841
+ const result = provider.stripMcpPrefix("");
842
+ expect(result).toBe("");
843
+ });
844
+ it("stripMcpPrefix handles partial prefix", () => {
845
+ const result = provider.stripMcpPrefix("mcp__other__tool");
846
+ expect(result).toBe("mcp__other__tool");
847
+ });
848
+ it("buildZodSchema handles string type", () => {
849
+ const tool = makeTool("t");
850
+ const schema = provider.buildZodSchema(tool);
851
+ expect(schema.input).toBeDefined();
852
+ });
853
+ it("buildZodSchema handles number type", () => {
854
+ const tool = {
855
+ name: "t",
856
+ description: "d",
857
+ parameters: {
858
+ type: "object",
859
+ properties: { count: { type: "number" } },
860
+ required: ["count"],
861
+ },
862
+ };
863
+ const schema = provider.buildZodSchema(tool);
864
+ expect(schema.count).toBeDefined();
865
+ });
866
+ it("buildZodSchema handles boolean type", () => {
867
+ const tool = {
868
+ name: "t",
869
+ description: "d",
870
+ parameters: {
871
+ type: "object",
872
+ properties: { flag: { type: "boolean" } },
873
+ required: ["flag"],
874
+ },
875
+ };
876
+ const schema = provider.buildZodSchema(tool);
877
+ expect(schema.flag).toBeDefined();
878
+ });
879
+ it("buildZodSchema handles array type", () => {
880
+ const tool = {
881
+ name: "t",
882
+ description: "d",
883
+ parameters: {
884
+ type: "object",
885
+ properties: { items: { type: "array" } },
886
+ required: ["items"],
887
+ },
888
+ };
889
+ const schema = provider.buildZodSchema(tool);
890
+ expect(schema.items).toBeDefined();
891
+ });
892
+ it("buildZodSchema handles object type", () => {
893
+ const tool = {
894
+ name: "t",
895
+ description: "d",
896
+ parameters: {
897
+ type: "object",
898
+ properties: { config: { type: "object" } },
899
+ required: ["config"],
900
+ },
901
+ };
902
+ const schema = provider.buildZodSchema(tool);
903
+ expect(schema.config).toBeDefined();
904
+ });
905
+ it("buildZodSchema handles enum types", () => {
906
+ const tool = {
907
+ name: "t",
908
+ description: "d",
909
+ parameters: {
910
+ type: "object",
911
+ properties: { mode: { type: "string", enum: ["fast", "slow"] } },
912
+ required: ["mode"],
913
+ },
914
+ };
915
+ const schema = provider.buildZodSchema(tool);
916
+ expect(schema.mode).toBeDefined();
917
+ });
918
+ it("buildZodSchema marks non-required fields as optional", () => {
919
+ const tool = {
920
+ name: "t",
921
+ description: "d",
922
+ parameters: {
923
+ type: "object",
924
+ properties: {
925
+ req: { type: "string" },
926
+ opt: { type: "string" },
927
+ },
928
+ required: ["req"],
929
+ },
930
+ };
931
+ const schema = provider.buildZodSchema(tool);
932
+ expect(schema.req.isOptional()).toBe(false);
933
+ expect(schema.opt.isOptional()).toBe(true);
934
+ });
935
+ it("buildZodSchema adds descriptions", () => {
936
+ const tool = {
937
+ name: "t",
938
+ description: "d",
939
+ parameters: {
940
+ type: "object",
941
+ properties: {
942
+ cmd: { type: "string", description: "The command to run" },
943
+ },
944
+ required: ["cmd"],
945
+ },
946
+ };
947
+ const schema = provider.buildZodSchema(tool);
948
+ expect(schema.cmd.description).toBe("The command to run");
949
+ });
950
+ it("parseMultimodal parses valid multimodal JSON", () => {
951
+ const json = JSON.stringify({
952
+ __multimodal: true,
953
+ text: "chart",
954
+ attachments: [{ mediaType: "image/png", data: "abc" }],
955
+ });
956
+ const result = provider.parseMultimodal(json);
957
+ expect(result).not.toBeNull();
958
+ expect(result.text).toBe("chart");
959
+ expect(result.attachments).toHaveLength(1);
960
+ });
961
+ it("parseMultimodal returns null for non-multimodal JSON", () => {
962
+ const result = provider.parseMultimodal(JSON.stringify({ hello: "world" }));
963
+ expect(result).toBeNull();
964
+ });
965
+ it("parseMultimodal returns null for non-JSON strings", () => {
966
+ const result = provider.parseMultimodal("just plain text");
967
+ expect(result).toBeNull();
968
+ });
969
+ it("parseMultimodal returns null for empty string", () => {
970
+ const result = provider.parseMultimodal("");
971
+ expect(result).toBeNull();
972
+ });
973
+ });
974
+ // ========== mapSdkMessage ==========
975
+ describe("mapSdkMessage", () => {
976
+ it("maps assistant message to tool_call events", () => {
977
+ const msg = {
978
+ type: "assistant",
979
+ message: {
980
+ content: [
981
+ { type: "text", text: "Let me run that" },
982
+ { type: "tool_use", id: "tu-1", name: "mcp__helios__remote_exec", input: { cmd: "ls" } },
983
+ ],
984
+ },
985
+ };
986
+ const events = [...provider.mapSdkMessage(msg)];
987
+ const toolCalls = events.filter((e) => e.type === "tool_call");
988
+ expect(toolCalls).toHaveLength(1);
989
+ expect(toolCalls[0].name).toBe("remote_exec"); // prefix stripped
990
+ expect(toolCalls[0].args).toEqual({ cmd: "ls" });
991
+ });
992
+ it("maps stream_event text_delta to text events", () => {
993
+ const msg = {
994
+ type: "stream_event",
995
+ event: {
996
+ type: "content_block_delta",
997
+ delta: { type: "text_delta", text: "Hello world" },
998
+ },
999
+ };
1000
+ const events = [...provider.mapSdkMessage(msg)];
1001
+ expect(events).toHaveLength(1);
1002
+ expect(events[0].type).toBe("text");
1003
+ expect(events[0].delta).toBe("Hello world");
1004
+ });
1005
+ it("maps result success to done event with usage", () => {
1006
+ const msg = {
1007
+ type: "result",
1008
+ subtype: "success",
1009
+ usage: { input_tokens: 100, output_tokens: 50 },
1010
+ total_cost_usd: 0.01,
1011
+ };
1012
+ const events = [...provider.mapSdkMessage(msg)];
1013
+ expect(events).toHaveLength(1);
1014
+ expect(events[0].type).toBe("done");
1015
+ expect(events[0].usage).toEqual({
1016
+ inputTokens: 100,
1017
+ outputTokens: 50,
1018
+ costUsd: 0.01,
1019
+ });
1020
+ });
1021
+ it("maps result error to error + done events", () => {
1022
+ const msg = {
1023
+ type: "result",
1024
+ subtype: "error",
1025
+ errors: ["Something went wrong", "Another error"],
1026
+ };
1027
+ const events = [...provider.mapSdkMessage(msg)];
1028
+ expect(events).toHaveLength(2);
1029
+ expect(events[0].type).toBe("error");
1030
+ expect(events[0].error.message).toContain("Something went wrong");
1031
+ expect(events[1].type).toBe("done");
1032
+ });
1033
+ it("maps result error without errors array", () => {
1034
+ const msg = { type: "result", subtype: "error" };
1035
+ const events = [...provider.mapSdkMessage(msg)];
1036
+ expect(events).toHaveLength(2);
1037
+ expect(events[0].type).toBe("error");
1038
+ expect(events[0].error.message).toContain("Unknown SDK error");
1039
+ });
1040
+ it("ignores unknown message types", () => {
1041
+ const msg = { type: "system", something: "else" };
1042
+ const events = [...provider.mapSdkMessage(msg)];
1043
+ expect(events).toHaveLength(0);
1044
+ });
1045
+ it("ignores non-text_delta stream events", () => {
1046
+ const msg = {
1047
+ type: "stream_event",
1048
+ event: {
1049
+ type: "content_block_delta",
1050
+ delta: { type: "input_json_delta", partial_json: "{}" },
1051
+ },
1052
+ };
1053
+ const events = [...provider.mapSdkMessage(msg)];
1054
+ expect(events).toHaveLength(0);
1055
+ });
1056
+ });
1057
+ // ========== Model Configuration ==========
1058
+ describe("Model Configuration", () => {
1059
+ it("currentModel defaults to claude-opus-4-6", () => {
1060
+ expect(provider.currentModel).toBe("claude-opus-4-6");
1061
+ });
1062
+ it("reasoningEffort defaults to medium", () => {
1063
+ expect(provider.reasoningEffort).toBe("medium");
1064
+ });
1065
+ it("thinking budget maps medium to 16000", async () => {
1066
+ provider.reasoningEffort = "medium";
1067
+ const session = await provider.createSession({});
1068
+ await collect(provider.send(session, "hi", []));
1069
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1070
+ expect(body.thinking.budget_tokens).toBe(16000);
1071
+ });
1072
+ it("thinking budget maps high to 50000", async () => {
1073
+ provider.reasoningEffort = "high";
1074
+ const session = await provider.createSession({});
1075
+ await collect(provider.send(session, "hi", []));
1076
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1077
+ expect(body.thinking.budget_tokens).toBe(50000);
1078
+ });
1079
+ it("thinking budget maps max to 100000", async () => {
1080
+ provider.reasoningEffort = "max";
1081
+ const session = await provider.createSession({});
1082
+ await collect(provider.send(session, "hi", []));
1083
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1084
+ expect(body.thinking.budget_tokens).toBe(100000);
1085
+ });
1086
+ it("thinking disabled when effort is none (budget 0)", async () => {
1087
+ provider.reasoningEffort = "none";
1088
+ const session = await provider.createSession({});
1089
+ await collect(provider.send(session, "hi", []));
1090
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1091
+ // budget is 0, so thinking should not be included
1092
+ expect(body.thinking).toBeUndefined();
1093
+ });
1094
+ it("max_tokens is at least budgetTokens + 8192", async () => {
1095
+ provider.reasoningEffort = "max";
1096
+ const session = await provider.createSession({});
1097
+ await collect(provider.send(session, "hi", []));
1098
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1099
+ expect(body.max_tokens).toBeGreaterThanOrEqual(100000 + 8192);
1100
+ });
1101
+ it("currentModel can be changed", () => {
1102
+ provider.currentModel = "claude-sonnet-4-6";
1103
+ expect(provider.currentModel).toBe("claude-sonnet-4-6");
1104
+ });
1105
+ });
1106
+ // ========== Interrupt ==========
1107
+ describe("Interrupt", () => {
1108
+ it("interrupt aborts active request via AbortController", () => {
1109
+ const ac = new AbortController();
1110
+ provider.abortController = ac;
1111
+ const session = makeSession();
1112
+ provider.interrupt(session);
1113
+ expect(ac.signal.aborted).toBe(true);
1114
+ expect(provider.abortController).toBeNull();
1115
+ });
1116
+ it("interrupt clears abort controller", () => {
1117
+ provider.abortController = new AbortController();
1118
+ const session = makeSession();
1119
+ provider.interrupt(session);
1120
+ expect(provider.abortController).toBeNull();
1121
+ });
1122
+ it("interrupt is safe when no active request", () => {
1123
+ const session = makeSession();
1124
+ // Should not throw
1125
+ provider.interrupt(session);
1126
+ expect(provider.abortController).toBeNull();
1127
+ });
1128
+ it("interrupt calls activeQuery.interrupt for CLI mode", () => {
1129
+ const mockQuery = { interrupt: vi.fn(), close: vi.fn() };
1130
+ provider.activeQuery = mockQuery;
1131
+ const session = makeSession();
1132
+ provider.interrupt(session);
1133
+ expect(mockQuery.interrupt).toHaveBeenCalled();
1134
+ });
1135
+ });
1136
+ // ========== Provider Metadata ==========
1137
+ describe("Provider Metadata", () => {
1138
+ it("name is claude", () => {
1139
+ expect(provider.name).toBe("claude");
1140
+ });
1141
+ it("displayName is Claude", () => {
1142
+ expect(provider.displayName).toBe("Claude");
1143
+ });
1144
+ });
1145
+ // ========== Web Search Tool Filtering ==========
1146
+ describe("Web Search Tool Filtering", () => {
1147
+ it("web_search tool is excluded from function tools in request", async () => {
1148
+ const webTool = {
1149
+ name: "web_search",
1150
+ description: "Search",
1151
+ parameters: { type: "object", properties: {}, required: [] },
1152
+ execute: vi.fn(),
1153
+ };
1154
+ const regularTool = makeTool("regular");
1155
+ const session = await provider.createSession({});
1156
+ await collect(provider.send(session, "hi", [webTool, regularTool]));
1157
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1158
+ const functionToolNames = body.tools
1159
+ .filter((t) => t.name && t.name !== "web_search")
1160
+ .map((t) => t.name);
1161
+ expect(functionToolNames).toContain("regular");
1162
+ // web_search should be present as a special type
1163
+ expect(body.tools.some((t) => t.type === "web_search_20250305")).toBe(true);
1164
+ });
1165
+ });
1166
+ });
1167
+ //# sourceMappingURL=provider.test.js.map