@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -0,0 +1,475 @@
1
+ import { Contract, JsonRpcProvider, getAddress, isAddress } from 'ethers'
2
+
3
+ import { formatAtomicAmount, normalizeAtomicString, parseDisplayAmountToAtomic } from '@/lib/wallet'
4
+ import type { AgentWallet, WalletAssetBalance } from '@/types'
5
+
6
+ import { getEvmNetworkConfig, getProviderForNetwork, type EvmNetworkId } from './ethereum'
7
+ import { getWalletPortfolioSnapshot } from './wallet-service'
8
+
9
+ const PARASWAP_API_BASE = 'https://api.paraswap.io'
10
+ const PARASWAP_VERSION = '6.2'
11
+ const PARASWAP_NATIVE_TOKEN = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'
12
+ const ERC20_ALLOWANCE_ABI = [
13
+ 'function allowance(address owner, address spender) view returns (uint256)',
14
+ 'function balanceOf(address owner) view returns (uint256)',
15
+ 'function approve(address spender, uint256 amount) returns (bool)',
16
+ 'function decimals() view returns (uint8)',
17
+ 'function symbol() view returns (string)',
18
+ 'function name() view returns (string)',
19
+ ] as const
20
+ const TOKEN_LIST_TTL_MS = 10 * 60 * 1000
21
+ const FETCH_TIMEOUT_MS = 15_000
22
+
23
+ interface CachedTokenList {
24
+ expiresAt: number
25
+ assets: ResolvedEvmSwapAsset[]
26
+ }
27
+
28
+ const paraswapTokenListCache = new Map<EvmNetworkId, CachedTokenList>()
29
+
30
+ export interface ResolvedEvmSwapAsset {
31
+ address: string
32
+ symbol: string
33
+ name: string
34
+ decimals: number
35
+ isNative: boolean
36
+ source: 'native' | 'portfolio' | 'paraswap' | 'onchain'
37
+ }
38
+
39
+ export interface PreparedEvmSwapPlan {
40
+ provider: 'paraswap'
41
+ network: ReturnType<typeof getEvmNetworkConfig>
42
+ walletAddress: string
43
+ recipient: string
44
+ sellToken: ResolvedEvmSwapAsset
45
+ buyToken: ResolvedEvmSwapAsset
46
+ sellAmountAtomic: string
47
+ sellAmountDisplay: string
48
+ buyAmountAtomic: string
49
+ buyAmountDisplay: string
50
+ slippageBps: number
51
+ spenderAddress: string | null
52
+ approvalRequired: boolean
53
+ approvalTransaction: Record<string, unknown> | null
54
+ swapTransaction: Record<string, unknown>
55
+ routeSummary: string
56
+ priceRoute: Record<string, unknown>
57
+ }
58
+
59
+ export interface PrepareEvmSwapPlanInput {
60
+ wallet: AgentWallet
61
+ network: EvmNetworkId | string
62
+ sellToken: unknown
63
+ buyToken: unknown
64
+ sellAmountAtomic?: unknown
65
+ sellAmountDisplay?: unknown
66
+ slippageBps?: unknown
67
+ recipient?: unknown
68
+ rpcUrl?: string | null
69
+ skipBalanceCheck?: boolean
70
+ }
71
+
72
+ function normalizeText(value: unknown): string {
73
+ return typeof value === 'string' ? value.trim() : ''
74
+ }
75
+
76
+ function looksLikeEvmAddress(value: string): boolean {
77
+ return isAddress(value)
78
+ }
79
+
80
+ function normalizeLowerAddress(value: string): string {
81
+ return getAddress(value).toLowerCase()
82
+ }
83
+
84
+ function makeNativeEthAsset(): ResolvedEvmSwapAsset {
85
+ return {
86
+ address: PARASWAP_NATIVE_TOKEN,
87
+ symbol: 'ETH',
88
+ name: 'Ether',
89
+ decimals: 18,
90
+ isNative: true,
91
+ source: 'native',
92
+ }
93
+ }
94
+
95
+ function normalizeSlippageBps(value: unknown): number {
96
+ if (typeof value === 'number' && Number.isFinite(value)) {
97
+ if (value > 0 && value <= 10) return Math.round(value * 100)
98
+ return Math.max(1, Math.min(5_000, Math.trunc(value)))
99
+ }
100
+ if (typeof value === 'string') {
101
+ const trimmed = value.trim()
102
+ if (!trimmed) return 100
103
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
104
+ const parsed = Number.parseFloat(trimmed)
105
+ if (parsed > 0 && parsed <= 10) return Math.round(parsed * 100)
106
+ return Math.max(1, Math.min(5_000, Math.trunc(parsed)))
107
+ }
108
+ }
109
+ return 100
110
+ }
111
+
112
+ async function fetchJson(url: string, init?: RequestInit): Promise<unknown> {
113
+ const controller = new AbortController()
114
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
115
+ try {
116
+ const response = await fetch(url, {
117
+ ...init,
118
+ signal: controller.signal,
119
+ headers: {
120
+ Accept: 'application/json',
121
+ ...(init?.headers || {}),
122
+ },
123
+ })
124
+ const text = await response.text()
125
+ const payload = text ? JSON.parse(text) as unknown : null
126
+ if (!response.ok) {
127
+ const message = payload && typeof payload === 'object' && payload && 'error' in payload
128
+ ? String((payload as { error?: unknown }).error)
129
+ : `${response.status} ${response.statusText}`.trim()
130
+ throw new Error(`ParaSwap API request failed: ${message}`)
131
+ }
132
+ return payload
133
+ } finally {
134
+ clearTimeout(timer)
135
+ }
136
+ }
137
+
138
+ async function getParaswapTokenList(network: EvmNetworkId): Promise<ResolvedEvmSwapAsset[]> {
139
+ const cached = paraswapTokenListCache.get(network)
140
+ if (cached && cached.expiresAt > Date.now()) return cached.assets
141
+
142
+ const config = getEvmNetworkConfig(network)
143
+ const response = await fetchJson(`${PARASWAP_API_BASE}/tokens/${config.chainId}`) as {
144
+ tokens?: Array<{
145
+ address?: string
146
+ symbol?: string
147
+ name?: string
148
+ decimals?: number
149
+ }>
150
+ }
151
+ const assets = (Array.isArray(response?.tokens) ? response.tokens : [])
152
+ .flatMap((token) => {
153
+ const address = normalizeText(token?.address)
154
+ const symbol = normalizeText(token?.symbol)
155
+ if (!address || !symbol) return []
156
+ if (address.toLowerCase() === PARASWAP_NATIVE_TOKEN.toLowerCase()) return [makeNativeEthAsset()]
157
+ if (!looksLikeEvmAddress(address)) return []
158
+ return [{
159
+ address: getAddress(address),
160
+ symbol,
161
+ name: normalizeText(token?.name) || symbol,
162
+ decimals: typeof token?.decimals === 'number' ? token.decimals : 18,
163
+ isNative: false,
164
+ source: 'paraswap' as const,
165
+ }]
166
+ })
167
+
168
+ const deduped = Array.from(new Map(
169
+ assets.map((asset) => [asset.address.toLowerCase(), asset]),
170
+ ).values())
171
+ paraswapTokenListCache.set(network, {
172
+ expiresAt: Date.now() + TOKEN_LIST_TTL_MS,
173
+ assets: deduped,
174
+ })
175
+ return deduped
176
+ }
177
+
178
+ function getPortfolioAssetCandidates(
179
+ walletAssets: WalletAssetBalance[],
180
+ networkId: EvmNetworkId,
181
+ tokenRef: string,
182
+ ): ResolvedEvmSwapAsset[] {
183
+ const normalized = tokenRef.trim().toLowerCase()
184
+ return walletAssets
185
+ .filter((asset) => asset.chain === 'ethereum' && asset.networkId === networkId)
186
+ .flatMap((asset) => {
187
+ const address = asset.isNative ? PARASWAP_NATIVE_TOKEN : normalizeText(asset.contractAddress)
188
+ if (!address) return []
189
+ const symbol = normalizeText(asset.symbol)
190
+ const name = normalizeText(asset.name)
191
+ const matchesAddress = !asset.isNative && looksLikeEvmAddress(tokenRef) && address.toLowerCase() === normalized
192
+ const matchesSymbol = symbol.toLowerCase() === normalized
193
+ const matchesName = name.toLowerCase() === normalized
194
+ const matchesNative = asset.isNative && ['eth', 'native', PARASWAP_NATIVE_TOKEN.toLowerCase()].includes(normalized)
195
+ if (!matchesAddress && !matchesSymbol && !matchesName && !matchesNative) return []
196
+ return [{
197
+ address: asset.isNative ? PARASWAP_NATIVE_TOKEN : getAddress(address),
198
+ symbol: symbol || (asset.isNative ? 'ETH' : 'TOKEN'),
199
+ name: name || symbol || 'Token',
200
+ decimals: typeof asset.decimals === 'number' ? asset.decimals : (asset.isNative ? 18 : 18),
201
+ isNative: asset.isNative === true,
202
+ source: 'portfolio' as const,
203
+ }]
204
+ })
205
+ }
206
+
207
+ async function resolveTokenByAddress(
208
+ provider: JsonRpcProvider,
209
+ address: string,
210
+ ): Promise<ResolvedEvmSwapAsset> {
211
+ const normalizedAddress = getAddress(address)
212
+ const contract = new Contract(normalizedAddress, ERC20_ALLOWANCE_ABI, provider)
213
+ const [decimalsRaw, symbolRaw, nameRaw] = await Promise.all([
214
+ contract.decimals().catch(() => 18),
215
+ contract.symbol().catch(() => 'TOKEN'),
216
+ contract.name().catch(() => 'Token'),
217
+ ])
218
+ return {
219
+ address: normalizedAddress,
220
+ symbol: normalizeText(symbolRaw) || 'TOKEN',
221
+ name: normalizeText(nameRaw) || normalizeText(symbolRaw) || 'Token',
222
+ decimals: typeof decimalsRaw === 'number' ? decimalsRaw : Number(decimalsRaw ?? 18),
223
+ isNative: false,
224
+ source: 'onchain',
225
+ }
226
+ }
227
+
228
+ export async function resolveEvmSwapAsset(input: {
229
+ wallet: AgentWallet
230
+ network: EvmNetworkId | string
231
+ token: unknown
232
+ rpcUrl?: string | null
233
+ }): Promise<ResolvedEvmSwapAsset> {
234
+ const tokenRef = normalizeText(input.token)
235
+ if (!tokenRef) throw new Error('Token is required')
236
+
237
+ const network = getEvmNetworkConfig(input.network).id
238
+ const normalized = tokenRef.toLowerCase()
239
+ if (['eth', 'native', PARASWAP_NATIVE_TOKEN.toLowerCase()].includes(normalized)) {
240
+ return makeNativeEthAsset()
241
+ }
242
+
243
+ const portfolio = await getWalletPortfolioSnapshot(input.wallet)
244
+ const portfolioMatches = getPortfolioAssetCandidates(portfolio.assets, network, tokenRef)
245
+ if (portfolioMatches.length === 1) return portfolioMatches[0]
246
+
247
+ const tokenList = await getParaswapTokenList(network)
248
+ if (looksLikeEvmAddress(tokenRef)) {
249
+ const addressMatch = tokenList.find((asset) => asset.address.toLowerCase() === normalized.toLowerCase())
250
+ if (addressMatch) return addressMatch
251
+ return resolveTokenByAddress(getProviderForNetwork(network, input.rpcUrl), tokenRef)
252
+ }
253
+
254
+ const symbolMatches = tokenList.filter((asset) => asset.symbol.toLowerCase() === normalized)
255
+ if (symbolMatches.length === 1) return symbolMatches[0]
256
+ if (portfolioMatches.length > 1) {
257
+ throw new Error(`Token "${tokenRef}" matches multiple wallet assets on ${network}. Use the contract address instead.`)
258
+ }
259
+ if (symbolMatches.length > 1) {
260
+ throw new Error(`Token "${tokenRef}" matches multiple ParaSwap assets on ${network}. Use the token contract address instead.`)
261
+ }
262
+
263
+ const nameMatch = tokenList.find((asset) => asset.name.toLowerCase() === normalized)
264
+ if (nameMatch) return nameMatch
265
+
266
+ throw new Error(`Could not resolve token "${tokenRef}" on ${network}. Use a symbol like USDC/ETH or a token contract address.`)
267
+ }
268
+
269
+ function parseSellAmountAtomic(input: {
270
+ sellAmountAtomic?: unknown
271
+ sellAmountDisplay?: unknown
272
+ decimals: number
273
+ }): string {
274
+ const atomic = normalizeAtomicString(input.sellAmountAtomic, '')
275
+ if (atomic) {
276
+ if (BigInt(atomic) <= BigInt(0)) throw new Error('Swap amount must be positive')
277
+ return atomic
278
+ }
279
+ const displayRaw = input.sellAmountDisplay
280
+ if (
281
+ displayRaw === undefined
282
+ || displayRaw === null
283
+ || (typeof displayRaw === 'string' && displayRaw.trim() === '')
284
+ ) {
285
+ throw new Error('sellAmountAtomic or sellAmountDisplay is required for swap')
286
+ }
287
+ const display = typeof displayRaw === 'number' || typeof displayRaw === 'string'
288
+ ? displayRaw
289
+ : String(displayRaw)
290
+ const parsed = parseDisplayAmountToAtomic(display, input.decimals)
291
+ if (BigInt(parsed) <= BigInt(0)) throw new Error('Swap amount must be positive')
292
+ return parsed
293
+ }
294
+
295
+ async function getTokenBalanceAtomic(
296
+ provider: JsonRpcProvider,
297
+ walletAddress: string,
298
+ token: ResolvedEvmSwapAsset,
299
+ ): Promise<bigint> {
300
+ if (token.isNative) {
301
+ return provider.getBalance(walletAddress)
302
+ }
303
+ const contract = new Contract(token.address, ERC20_ALLOWANCE_ABI, provider)
304
+ const balance = await contract.balanceOf(walletAddress)
305
+ return BigInt(balance.toString())
306
+ }
307
+
308
+ async function getTokenAllowanceAtomic(
309
+ provider: JsonRpcProvider,
310
+ walletAddress: string,
311
+ spenderAddress: string,
312
+ token: ResolvedEvmSwapAsset,
313
+ ): Promise<bigint> {
314
+ if (token.isNative) return BigInt(0)
315
+ const contract = new Contract(token.address, ERC20_ALLOWANCE_ABI, provider)
316
+ const allowance = await contract.allowance(walletAddress, spenderAddress)
317
+ return BigInt(allowance.toString())
318
+ }
319
+
320
+ function collectRouteExchanges(priceRoute: Record<string, unknown>): string[] {
321
+ const bestRoute = Array.isArray(priceRoute.bestRoute) ? priceRoute.bestRoute : []
322
+ const exchanges = new Set<string>()
323
+ for (const route of bestRoute) {
324
+ const swaps = Array.isArray((route as { swaps?: unknown[] }).swaps) ? (route as { swaps: unknown[] }).swaps : []
325
+ for (const swap of swaps) {
326
+ const swapExchanges = Array.isArray((swap as { swapExchanges?: unknown[] }).swapExchanges)
327
+ ? (swap as { swapExchanges: unknown[] }).swapExchanges
328
+ : []
329
+ for (const entry of swapExchanges) {
330
+ const exchange = normalizeText((entry as { exchange?: unknown }).exchange)
331
+ if (exchange) exchanges.add(exchange)
332
+ }
333
+ }
334
+ }
335
+ return [...exchanges]
336
+ }
337
+
338
+ function toComparableTransaction(transaction: Record<string, unknown>, network: ReturnType<typeof getEvmNetworkConfig>): Record<string, unknown> {
339
+ const normalized: Record<string, unknown> = {}
340
+ const to = normalizeText(transaction.to)
341
+ const data = normalizeText(transaction.data)
342
+ const value = transaction.value
343
+ if (to) normalized.to = getAddress(to)
344
+ if (data) normalized.data = data
345
+ if (value !== undefined && value !== null && String(value).trim() !== '') normalized.value = String(value).trim()
346
+ normalized.chainId = network.chainId
347
+ return normalized
348
+ }
349
+
350
+ export async function prepareEvmSwapPlan(input: PrepareEvmSwapPlanInput): Promise<PreparedEvmSwapPlan> {
351
+ if (input.wallet.chain !== 'ethereum') {
352
+ throw new Error('Generic swap is currently supported only for Ethereum-compatible wallets')
353
+ }
354
+
355
+ const network = getEvmNetworkConfig(input.network)
356
+ const provider = getProviderForNetwork(network.id, input.rpcUrl || undefined)
357
+ const walletAddress = getAddress(input.wallet.publicKey)
358
+ const recipient = normalizeText(input.recipient) ? getAddress(normalizeText(input.recipient)) : walletAddress
359
+ const sellToken = await resolveEvmSwapAsset({
360
+ wallet: input.wallet,
361
+ network: network.id,
362
+ token: input.sellToken,
363
+ rpcUrl: input.rpcUrl,
364
+ })
365
+ const buyToken = await resolveEvmSwapAsset({
366
+ wallet: input.wallet,
367
+ network: network.id,
368
+ token: input.buyToken,
369
+ rpcUrl: input.rpcUrl,
370
+ })
371
+ if (sellToken.address.toLowerCase() === buyToken.address.toLowerCase()) {
372
+ throw new Error('Swap sellToken and buyToken must be different')
373
+ }
374
+
375
+ const sellAmountAtomic = parseSellAmountAtomic({
376
+ sellAmountAtomic: input.sellAmountAtomic,
377
+ sellAmountDisplay: input.sellAmountDisplay,
378
+ decimals: sellToken.decimals,
379
+ })
380
+ if (input.skipBalanceCheck !== true) {
381
+ const sellBalanceAtomic = await getTokenBalanceAtomic(provider, walletAddress, sellToken)
382
+ if (sellBalanceAtomic < BigInt(sellAmountAtomic)) {
383
+ const available = formatAtomicAmount(sellBalanceAtomic.toString(), sellToken.decimals, { maxFractionDigits: 6 })
384
+ throw new Error(`Insufficient ${sellToken.symbol} balance on ${network.label}. Available ${available} ${sellToken.symbol}.`)
385
+ }
386
+ }
387
+
388
+ const priceUrl = new URL(`${PARASWAP_API_BASE}/prices`)
389
+ priceUrl.searchParams.set('srcToken', sellToken.address)
390
+ priceUrl.searchParams.set('destToken', buyToken.address)
391
+ priceUrl.searchParams.set('amount', sellAmountAtomic)
392
+ priceUrl.searchParams.set('srcDecimals', String(sellToken.decimals))
393
+ priceUrl.searchParams.set('destDecimals', String(buyToken.decimals))
394
+ priceUrl.searchParams.set('side', 'SELL')
395
+ priceUrl.searchParams.set('network', String(network.chainId))
396
+ priceUrl.searchParams.set('version', PARASWAP_VERSION)
397
+ const priceResponse = await fetchJson(priceUrl.toString()) as { priceRoute?: Record<string, unknown> }
398
+ const priceRoute = priceResponse?.priceRoute
399
+ if (!priceRoute || typeof priceRoute !== 'object') {
400
+ throw new Error('ParaSwap did not return a price route')
401
+ }
402
+
403
+ const transactionsUrl = `${PARASWAP_API_BASE}/transactions/${network.chainId}?ignoreChecks=true`
404
+ const transactionsRequest = {
405
+ srcToken: sellToken.address,
406
+ destToken: buyToken.address,
407
+ srcAmount: sellAmountAtomic,
408
+ userAddress: walletAddress,
409
+ srcDecimals: sellToken.decimals,
410
+ destDecimals: buyToken.decimals,
411
+ priceRoute,
412
+ receiver: recipient,
413
+ slippage: normalizeSlippageBps(input.slippageBps),
414
+ }
415
+ const swapResponse = await fetchJson(transactionsUrl, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify(transactionsRequest),
419
+ }) as Record<string, unknown>
420
+
421
+ const rawTo = normalizeText(swapResponse.to)
422
+ const rawData = normalizeText(swapResponse.data)
423
+ if (!rawTo || !rawData) {
424
+ throw new Error('ParaSwap did not return executable transaction calldata')
425
+ }
426
+
427
+ const spenderAddress = normalizeText((priceRoute as { tokenTransferProxy?: unknown }).tokenTransferProxy)
428
+ || normalizeText((priceRoute as { contractAddress?: unknown }).contractAddress)
429
+ || rawTo
430
+
431
+ let approvalRequired = false
432
+ let approvalTransaction: Record<string, unknown> | null = null
433
+ if (!sellToken.isNative) {
434
+ const allowance = await getTokenAllowanceAtomic(provider, walletAddress, getAddress(spenderAddress), sellToken)
435
+ approvalRequired = allowance < BigInt(sellAmountAtomic)
436
+ if (approvalRequired) {
437
+ approvalTransaction = {
438
+ to: getAddress(sellToken.address),
439
+ data: new Contract(sellToken.address, ERC20_ALLOWANCE_ABI, provider).interface.encodeFunctionData('approve', [
440
+ getAddress(spenderAddress),
441
+ BigInt(sellAmountAtomic),
442
+ ]),
443
+ value: '0',
444
+ chainId: network.chainId,
445
+ }
446
+ }
447
+ }
448
+
449
+ const buyAmountAtomic = normalizeAtomicString((priceRoute as { destAmount?: unknown }).destAmount, '0')
450
+ const exchanges = collectRouteExchanges(priceRoute)
451
+ return {
452
+ provider: 'paraswap',
453
+ network,
454
+ walletAddress,
455
+ recipient,
456
+ sellToken,
457
+ buyToken,
458
+ sellAmountAtomic,
459
+ sellAmountDisplay: `${formatAtomicAmount(sellAmountAtomic, sellToken.decimals, { maxFractionDigits: 6 })} ${sellToken.symbol}`,
460
+ buyAmountAtomic,
461
+ buyAmountDisplay: `${formatAtomicAmount(buyAmountAtomic, buyToken.decimals, { maxFractionDigits: 6 })} ${buyToken.symbol}`,
462
+ slippageBps: normalizeSlippageBps(input.slippageBps),
463
+ spenderAddress: spenderAddress ? getAddress(spenderAddress) : null,
464
+ approvalRequired,
465
+ approvalTransaction,
466
+ swapTransaction: toComparableTransaction(swapResponse, network),
467
+ routeSummary: exchanges.length > 0 ? exchanges.join(', ') : 'ParaSwap route',
468
+ priceRoute,
469
+ }
470
+ }
471
+
472
+ export function isLikelyRetryableSwapError(err: unknown): boolean {
473
+ const message = err instanceof Error ? err.message : String(err)
474
+ return /rate|price|slippage|expired|call exception|execution reverted|insufficient output/i.test(message)
475
+ }
@@ -20,6 +20,7 @@ export type LogCategory =
20
20
  | 'mission_checkpoint' // periodic mission state snapshot
