@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,538 @@
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
+ import { stripMainLoopMetaForPersistence } from './main-agent-loop.ts'
9
+
10
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
11
+
12
+ function runWithTempDataDir(script: string) {
13
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-loop-adv-'))
14
+ try {
15
+ const result = spawnSync(
16
+ process.execPath,
17
+ ['--import', 'tsx', '--input-type=module', '--eval', script],
18
+ {
19
+ cwd: repoRoot,
20
+ env: {
21
+ ...process.env,
22
+ DATA_DIR: tempDir,
23
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
24
+ SWARMCLAW_BUILD_MODE: '1',
25
+ },
26
+ encoding: 'utf-8',
27
+ timeout: 15000,
28
+ },
29
+ )
30
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
31
+ const lines = (result.stdout || '')
32
+ .trim()
33
+ .split('\n')
34
+ .map((line) => line.trim())
35
+ .filter(Boolean)
36
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
37
+ return JSON.parse(jsonLine || '{}') as Record<string, unknown>
38
+ } finally {
39
+ fs.rmSync(tempDir, { recursive: true, force: true })
40
+ }
41
+ }
42
+
43
+ /** Shared setup script that creates one agent and one heartbeat-enabled main session */
44
+ function sessionSetupScript(sessionOverrides?: string, extraSessions?: string): string {
45
+ return `
46
+ const storageMod = await import('./src/lib/server/storage.ts')
47
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
48
+ const mainLoopMod = await import('./src/lib/server/main-agent-loop.ts')
49
+ const mainLoop = mainLoopMod.default || mainLoopMod['module.exports'] || mainLoopMod
50
+
51
+ storage.saveAgents({
52
+ 'agent-a': {
53
+ id: 'agent-a',
54
+ name: 'Agent A',
55
+ provider: 'openai',
56
+ model: 'gpt-test',
57
+ },
58
+ })
59
+
60
+ storage.saveSessions({
61
+ main: {
62
+ id: 'main',
63
+ name: 'Main Thread',
64
+ shortcutForAgentId: 'agent-a',
65
+ cwd: process.cwd(),
66
+ user: 'tester',
67
+ provider: 'openai',
68
+ model: 'gpt-test',
69
+ claudeSessionId: null,
70
+ messages: [
71
+ { role: 'user', text: 'Deploy the system.', time: 1 },
72
+ ],
73
+ createdAt: 1,
74
+ lastActiveAt: 1,
75
+ sessionType: 'human',
76
+ agentId: 'agent-a',
77
+ heartbeatEnabled: true,
78
+ ${sessionOverrides || ''}
79
+ },
80
+ ${extraSessions || ''}
81
+ })
82
+ `
83
+ }
84
+
85
+ function heartbeatMetaLine(status: string, goal: string, nextAction?: string, extraFields?: string): string {
86
+ const parts = [`"status":"${status}","goal":"${goal}"`]
87
+ if (nextAction) parts.push(`"next_action":"${nextAction}"`)
88
+ if (extraFields) parts.push(extraFields)
89
+ return `[AGENT_HEARTBEAT_META]{${parts.join(',')}}`
90
+ }
91
+
92
+ function makeRunResultCall(
93
+ index: number,
94
+ resultText: string,
95
+ opts?: { error?: string; inputTokens?: number; outputTokens?: number; estimatedCost?: number; source?: string },
96
+ ): string {
97
+ const errorPart = opts?.error ? `error: '${opts.error}',` : ''
98
+ const inputTokens = opts?.inputTokens ?? 10
99
+ const outputTokens = opts?.outputTokens ?? 5
100
+ const estimatedCost = opts?.estimatedCost ?? 0
101
+ const source = opts?.source ?? 'heartbeat'
102
+ return `
103
+ const followup${index} = mainLoop.handleMainLoopRunResult({
104
+ sessionId: 'main',
105
+ message: 'Continue objective step ${index}.',
106
+ internal: true,
107
+ source: '${source}',
108
+ resultText: \`${resultText}\`,
109
+ ${errorPart}
110
+ inputTokens: ${inputTokens},
111
+ outputTokens: ${outputTokens},
112
+ estimatedCost: ${estimatedCost},
113
+ })
114
+ const state${index} = mainLoop.getMainLoopStateForSession('main')
115
+ `
116
+ }
117
+
118
+ describe('main-agent-loop advanced', () => {
119
+ // ─────────────────────────────────────────────────────────────────────
120
+ // 1. Followup chain escalation and cap
121
+ // ─────────────────────────────────────────────────────────────────────
122
+ it('followup chain escalates then resets at DEFAULT_MAX_FOLLOWUP_CHAIN=3', () => {
123
+ const meta = heartbeatMetaLine('progress', 'deploy', 'continue')
124
+ const output = runWithTempDataDir(`
125
+ ${sessionSetupScript()}
126
+
127
+ ${makeRunResultCall(1, `Working on deployment.\\n${meta}`)}
128
+ ${makeRunResultCall(2, `Still deploying.\\n${meta}`)}
129
+ ${makeRunResultCall(3, `Almost done.\\n${meta}`)}
130
+ ${makeRunResultCall(4, `Finishing up.\\n${meta}`)}
131
+ ${makeRunResultCall(5, `Final polish.\\n${meta}`)}
132
+
133
+ console.log(JSON.stringify({
134
+ chain1: state1?.followupChainCount ?? -1,
135
+ chain2: state2?.followupChainCount ?? -1,
136
+ chain3: state3?.followupChainCount ?? -1,
137
+ chain4: state4?.followupChainCount ?? -1,
138
+ chain5: state5?.followupChainCount ?? -1,
139
+ hasFollowup1: followup1 !== null,
140
+ hasFollowup2: followup2 !== null,
141
+ hasFollowup3: followup3 !== null,
142
+ hasFollowup4: followup4 !== null,
143
+ hasFollowup5: followup5 !== null,
144
+ }))
145
+ `)
146
+
147
+ // Chain increments 0→1→2→3, then at 3 the condition (3 < 3) is false → resets to 0
148
+ // Call 5 starts from 0 again → increments to 1
149
+ assert.equal(output.chain1, 1, 'first call increments to 1')
150
+ assert.equal(output.chain2, 2, 'second call increments to 2')
151
+ assert.equal(output.chain3, 3, 'third call increments to 3 (the cap)')
152
+ assert.equal(output.chain4, 0, 'fourth call resets to 0 because cap was reached')
153
+ assert.equal(output.chain5, 1, 'fifth call increments from 0 to 1 again')
154
+ assert.equal(output.hasFollowup1, true, 'followup returned for call 1')
155
+ assert.equal(output.hasFollowup2, true, 'followup returned for call 2')
156
+ assert.equal(output.hasFollowup3, true, 'followup returned for call 3')
157
+ assert.equal(output.hasFollowup4, false, 'no followup at cap boundary')
158
+ assert.equal(output.hasFollowup5, true, 'followup resumes after reset')
159
+ })
160
+
161
+ // ─────────────────────────────────────────────────────────────────────
162
+ // 2. Chain reset on terminal status (ok / HEARTBEAT_OK)
163
+ // ─────────────────────────────────────────────────────────────────────
164
+ it('followup chain resets to 0 on terminal ok status', () => {
165
+ const progressMeta = heartbeatMetaLine('progress', 'deploy', 'keep going')
166
+ const output = runWithTempDataDir(`
167
+ ${sessionSetupScript()}
168
+
169
+ ${makeRunResultCall(1, `Step one.\\n${progressMeta}`)}
170
+ ${makeRunResultCall(2, `Step two.\\n${progressMeta}`)}
171
+
172
+ // Now send a terminal ack
173
+ const followupOk = mainLoop.handleMainLoopRunResult({
174
+ sessionId: 'main',
175
+ message: 'Continue.',
176
+ internal: true,
177
+ source: 'heartbeat',
178
+ resultText: 'HEARTBEAT_OK',
179
+ })
180
+ const stateOk = mainLoop.getMainLoopStateForSession('main')
181
+
182
+ console.log(JSON.stringify({
183
+ chainBefore1: state1?.followupChainCount ?? -1,
184
+ chainBefore2: state2?.followupChainCount ?? -1,
185
+ chainAfterOk: stateOk?.followupChainCount ?? -1,
186
+ statusAfterOk: stateOk?.status ?? null,
187
+ followupOk: followupOk,
188
+ }))
189
+ `)
190
+
191
+ assert.equal(output.chainBefore1, 1)
192
+ assert.equal(output.chainBefore2, 2)
193
+ assert.equal(output.chainAfterOk, 0, 'chain resets on HEARTBEAT_OK')
194
+ assert.equal(output.statusAfterOk, 'ok', 'status becomes ok')
195
+ assert.equal(output.followupOk, null, 'no followup on terminal ack')
196
+ })
197
+
198
+ // ─────────────────────────────────────────────────────────────────────
199
+ // 3. Chain reset on error
200
+ // ─────────────────────────────────────────────────────────────────────
201
+ it('followup chain resets to 0 when error is present', () => {
202
+ const progressMeta = heartbeatMetaLine('progress', 'deploy', 'next step')
203
+ const output = runWithTempDataDir(`
204
+ ${sessionSetupScript()}
205
+
206
+ ${makeRunResultCall(1, `Working.\\n${progressMeta}`)}
207
+ ${makeRunResultCall(2, `More work.\\n${progressMeta}`)}
208
+
209
+ // Send error result
210
+ ${makeRunResultCall(3, 'Something broke.', { error: 'Connection timeout' })}
211
+
212
+ console.log(JSON.stringify({
213
+ chain1: state1?.followupChainCount ?? -1,
214
+ chain2: state2?.followupChainCount ?? -1,
215
+ chain3: state3?.followupChainCount ?? -1,
216
+ status3: state3?.status ?? null,
217
+ followup3: followup3,
218
+ }))
219
+ `)
220
+
221
+ assert.equal(output.chain1, 1)
222
+ assert.equal(output.chain2, 2)
223
+ assert.equal(output.chain3, 0, 'chain resets on error')
224
+ assert.equal(output.status3, 'blocked', 'status becomes blocked on error')
225
+ assert.equal(output.followup3, null, 'no followup on error')
226
+ })
227
+
228
+ // ─────────────────────────────────────────────────────────────────────
229
+ // 4. Event fan-out to main sessions only
230
+ // ─────────────────────────────────────────────────────────────────────
231
+ it('pushMainLoopEventToMainSessions only targets heartbeat-enabled sessions', () => {
232
+ const output = runWithTempDataDir(`
233
+ ${sessionSetupScript(
234
+ '',
235
+ `'non-hb': {
236
+ id: 'non-hb',
237
+ name: 'Non-HB Thread',
238
+ cwd: process.cwd(),
239
+ user: 'tester',
240
+ provider: 'openai',
241
+ model: 'gpt-test',
242
+ claudeSessionId: null,
243
+ messages: [{ role: 'user', text: 'Hello.', time: 1 }],
244
+ createdAt: 1,
245
+ lastActiveAt: 1,
246
+ sessionType: 'human',
247
+ agentId: 'agent-a',
248
+ heartbeatEnabled: false,
249
+ },`
250
+ )}
251
+
252
+ const count = mainLoop.pushMainLoopEventToMainSessions({
253
+ type: 'task_completed',
254
+ text: 'Deployment finished',
255
+ })
256
+
257
+ const mainState = mainLoop.getMainLoopStateForSession('main')
258
+ const nonHbState = mainLoop.getMainLoopStateForSession('non-hb')
259
+
260
+ console.log(JSON.stringify({
261
+ count,
262
+ mainPendingCount: mainState?.pendingEvents?.length ?? 0,
263
+ mainEventText: mainState?.pendingEvents?.[0]?.text ?? null,
264
+ nonHbState: nonHbState,
265
+ }))
266
+ `)
267
+
268
+ assert.equal(output.count, 1, 'only 1 session received the event')
269
+ assert.equal(output.mainPendingCount, 1, 'heartbeat-enabled session got the event')
270
+ assert.equal(output.mainEventText, 'Deployment finished')
271
+ assert.equal(output.nonHbState, null, 'non-heartbeat session has no state')
272
+ })
273
+
274
+ // ─────────────────────────────────────────────────────────────────────
275
+ // 5. Pending events cap at MAX_PENDING_EVENTS=16
276
+ // ─────────────────────────────────────────────────────────────────────
277
+ it('caps pending events at 16, keeping the most recent', () => {
278
+ const output = runWithTempDataDir(`
279
+ ${sessionSetupScript()}
280
+
281
+ for (let i = 0; i < 20; i++) {
282
+ mainLoop.pushMainLoopEventToMainSessions({
283
+ type: 'update',
284
+ text: 'Event number ' + i,
285
+ })
286
+ }
287
+
288
+ const state = mainLoop.getMainLoopStateForSession('main')
289
+ const firstEventText = state?.pendingEvents?.[0]?.text ?? null
290
+ const lastEventText = state?.pendingEvents?.[state.pendingEvents.length - 1]?.text ?? null
291
+
292
+ console.log(JSON.stringify({
293
+ pendingCount: state?.pendingEvents?.length ?? 0,
294
+ firstEventText,
295
+ lastEventText,
296
+ }))
297
+ `)
298
+
299
+ assert.equal(output.pendingCount, 16, 'capped at 16')
300
+ // The oldest events (0-3) should have been dropped; the most recent 16 (4-19) remain
301
+ assert.equal(output.firstEventText, 'Event number 4', 'oldest events are dropped')
302
+ assert.equal(output.lastEventText, 'Event number 19', 'newest events are kept')
303
+ })
304
+
305
+ // ─────────────────────────────────────────────────────────────────────
306
+ // 6. Timeline accumulation and cap at MAX_TIMELINE_ITEMS=40
307
+ // ─────────────────────────────────────────────────────────────────────
308
+ it('accumulates timeline entries with correct source/status and caps at 40', () => {
309
+ const output = runWithTempDataDir(`
310
+ ${sessionSetupScript()}
311
+
312
+ // Push enough run results to exceed the timeline cap.
313
+ // Each handleMainLoopRunResult with substantive text appends at least 1 timeline entry.
314
+ // With followup chaining, each also appends a 'followup' timeline entry → ~2 per call.
315
+ // We need >40 entries total, so 25 calls should exceed 40.
316
+ const chainCounts = []
317
+ for (let i = 0; i < 25; i++) {
318
+ const meta = '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"timeline test","next_action":"step ' + i + '"}'
319
+ mainLoop.handleMainLoopRunResult({
320
+ sessionId: 'main',
321
+ message: 'Continue timeline test ' + i + '.',
322
+ internal: true,
323
+ source: 'heartbeat',
324
+ resultText: 'Completed step ' + i + '.\\n' + meta,
325
+ inputTokens: 5,
326
+ outputTokens: 3,
327
+ })
328
+ const s = mainLoop.getMainLoopStateForSession('main')
329
+ chainCounts.push(s?.followupChainCount ?? -1)
330
+ }
331
+
332
+ const finalState = mainLoop.getMainLoopStateForSession('main')
333
+ const timelineLength = finalState?.timeline?.length ?? 0
334
+ const sources = [...new Set((finalState?.timeline || []).map(e => e.source))]
335
+ const hasProgressStatus = (finalState?.timeline || []).some(e => e.status === 'progress')
336
+
337
+ console.log(JSON.stringify({
338
+ timelineLength,
339
+ cappedAt40: timelineLength <= 40,
340
+ sources,
341
+ hasProgressStatus,
342
+ firstNote: finalState?.timeline?.[0]?.note ?? null,
343
+ lastNote: finalState?.timeline?.[timelineLength - 1]?.note ?? null,
344
+ }))
345
+ `)
346
+
347
+ assert.ok((output.timelineLength as number) > 0, 'timeline has entries')
348
+ assert.equal(output.cappedAt40, true, 'timeline is capped at 40')
349
+ assert.ok(
350
+ (output.sources as string[]).includes('heartbeat') || (output.sources as string[]).includes('followup'),
351
+ 'timeline has expected source values',
352
+ )
353
+ assert.equal(output.hasProgressStatus, true, 'timeline includes progress status entries')
354
+ })
355
+
356
+ // ─────────────────────────────────────────────────────────────────────
357
+ // 7. Working memory notes from tool events
358
+ // ─────────────────────────────────────────────────────────────────────
359
+ it('appends working memory notes when tool events are present', () => {
360
+ const output = runWithTempDataDir(`
361
+ ${sessionSetupScript()}
362
+
363
+ const meta = '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"research","next_action":"analyze"}'
364
+ mainLoop.handleMainLoopRunResult({
365
+ sessionId: 'main',
366
+ message: 'Research step.',
367
+ internal: true,
368
+ source: 'heartbeat',
369
+ resultText: 'Found important data.\\n' + meta,
370
+ toolEvents: [
371
+ { name: 'web_search', input: '{"query":"important finding about X"}' },
372
+ { name: 'shell', input: '{"action":"execute","command":"ls"}' },
373
+ ],
374
+ inputTokens: 20,
375
+ outputTokens: 10,
376
+ })
377
+
378
+ const state = mainLoop.getMainLoopStateForSession('main')
379
+
380
+ console.log(JSON.stringify({
381
+ workingMemoryNotes: state?.workingMemoryNotes ?? [],
382
+ hasToolNote: (state?.workingMemoryNotes ?? []).some(n => n.includes('web_search') || n.includes('shell')),
383
+ lastMemoryNoteAt: state?.lastMemoryNoteAt !== null,
384
+ }))
385
+ `)
386
+
387
+ assert.ok((output.workingMemoryNotes as string[]).length > 0, 'working memory has notes')
388
+ assert.equal(output.hasToolNote, true, 'working memory includes tool names')
389
+ assert.equal(output.lastMemoryNoteAt, true, 'lastMemoryNoteAt is set')
390
+ })
391
+
392
+ // ─────────────────────────────────────────────────────────────────────
393
+ // 8. Meta strip for persistence (direct import — no subprocess needed)
394
+ // ─────────────────────────────────────────────────────────────────────
395
+ it('stripMainLoopMetaForPersistence removes meta lines and preserves regular text', () => {
396
+ const input = [
397
+ 'Here is a normal analysis of the system.',
398
+ '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"test"}',
399
+ 'Another regular line with findings.',
400
+ '[MAIN_LOOP_PLAN]{"steps":["step1","step2"],"current_step":"step1"}',
401
+ 'Final observation about performance.',
402
+ '[MAIN_LOOP_REVIEW]{"note":"reviewed","confidence":0.8,"needs_replan":false}',
403
+ ].join('\n')
404
+
405
+ const result = stripMainLoopMetaForPersistence(input)
406
+
407
+ assert.ok(!result.includes('[AGENT_HEARTBEAT_META]'), 'heartbeat meta removed')
408
+ assert.ok(!result.includes('[MAIN_LOOP_PLAN]'), 'plan meta removed')
409
+ assert.ok(!result.includes('[MAIN_LOOP_REVIEW]'), 'review meta removed')
410
+ assert.ok(result.includes('Here is a normal analysis of the system.'), 'first regular line preserved')
411
+ assert.ok(result.includes('Another regular line with findings.'), 'second regular line preserved')
412
+ assert.ok(result.includes('Final observation about performance.'), 'third regular line preserved')
413
+ })
414
+
415
+ it('stripMainLoopMetaForPersistence handles text with no meta lines', () => {
416
+ const input = 'Just a simple message with no meta.'
417
+ const result = stripMainLoopMetaForPersistence(input)
418
+ assert.equal(result, input)
419
+ })
420
+
421
+ it('stripMainLoopMetaForPersistence handles text that is only meta', () => {
422
+ const input = '[AGENT_HEARTBEAT_META]{"status":"ok","goal":"done"}'
423
+ const result = stripMainLoopMetaForPersistence(input)
424
+ assert.equal(result, '')
425
+ })
426
+
427
+ // ─────────────────────────────────────────────────────────────────────
428
+ // 9. Status transitions (direct import via subprocess for state access)
429
+ // ─────────────────────────────────────────────────────────────────────
430
+ it('preserves all valid status values: idle, progress, blocked, ok', () => {
431
+ const output = runWithTempDataDir(`
432
+ ${sessionSetupScript()}
433
+
434
+ const statuses = ['idle', 'progress', 'blocked', 'ok']
435
+ const results = {}
436
+
437
+ for (const status of statuses) {
438
+ const meta = '[AGENT_HEARTBEAT_META]{"status":"' + status + '","goal":"status test ' + status + '"}'
439
+ // For terminal statuses we need non-internal source to avoid chain logic
440
+ // Actually internal + heartbeat source + no error works for progress/blocked/idle
441
+ // For 'ok' status without HEARTBEAT_OK text, it should preserve the status
442
+ mainLoop.handleMainLoopRunResult({
443
+ sessionId: 'main',
444
+ message: 'Testing status ' + status + '.',
445
+ internal: true,
446
+ source: 'heartbeat',
447
+ resultText: 'Status is ' + status + '.\\n' + meta,
448
+ inputTokens: 5,
449
+ outputTokens: 3,
450
+ })
451
+ const s = mainLoop.getMainLoopStateForSession('main')
452
+ results[status] = s?.status ?? null
453
+ }
454
+
455
+ console.log(JSON.stringify({ results }))
456
+ `)
457
+
458
+ const results = output.results as Record<string, string>
459
+ assert.equal(results.idle, 'idle', 'idle status preserved')
460
+ assert.equal(results.progress, 'progress', 'progress status preserved')
461
+ assert.equal(results.blocked, 'blocked', 'blocked status preserved')
462
+ assert.equal(results.ok, 'ok', 'ok status preserved')
463
+ })
464
+
465
+ // ─────────────────────────────────────────────────────────────────────
466
+ // 10. Mission cost accumulation
467
+ // ─────────────────────────────────────────────────────────────────────
468
+ it('accumulates missionCostUsd and missionTokens across multiple runs', () => {
469
+ const output = runWithTempDataDir(`
470
+ ${sessionSetupScript()}
471
+
472
+ const meta1 = '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"cost test","next_action":"step 1"}'
473
+ const meta2 = '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"cost test","next_action":"step 2"}'
474
+ const meta3 = '[AGENT_HEARTBEAT_META]{"status":"progress","goal":"cost test","next_action":"step 3"}'
475
+
476
+ mainLoop.handleMainLoopRunResult({
477
+ sessionId: 'main',
478
+ message: 'Cost step 1.',
479
+ internal: true,
480
+ source: 'heartbeat',
481
+ resultText: 'Step 1 complete.\\n' + meta1,
482
+ inputTokens: 100,
483
+ outputTokens: 50,
484
+ estimatedCost: 0.05,
485
+ })
486
+ const s1 = mainLoop.getMainLoopStateForSession('main')
487
+
488
+ mainLoop.handleMainLoopRunResult({
489
+ sessionId: 'main',
490
+ message: 'Cost step 2.',
491
+ internal: true,
492
+ source: 'heartbeat',
493
+ resultText: 'Step 2 complete.\\n' + meta2,
494
+ inputTokens: 200,
495
+ outputTokens: 100,
496
+ estimatedCost: 0.10,
497
+ })
498
+ const s2 = mainLoop.getMainLoopStateForSession('main')
499
+
500
+ mainLoop.handleMainLoopRunResult({
501
+ sessionId: 'main',
502
+ message: 'Cost step 3.',
503
+ internal: true,
504
+ source: 'heartbeat',
505
+ resultText: 'Step 3 complete.\\n' + meta3,
506
+ inputTokens: 300,
507
+ outputTokens: 150,
508
+ estimatedCost: 0.15,
509
+ })
510
+ const s3 = mainLoop.getMainLoopStateForSession('main')
511
+
512
+ console.log(JSON.stringify({
513
+ cost1: s1?.missionCostUsd ?? -1,
514
+ cost2: s2?.missionCostUsd ?? -1,
515
+ cost3: s3?.missionCostUsd ?? -1,
516
+ tokens1: s1?.missionTokens ?? -1,
517
+ tokens2: s2?.missionTokens ?? -1,
518
+ tokens3: s3?.missionTokens ?? -1,
519
+ }))
520
+ `)
521
+
522
+ assert.ok(
523
+ Math.abs((output.cost1 as number) - 0.05) < 0.001,
524
+ `cost after step 1 should be ~0.05, got ${output.cost1}`,
525
+ )
526
+ assert.ok(
527
+ Math.abs((output.cost2 as number) - 0.15) < 0.001,
528
+ `cost after step 2 should be ~0.15, got ${output.cost2}`,
529
+ )
530
+ assert.ok(
531
+ Math.abs((output.cost3 as number) - 0.30) < 0.001,
532
+ `cost after step 3 should be ~0.30, got ${output.cost3}`,
533
+ )
534
+ assert.equal(output.tokens1, 150, 'tokens after step 1: 100+50=150')
535
+ assert.equal(output.tokens2, 450, 'tokens after step 2: 150+200+100=450')
536
+ assert.equal(output.tokens3, 900, 'tokens after step 3: 450+300+150=900')
537
+ })
538
+ })