@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,564 @@
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 { describe, it, before, after } from 'node:test'
6
+
7
+ // ── Temp-dir env isolation ────────────────────────────────────────────
8
+ let tempDir: string
9
+ let storage: typeof import('../storage')
10
+ let dedupe: typeof import('../../schedule-dedupe')
11
+ let origin: typeof import('../../schedule-origin')
12
+ let scheduleName: typeof import('../../schedule-name')
13
+ let normalization: typeof import('../schedule-normalization')
14
+
15
+ const originalEnv = {
16
+ DATA_DIR: process.env.DATA_DIR,
17
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
18
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
19
+ }
20
+
21
+ before(async () => {
22
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-sched-adv-'))
23
+ const dataDir = path.join(tempDir, 'data')
24
+ const workspaceDir = path.join(tempDir, 'workspace')
25
+ fs.mkdirSync(dataDir, { recursive: true })
26
+ fs.mkdirSync(workspaceDir, { recursive: true })
27
+
28
+ process.env.DATA_DIR = dataDir
29
+ process.env.WORKSPACE_DIR = workspaceDir
30
+ delete process.env.SWARMCLAW_BUILD_MODE
31
+
32
+ storage = await import('../storage')
33
+ dedupe = await import('../../schedule-dedupe')
34
+ origin = await import('../../schedule-origin')
35
+ scheduleName = await import('../../schedule-name')
36
+ normalization = await import('../schedule-normalization')
37
+ })
38
+
39
+ after(() => {
40
+ process.env.DATA_DIR = originalEnv.DATA_DIR
41
+ process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
42
+ if (originalEnv.SWARMCLAW_BUILD_MODE != null) {
43
+ process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
44
+ }
45
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true })
46
+ })
47
+
48
+ // ── Helpers ───────────────────────────────────────────────────────────
49
+ function makeSchedule(overrides: Record<string, unknown> = {}) {
50
+ const now = Date.now()
51
+ return {
52
+ id: `sched-${Math.random().toString(36).slice(2, 8)}`,
53
+ name: 'Test Schedule',
54
+ agentId: 'agent-1',
55
+ taskPrompt: 'check server status',
56
+ scheduleType: 'interval' as const,
57
+ intervalMs: 60_000,
58
+ status: 'active' as const,
59
+ createdAt: now,
60
+ updatedAt: now,
61
+ ...overrides,
62
+ }
63
+ }
64
+
65
+ // ══════════════════════════════════════════════════════════════════════
66
+ // Schedule normalization
67
+ // ══════════════════════════════════════════════════════════════════════
68
+ describe('schedule normalization', () => {
69
+ it('1. interval schedule → nextRunAt = now + intervalMs', () => {
70
+ const now = 1_700_000_000_000
71
+ const result = normalization.normalizeSchedulePayload(
72
+ { scheduleType: 'interval', intervalMs: 60_000, agentId: 'a1', taskPrompt: 'do stuff' },
73
+ { now },
74
+ )
75
+ assert.equal(result.ok, true)
76
+ if (result.ok) {
77
+ assert.equal(result.value.nextRunAt, now + 60_000)
78
+ }
79
+ })
80
+
81
+ it('2. once schedule → nextRunAt = runAt', () => {
82
+ const runAt = 1_700_000_060_000
83
+ const result = normalization.normalizeSchedulePayload(
84
+ { scheduleType: 'once', runAt, agentId: 'a1', taskPrompt: 'one-shot' },
85
+ { now: 1_700_000_000_000 },
86
+ )
87
+ assert.equal(result.ok, true)
88
+ if (result.ok) {
89
+ assert.equal(result.value.nextRunAt, runAt)
90
+ }
91
+ })
92
+
93
+ it('3. missing taskPrompt with action/command → derives taskPrompt', () => {
94
+ const cwd = process.env.WORKSPACE_DIR!
95
+ const scriptPath = path.join(cwd, 'test_script.py')
96
+ fs.writeFileSync(scriptPath, 'print("ok")\n')
97
+
98
+ const result = normalization.normalizeSchedulePayload(
99
+ { scheduleType: 'interval', intervalMs: 60_000, agentId: 'a1', action: 'run_script', path: 'test_script.py' },
100
+ { cwd },
101
+ )
102
+ assert.equal(result.ok, true)
103
+ if (result.ok) {
104
+ assert.ok(typeof result.value.taskPrompt === 'string')
105
+ assert.ok((result.value.taskPrompt as string).length > 0)
106
+ assert.ok((result.value.taskPrompt as string).includes('test_script.py'))
107
+ }
108
+ })
109
+
110
+ it('4. invalid scheduleType → defaults to interval', () => {
111
+ const result = normalization.normalizeSchedulePayload(
112
+ { scheduleType: 'bogus', intervalMs: 5000, agentId: 'a1', taskPrompt: 'hello' },
113
+ { now: Date.now() },
114
+ )
115
+ assert.equal(result.ok, true)
116
+ if (result.ok) {
117
+ assert.equal(result.value.scheduleType, 'interval')
118
+ }
119
+ })
120
+ })
121
+
122
+ // ══════════════════════════════════════════════════════════════════════
123
+ // Schedule creation & storage
124
+ // ══════════════════════════════════════════════════════════════════════
125
+ describe('schedule creation & storage', () => {
126
+ it('5. create interval schedule → stored in loadSchedules()', () => {
127
+ const sched = makeSchedule({ id: 'int-1', scheduleType: 'interval', intervalMs: 30_000 })
128
+ storage.saveSchedules({ 'int-1': sched })
129
+ const loaded = storage.loadSchedules()
130
+ assert.ok(loaded['int-1'])
131
+ assert.equal(loaded['int-1'].scheduleType, 'interval')
132
+ assert.equal(loaded['int-1'].intervalMs, 30_000)
133
+ })
134
+
135
+ it('6. create cron schedule → stored with cron expression', () => {
136
+ const sched = makeSchedule({ id: 'cron-1', scheduleType: 'cron', cron: '*/5 * * * *' })
137
+ storage.saveSchedules({ 'cron-1': sched })
138
+ const loaded = storage.loadSchedules()
139
+ assert.ok(loaded['cron-1'])
140
+ assert.equal(loaded['cron-1'].cron, '*/5 * * * *')
141
+ })
142
+
143
+ it('7. create once schedule with runAt → stored correctly', () => {
144
+ const runAt = Date.now() + 3_600_000
145
+ const sched = makeSchedule({ id: 'once-1', scheduleType: 'once', runAt })
146
+ storage.saveSchedules({ 'once-1': sched })
147
+ const loaded = storage.loadSchedules()
148
+ assert.ok(loaded['once-1'])
149
+ assert.equal(loaded['once-1'].runAt, runAt)
150
+ assert.equal(loaded['once-1'].scheduleType, 'once')
151
+ })
152
+ })
153
+
154
+ // ══════════════════════════════════════════════════════════════════════
155
+ // Dedup on create
156
+ // ══════════════════════════════════════════════════════════════════════
157
+ describe('dedup on create', () => {
158
+ it('8. same agent, prompt, cadence → duplicate detected', () => {
159
+ const existing = {
160
+ s1: makeSchedule({ id: 's1', agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval', intervalMs: 60_000 }),
161
+ }
162
+ const candidate = { agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval' as const, intervalMs: 60_000 }
163
+ const dup = dedupe.findDuplicateSchedule(existing, candidate)
164
+ assert.ok(dup, 'expected duplicate to be found')
165
+ assert.equal(dup.id, 's1')
166
+ })
167
+
168
+ it('9. whitespace-normalized prompts match as duplicates', () => {
169
+ const existing = {
170
+ s1: makeSchedule({ id: 's1', agentId: 'a1', taskPrompt: ' deploy app ', scheduleType: 'interval', intervalMs: 60_000 }),
171
+ }
172
+ const candidate = { agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval' as const, intervalMs: 60_000 }
173
+ const dup = dedupe.findDuplicateSchedule(existing, candidate)
174
+ assert.ok(dup, 'whitespace-normalized prompt should match')
175
+ })
176
+
177
+ it('10. different agents → not duplicates', () => {
178
+ const existing = {
179
+ s1: makeSchedule({ id: 's1', agentId: 'agent-A', taskPrompt: 'deploy app', scheduleType: 'interval', intervalMs: 60_000 }),
180
+ }
181
+ const candidate = { agentId: 'agent-B', taskPrompt: 'deploy app', scheduleType: 'interval' as const, intervalMs: 60_000 }
182
+ const dup = dedupe.findDuplicateSchedule(existing, candidate)
183
+ assert.equal(dup, null, 'different agents should not be duplicates')
184
+ })
185
+
186
+ it('11. same prompt but different cadence type → not exact duplicates', () => {
187
+ const existing = {
188
+ s1: makeSchedule({ id: 's1', agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval', intervalMs: 60_000 }),
189
+ }
190
+ const candidate = { agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'cron' as const, cron: '*/1 * * * *' }
191
+ const dup = dedupe.findDuplicateSchedule(existing, candidate)
192
+ assert.equal(dup, null, 'different cadence type without session scope should not match')
193
+ })
194
+
195
+ it('12. fuzzy: similar prompts with same session → fuzzy match', () => {
196
+ const existing = {
197
+ s1: makeSchedule({
198
+ id: 's1',
199
+ agentId: 'a1',
200
+ taskPrompt: 'check server status',
201
+ scheduleType: 'interval',
202
+ intervalMs: 3_600_000,
203
+ createdByAgentId: 'a1',
204
+ createdInSessionId: 'sess-1',
205
+ }),
206
+ }
207
+ const candidate = {
208
+ agentId: 'a1',
209
+ taskPrompt: 'check the server status',
210
+ scheduleType: 'interval' as const,
211
+ intervalMs: 3_600_000,
212
+ createdByAgentId: 'a1',
213
+ createdInSessionId: 'sess-1',
214
+ }
215
+ const dup = dedupe.findDuplicateSchedule(existing, candidate, {
216
+ creatorScope: { sessionId: 'sess-1' },
217
+ })
218
+ assert.ok(dup, 'fuzzy prompt match should be found within same session')
219
+ })
220
+ })
221
+
222
+ // ══════════════════════════════════════════════════════════════════════
223
+ // Schedule name derivation
224
+ // ══════════════════════════════════════════════════════════════════════
225
+ describe('schedule name derivation', () => {
226
+ it('13. name provided → used as-is', () => {
227
+ const name = scheduleName.resolveScheduleName({ name: 'My Custom Name', taskPrompt: 'do stuff' })
228
+ assert.equal(name, 'My Custom Name')
229
+ })
230
+
231
+ it('14. no name, has taskPrompt → derived from prompt', () => {
232
+ const name = scheduleName.resolveScheduleName({ taskPrompt: 'backup the database daily' })
233
+ assert.ok(name.length > 0)
234
+ assert.notEqual(name, '')
235
+ })
236
+
237
+ it('15. long prompt → truncated name', () => {
238
+ const longPrompt = 'a'.repeat(200)
239
+ const name = scheduleName.resolveScheduleName({ taskPrompt: longPrompt })
240
+ assert.ok(name.length <= 83, `name should be truncated, got length ${name.length}`)
241
+ })
242
+ })
243
+
244
+ // ══════════════════════════════════════════════════════════════════════
245
+ // Creator scope
246
+ // ══════════════════════════════════════════════════════════════════════
247
+ describe('creator scope', () => {
248
+ it('16. schedule with createdByAgentId → isAgentCreatedSchedule returns true', () => {
249
+ const sched = makeSchedule({ createdByAgentId: 'agent-1' })
250
+ assert.equal(origin.isAgentCreatedSchedule(sched), true)
251
+ })
252
+
253
+ it('17. schedule without createdByAgentId → returns false', () => {
254
+ const sched = makeSchedule({ createdByAgentId: undefined })
255
+ // Remove the key entirely
256
+ const plain = { ...sched }
257
+ delete (plain as Record<string, unknown>).createdByAgentId
258
+ assert.equal(origin.isAgentCreatedSchedule(plain), false)
259
+ })
260
+
261
+ it('18. empty string createdByAgentId → returns false', () => {
262
+ const sched = makeSchedule({ createdByAgentId: '' })
263
+ assert.equal(origin.isAgentCreatedSchedule(sched), false)
264
+ })
265
+ })
266
+
267
+ // ══════════════════════════════════════════════════════════════════════
268
+ // Auto-delete logic
269
+ // ══════════════════════════════════════════════════════════════════════
270
+ describe('auto-delete logic', () => {
271
+ it('19. once + agent-created → shouldAutoDelete = true', () => {
272
+ const sched = makeSchedule({ scheduleType: 'once', createdByAgentId: 'agent-1' })
273
+ assert.equal(origin.shouldAutoDeleteScheduleAfterTerminalRun(sched), true)
274
+ })
275
+
276
+ it('20. interval + agent-created → false', () => {
277
+ const sched = makeSchedule({ scheduleType: 'interval', createdByAgentId: 'agent-1' })
278
+ assert.equal(origin.shouldAutoDeleteScheduleAfterTerminalRun(sched), false)
279
+ })
280
+
281
+ it('21. cron + agent-created → false', () => {
282
+ const sched = makeSchedule({ scheduleType: 'cron', createdByAgentId: 'agent-1' })
283
+ assert.equal(origin.shouldAutoDeleteScheduleAfterTerminalRun(sched), false)
284
+ })
285
+
286
+ it('22. once + manual (no createdByAgentId) → false', () => {
287
+ const plain = { scheduleType: 'once' as const, createdByAgentId: undefined }
288
+ assert.equal(origin.shouldAutoDeleteScheduleAfterTerminalRun(plain), false)
289
+ })
290
+ })
291
+
292
+ // ══════════════════════════════════════════════════════════════════════
293
+ // Related schedule discovery
294
+ // ══════════════════════════════════════════════════════════════════════
295
+ describe('related schedule discovery', () => {
296
+ it('23-24. findEquivalentSchedules returns all equivalent schedules', () => {
297
+ const base = {
298
+ agentId: 'a1',
299
+ taskPrompt: 'send weekly digest',
300
+ scheduleType: 'interval' as const,
301
+ intervalMs: 3_600_000,
302
+ status: 'active' as const,
303
+ createdByAgentId: 'a1',
304
+ createdInSessionId: 'sess-1',
305
+ }
306
+ const schedules: Record<string, ReturnType<typeof makeSchedule>> = {
307
+ eq1: makeSchedule({ ...base, id: 'eq1' }),
308
+ eq2: makeSchedule({ ...base, id: 'eq2' }),
309
+ eq3: makeSchedule({ ...base, id: 'eq3' }),
310
+ }
311
+ const candidate = { ...base, id: 'eq1' }
312
+ const equivalents = dedupe.findEquivalentSchedules(schedules, candidate, { ignoreId: 'eq1' })
313
+ assert.equal(equivalents.length, 2, 'should find 2 equivalents (excluding self)')
314
+ const ids = equivalents.map((s) => s.id)
315
+ assert.ok(ids.includes('eq2'))
316
+ assert.ok(ids.includes('eq3'))
317
+ })
318
+
319
+ it('25. paused schedule still found by findEquivalentSchedules (default includes paused)', () => {
320
+ const base = {
321
+ agentId: 'a1',
322
+ taskPrompt: 'send weekly digest',
323
+ scheduleType: 'interval' as const,
324
+ intervalMs: 3_600_000,
325
+ createdByAgentId: 'a1',
326
+ createdInSessionId: 'sess-1',
327
+ }
328
+ const schedules: Record<string, ReturnType<typeof makeSchedule>> = {
329
+ eq1: makeSchedule({ ...base, id: 'eq1', status: 'active' }),
330
+ eq2: makeSchedule({ ...base, id: 'eq2', status: 'paused' }),
331
+ }
332
+ const candidate = { ...base, id: 'eq1' }
333
+ const equivalents = dedupe.findEquivalentSchedules(schedules, candidate, { ignoreId: 'eq1' })
334
+ assert.equal(equivalents.length, 1)
335
+ assert.equal(equivalents[0].id, 'eq2')
336
+ })
337
+ })
338
+
339
+ // ══════════════════════════════════════════════════════════════════════
340
+ // Signature key stability
341
+ // ══════════════════════════════════════════════════════════════════════
342
+ describe('signature key stability', () => {
343
+ it('26. same schedule → same signature key', () => {
344
+ const sched = makeSchedule({ id: 'k1', agentId: 'a1', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 60_000 })
345
+ const key1 = dedupe.getScheduleSignatureKey(sched)
346
+ const key2 = dedupe.getScheduleSignatureKey(sched)
347
+ assert.equal(key1, key2)
348
+ assert.ok(key1.length > 0, 'key should not be empty')
349
+ })
350
+
351
+ it('27. different prompt → different key', () => {
352
+ const sched1 = makeSchedule({ agentId: 'a1', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 60_000 })
353
+ const sched2 = makeSchedule({ agentId: 'a1', taskPrompt: 'goodbye world', scheduleType: 'interval', intervalMs: 60_000 })
354
+ assert.notEqual(dedupe.getScheduleSignatureKey(sched1), dedupe.getScheduleSignatureKey(sched2))
355
+ })
356
+
357
+ it('28. different agent → different key', () => {
358
+ const sched1 = makeSchedule({ agentId: 'agent-A', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 60_000 })
359
+ const sched2 = makeSchedule({ agentId: 'agent-B', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 60_000 })
360
+ assert.notEqual(dedupe.getScheduleSignatureKey(sched1), dedupe.getScheduleSignatureKey(sched2))
361
+ })
362
+
363
+ it('29. different cadence → different key', () => {
364
+ const sched1 = makeSchedule({ agentId: 'a1', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 60_000 })
365
+ const sched2 = makeSchedule({ agentId: 'a1', taskPrompt: 'hello world', scheduleType: 'interval', intervalMs: 120_000 })
366
+ assert.notEqual(dedupe.getScheduleSignatureKey(sched1), dedupe.getScheduleSignatureKey(sched2))
367
+ })
368
+ })
369
+
370
+ // ══════════════════════════════════════════════════════════════════════
371
+ // Status transitions
372
+ // ══════════════════════════════════════════════════════════════════════
373
+ describe('status transitions', () => {
374
+ it('30. active → paused → active round-trip', () => {
375
+ const sched = makeSchedule({ id: 'rt-1', status: 'active' })
376
+ storage.saveSchedules({ 'rt-1': sched })
377
+
378
+ // Pause
379
+ const all1 = storage.loadSchedules()
380
+ all1['rt-1'].status = 'paused'
381
+ storage.saveSchedules(all1)
382
+ assert.equal(storage.loadSchedules()['rt-1'].status, 'paused')
383
+
384
+ // Reactivate
385
+ const all2 = storage.loadSchedules()
386
+ all2['rt-1'].status = 'active'
387
+ storage.saveSchedules(all2)
388
+ assert.equal(storage.loadSchedules()['rt-1'].status, 'active')
389
+ })
390
+
391
+ it('31. active → completed (once schedule after execution)', () => {
392
+ const sched = makeSchedule({ id: 'oc-1', scheduleType: 'once', status: 'active', runAt: Date.now() })
393
+ storage.saveSchedules({ 'oc-1': sched })
394
+
395
+ const all = storage.loadSchedules()
396
+ all['oc-1'].status = 'completed'
397
+ all['oc-1'].lastRunAt = Date.now()
398
+ storage.saveSchedules(all)
399
+ assert.equal(storage.loadSchedules()['oc-1'].status, 'completed')
400
+ })
401
+
402
+ it('32. schedule with status failed → excluded from normal dedup searches', () => {
403
+ const existing = {
404
+ f1: makeSchedule({
405
+ id: 'f1',
406
+ agentId: 'a1',
407
+ taskPrompt: 'deploy app',
408
+ scheduleType: 'interval',
409
+ intervalMs: 60_000,
410
+ status: 'failed',
411
+ }),
412
+ }
413
+ const candidate = { agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval' as const, intervalMs: 60_000 }
414
+ // Default includeStatuses is ['active', 'paused'] — 'failed' excluded
415
+ const dup = dedupe.findDuplicateSchedule(existing, candidate)
416
+ assert.equal(dup, null, 'failed schedules should be excluded from dedup')
417
+ })
418
+ })
419
+
420
+ // ══════════════════════════════════════════════════════════════════════
421
+ // Edge cases
422
+ // ══════════════════════════════════════════════════════════════════════
423
+ describe('edge cases', () => {
424
+ it('33. empty taskPrompt → validation error', () => {
425
+ const result = normalization.normalizeSchedulePayload(
426
+ { scheduleType: 'interval', intervalMs: 5000, agentId: 'a1', taskPrompt: '' },
427
+ { now: Date.now() },
428
+ )
429
+ assert.equal(result.ok, false)
430
+ if (!result.ok) {
431
+ assert.ok(result.error.length > 0)
432
+ }
433
+ })
434
+
435
+ it('34. null agentId → validation error', () => {
436
+ const result = normalization.normalizeSchedulePayload(
437
+ { scheduleType: 'interval', intervalMs: 5000, agentId: null, taskPrompt: 'hello' },
438
+ { now: Date.now() },
439
+ )
440
+ assert.equal(result.ok, false)
441
+ if (!result.ok) {
442
+ assert.ok(result.error.includes('agentId'))
443
+ }
444
+ })
445
+
446
+ it('35. very long cron expression → stored correctly', () => {
447
+ const longCron = '*/5 * * * *'
448
+ const sched = makeSchedule({ id: 'lc-1', scheduleType: 'cron', cron: longCron })
449
+ storage.saveSchedules({ 'lc-1': sched })
450
+ const loaded = storage.loadSchedules()
451
+ assert.equal(loaded['lc-1'].cron, longCron)
452
+ })
453
+ })
454
+
455
+ // ══════════════════════════════════════════════════════════════════════
456
+ // Additional edge cases & integration
457
+ // ══════════════════════════════════════════════════════════════════════
458
+ describe('additional scenarios', () => {
459
+ it('36. getScheduleSignatureKey returns empty for missing agentId', () => {
460
+ const sched = makeSchedule({ agentId: '', taskPrompt: 'hello' })
461
+ const key = dedupe.getScheduleSignatureKey(sched)
462
+ assert.equal(key, '')
463
+ })
464
+
465
+ it('37. getScheduleSignatureKey returns empty for missing taskPrompt', () => {
466
+ const sched = makeSchedule({ agentId: 'a1', taskPrompt: '' })
467
+ const key = dedupe.getScheduleSignatureKey(sched)
468
+ assert.equal(key, '')
469
+ })
470
+
471
+ it('38. cron schedule normalization does not set nextRunAt (no interval fallback)', () => {
472
+ const result = normalization.normalizeSchedulePayload(
473
+ { scheduleType: 'cron', cron: '0 9 * * *', agentId: 'a1', taskPrompt: 'daily task' },
474
+ { now: Date.now() },
475
+ )
476
+ assert.equal(result.ok, true)
477
+ if (result.ok) {
478
+ // cron nextRunAt is not set by normalizeSchedulePayload (calculated by the scheduler)
479
+ assert.equal(result.value.nextRunAt, undefined)
480
+ }
481
+ })
482
+
483
+ it('39. duplicate with ignoreId → self is excluded', () => {
484
+ const existing = {
485
+ s1: makeSchedule({ id: 's1', agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval', intervalMs: 60_000 }),
486
+ }
487
+ const candidate = { id: 's1', agentId: 'a1', taskPrompt: 'deploy app', scheduleType: 'interval' as const, intervalMs: 60_000 }
488
+ const dup = dedupe.findDuplicateSchedule(existing, candidate, { ignoreId: 's1' })
489
+ assert.equal(dup, null, 'should not match self when ignoreId is set')
490
+ })
491
+
492
+ it('40. resolveScheduleName for generic name falls back to prompt derivation', () => {
493
+ const name = scheduleName.resolveScheduleName({ name: 'schedule', taskPrompt: 'backup the database' })
494
+ // 'schedule' is generic, so it should derive from taskPrompt
495
+ assert.notEqual(name, 'schedule')
496
+ assert.ok(name.length > 0)
497
+ })
498
+
499
+ it('41. saveSchedules overwrites entire collection', () => {
500
+ storage.saveSchedules({ x1: makeSchedule({ id: 'x1' }) })
501
+ storage.saveSchedules({ x2: makeSchedule({ id: 'x2' }) })
502
+ const loaded = storage.loadSchedules()
503
+ assert.equal(loaded['x1'], undefined, 'x1 should be gone after full overwrite')
504
+ assert.ok(loaded['x2'])
505
+ })
506
+
507
+ it('42. normalizeSchedulePayload with command → derives taskPrompt from command', () => {
508
+ const result = normalization.normalizeSchedulePayload(
509
+ { scheduleType: 'interval', intervalMs: 5000, agentId: 'a1', command: 'echo hello' },
510
+ { now: Date.now() },
511
+ )
512
+ assert.equal(result.ok, true)
513
+ if (result.ok) {
514
+ assert.ok(typeof result.value.taskPrompt === 'string')
515
+ assert.ok((result.value.taskPrompt as string).includes('echo hello'))
516
+ }
517
+ })
518
+
519
+ it('43. once schedule without runAt still normalizes', () => {
520
+ const result = normalization.normalizeSchedulePayload(
521
+ { scheduleType: 'once', agentId: 'a1', taskPrompt: 'one shot' },
522
+ { now: Date.now() },
523
+ )
524
+ assert.equal(result.ok, true)
525
+ if (result.ok) {
526
+ // No runAt means no nextRunAt
527
+ assert.equal(result.value.nextRunAt, undefined)
528
+ }
529
+ })
530
+
531
+ it('44. findEquivalentSchedules with completed status excluded by default', () => {
532
+ const existing = {
533
+ c1: makeSchedule({
534
+ id: 'c1',
535
+ agentId: 'a1',
536
+ taskPrompt: 'run backup',
537
+ scheduleType: 'interval',
538
+ intervalMs: 60_000,
539
+ status: 'completed',
540
+ }),
541
+ }
542
+ const candidate = { agentId: 'a1', taskPrompt: 'run backup', scheduleType: 'interval' as const, intervalMs: 60_000 }
543
+ const results = dedupe.findEquivalentSchedules(existing, candidate)
544
+ assert.equal(results.length, 0, 'completed should be excluded by default')
545
+ })
546
+
547
+ it('45. findEquivalentSchedules with explicit includeStatuses includes completed', () => {
548
+ const existing = {
549
+ c1: makeSchedule({
550
+ id: 'c1',
551
+ agentId: 'a1',
552
+ taskPrompt: 'run backup',
553
+ scheduleType: 'interval',
554
+ intervalMs: 60_000,
555
+ status: 'completed',
556
+ }),
557
+ }
558
+ const candidate = { agentId: 'a1', taskPrompt: 'run backup', scheduleType: 'interval' as const, intervalMs: 60_000 }
559
+ const results = dedupe.findEquivalentSchedules(existing, candidate, {
560
+ includeStatuses: ['active', 'paused', 'completed'],
561
+ })
562
+ assert.equal(results.length, 1)
563
+ })
564
+ })