21
21
  | 'mission_complete' // mission reached ok status
22
22
  | 'budget_warning' // mission approaching or exceeding budget
23
+ | 'loop_detection' // repeated tool call pattern detected
23
24
 
24
25
  export interface ExecutionLogEntry {
25
26
  id: string
@@ -0,0 +1,173 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-heartbeat-timer-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
+ },
21
+ encoding: 'utf-8',
22
+ })
23
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
+ const lines = (result.stdout || '')
25
+ .trim()
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
+ return JSON.parse(jsonLine || '{}')
31
+ } finally {
32
+ fs.rmSync(tempDir, { recursive: true, force: true })
33
+ }
34
+ }
35
+
36
+ describe('heartbeat-service scheduling', () => {
37
+ it('does not fire periodic heartbeats for agents that are explicitly off', () => {
38
+ const output = runWithTempDataDir(`
39
+ const { setTimeout: delay } = await import('node:timers/promises')
40
+ const storageMod = await import('./src/lib/server/storage.ts')
41
+ const heartbeatMod = await import('./src/lib/server/heartbeat-service.ts')
42
+ const runsMod = await import('./src/lib/server/session-run-manager.ts')
43
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
44
+ const heartbeat = heartbeatMod.default || heartbeatMod['module.exports'] || heartbeatMod
45
+ const runs = runsMod.default || runsMod['module.exports'] || runsMod
46
+
47
+ const now = Date.now()
48
+ storage.saveSettings({ loopMode: 'bounded' })
49
+ storage.saveAgents({
50
+ probe: {
51
+ id: 'probe',
52
+ name: 'Probe',
53
+ description: 'Heartbeat probe',
54
+ provider: 'openai',
55
+ model: 'gpt-test',
56
+ credentialId: null,
57
+ apiEndpoint: null,
58
+ fallbackCredentialIds: [],
59
+ heartbeatEnabled: false,
60
+ heartbeatIntervalSec: 1,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ plugins: [],
64
+ },
65
+ })
66
+ storage.saveSessions({
67
+ main: {
68
+ id: 'main',
69
+ name: 'Probe Main',
70
+ shortcutForAgentId: 'probe',
71
+ cwd: process.env.WORKSPACE_DIR,
72
+ user: 'tester',
73
+ provider: 'openai',
74
+ model: 'gpt-test',
75
+ claudeSessionId: null,
76
+ messages: [{ role: 'user', text: 'Old task', time: now - 20_000 }],
77
+ createdAt: now - 20_000,
78
+ lastActiveAt: now - 20_000,
79
+ sessionType: 'human',
80
+ agentId: 'probe',
81
+ heartbeatEnabled: false,
82
+ },
83
+ })
84
+
85
+ heartbeat.startHeartbeatService()
86
+ await delay(6_500)
87
+ const later = runs.listRuns({ sessionId: 'main', limit: 20 })
88
+ heartbeat.stopHeartbeatService()
89
+
90
+ console.log(JSON.stringify({
91
+ laterCount: later.length,
92
+ laterSources: later.map((run) => run.source),
93
+ }))
94
+ `)
95
+
96
+ assert.equal(output.laterCount, 0)
97
+ assert.deepEqual(output.laterSources, [])
98
+ })
99
+
100
+ it('fires periodic heartbeats only after the service tick window when enabled', () => {
101
+ const output = runWithTempDataDir(`
102
+ const { setTimeout: delay } = await import('node:timers/promises')
103
+ const storageMod = await import('./src/lib/server/storage.ts')
104
+ const heartbeatMod = await import('./src/lib/server/heartbeat-service.ts')
105
+ const runsMod = await import('./src/lib/server/session-run-manager.ts')
106
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
107
+ const heartbeat = heartbeatMod.default || heartbeatMod['module.exports'] || heartbeatMod
108
+ const runs = runsMod.default || runsMod['module.exports'] || runsMod
109
+
110
+ const now = Date.now()
111
+ storage.saveSettings({ loopMode: 'bounded' })
112
+ storage.saveAgents({
113
+ probe: {
114
+ id: 'probe',
115
+ name: 'Probe',
116
+ description: 'Heartbeat probe',
117
+ provider: 'openai',
118
+ model: 'gpt-test',
119
+ credentialId: null,
120
+ apiEndpoint: null,
121
+ fallbackCredentialIds: [],
122
+ heartbeatEnabled: true,
123
+ heartbeatIntervalSec: 1,
124
+ heartbeatInterval: '1s',
125
+ heartbeatPrompt: 'Reply HEARTBEAT_OK if idle.',
126
+ createdAt: now,
127
+ updatedAt: now,
128
+ plugins: [],
129
+ },
130
+ })
131
+ storage.saveSessions({
132
+ main: {
133
+ id: 'main',
134
+ name: 'Probe Main',
135
+ shortcutForAgentId: 'probe',
136
+ cwd: process.env.WORKSPACE_DIR,
137
+ user: 'tester',
138
+ provider: 'openai',
139
+ model: 'gpt-test',
140
+ claudeSessionId: null,
141
+ messages: [{ role: 'user', text: 'Old task', time: now - 20_000 }],
142
+ createdAt: now - 20_000,
143
+ lastActiveAt: now - 20_000,
144
+ sessionType: 'human',
145
+ agentId: 'probe',
146
+ heartbeatEnabled: true,
147
+ heartbeatIntervalSec: 1,
148
+ },
149
+ })
150
+
151
+ const startedAt = Date.now()
152
+ heartbeat.startHeartbeatService()
153
+ await delay(2_500)
154
+ const early = runs.listRuns({ sessionId: 'main', limit: 20 })
155
+ await delay(4_500)
156
+ const later = runs.listRuns({ sessionId: 'main', limit: 20 })
157
+ heartbeat.stopHeartbeatService()
158
+
159
+ console.log(JSON.stringify({
160
+ earlyCount: early.length,
161
+ laterCount: later.length,
162
+ laterSources: later.map((run) => run.source),
163
+ firstQueuedDeltaMs: later[0]?.queuedAt ? later[0].queuedAt - startedAt : null,
164
+ }))
165
+ `)
166
+
167
+ assert.equal(output.earlyCount, 0, 'no periodic heartbeat before the 5s service tick')
168
+ assert.ok(output.laterCount >= 1, 'expected at least one periodic heartbeat run after the tick window')
169
+ assert.ok((output.laterSources || []).includes('heartbeat'))
170
+ assert.equal(typeof output.firstQueuedDeltaMs, 'number')
171
+ assert.ok(output.firstQueuedDeltaMs >= 4_500, `expected first heartbeat after timer window, got ${output.firstQueuedDeltaMs}`)
172
+ })
173
+ })