@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,1335 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import {
4
+ findDuplicateSchedule,
5
+ findEquivalentSchedules,
6
+ getScheduleSignatureKey,
7
+ type ScheduleLike,
8
+ } from './schedule-dedupe.ts'
9
+ import {
10
+ isAgentCreatedSchedule,
11
+ shouldAutoDeleteScheduleAfterTerminalRun,
12
+ } from './schedule-origin.ts'
13
+ import type { Schedule } from '@/types'
14
+
15
+ function makeSchedule(partial?: Partial<ScheduleLike>): ScheduleLike {
16
+ const now = Date.now()
17
+ return {
18
+ id: 'sched-1',
19
+ name: 'Test',
20
+ agentId: 'agent-a',
21
+ taskPrompt: 'do something',
22
+ scheduleType: 'interval',
23
+ intervalMs: 3600000,
24
+ status: 'active',
25
+ createdAt: now,
26
+ updatedAt: now,
27
+ ...partial,
28
+ }
29
+ }
30
+
31
+ function makeFullSchedule(partial?: Partial<Schedule>): Schedule {
32
+ const now = Date.now()
33
+ return {
34
+ id: 'sched-1',
35
+ name: 'Test',
36
+ agentId: 'agent-a',
37
+ taskPrompt: 'do something',
38
+ scheduleType: 'interval',
39
+ intervalMs: 3600000,
40
+ status: 'active',
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ ...partial,
44
+ }
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // 1. Exact duplicate detection
49
+ // ---------------------------------------------------------------------------
50
+ describe('exact duplicate detection', () => {
51
+ it('matches two schedules with same agentId, same prompt, same cron', () => {
52
+ const schedules: Record<string, ScheduleLike> = {
53
+ existing: makeSchedule({
54
+ id: 'existing',
55
+ agentId: 'agent-a',
56
+ taskPrompt: 'Generate weekly sales report',
57
+ scheduleType: 'cron',
58
+ cron: '0 9 * * 1',
59
+ intervalMs: null,
60
+ }),
61
+ }
62
+
63
+ const result = findDuplicateSchedule(schedules, {
64
+ agentId: 'agent-a',
65
+ taskPrompt: 'generate weekly sales report',
66
+ scheduleType: 'cron',
67
+ cron: '0 9 * * 1',
68
+ })
69
+
70
+ assert.ok(result, 'should find exact duplicate')
71
+ assert.equal(result?.id, 'existing')
72
+ })
73
+
74
+ it('matches exact duplicate with interval cadence', () => {
75
+ const schedules: Record<string, ScheduleLike> = {
76
+ s1: makeSchedule({
77
+ id: 's1',
78
+ taskPrompt: 'Ping healthcheck endpoint',
79
+ scheduleType: 'interval',
80
+ intervalMs: 300_000,
81
+ }),
82
+ }
83
+
84
+ const result = findDuplicateSchedule(schedules, {
85
+ agentId: 'agent-a',
86
+ taskPrompt: 'ping healthcheck endpoint',
87
+ scheduleType: 'interval',
88
+ intervalMs: 300_000,
89
+ })
90
+
91
+ assert.ok(result)
92
+ assert.equal(result?.id, 's1')
93
+ })
94
+
95
+ it('matches exact duplicate ignoring extra whitespace in prompt', () => {
96
+ const schedules: Record<string, ScheduleLike> = {
97
+ s1: makeSchedule({
98
+ id: 's1',
99
+ taskPrompt: ' Check disk space ',
100
+ scheduleType: 'interval',
101
+ intervalMs: 60_000,
102
+ }),
103
+ }
104
+
105
+ const result = findDuplicateSchedule(schedules, {
106
+ agentId: 'agent-a',
107
+ taskPrompt: 'check disk space',
108
+ scheduleType: 'interval',
109
+ intervalMs: 60_000,
110
+ })
111
+
112
+ assert.ok(result)
113
+ assert.equal(result?.id, 's1')
114
+ })
115
+
116
+ it('does not match when agentId differs', () => {
117
+ const schedules: Record<string, ScheduleLike> = {
118
+ s1: makeSchedule({
119
+ id: 's1',
120
+ agentId: 'agent-a',
121
+ taskPrompt: 'Run tests',
122
+ scheduleType: 'interval',
123
+ intervalMs: 60_000,
124
+ }),
125
+ }
126
+
127
+ const result = findDuplicateSchedule(schedules, {
128
+ agentId: 'agent-b',
129
+ taskPrompt: 'Run tests',
130
+ scheduleType: 'interval',
131
+ intervalMs: 60_000,
132
+ })
133
+
134
+ assert.equal(result, null, 'different agentId should not match')
135
+ })
136
+ })
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // 2. Fuzzy matching with reworded prompts
140
+ // ---------------------------------------------------------------------------
141
+ describe('fuzzy matching with reworded prompts', () => {
142
+ it('fuzzy-matches "Send daily weather report to Slack" vs reworded variant', () => {
143
+ const schedules: Record<string, ScheduleLike> = {
144
+ s1: makeSchedule({
145
+ id: 's1',
146
+ agentId: 'agent-a',
147
+ taskPrompt: 'Send daily weather report to Slack',
148
+ scheduleType: 'interval',
149
+ intervalMs: 86_400_000,
150
+ createdByAgentId: 'agent-a',
151
+ createdInSessionId: 'sess-1',
152
+ }),
153
+ }
154
+
155
+ const result = findDuplicateSchedule(
156
+ schedules,
157
+ {
158
+ agentId: 'agent-a',
159
+ taskPrompt: 'send the weather report daily to slack channel',
160
+ scheduleType: 'interval',
161
+ intervalMs: 86_400_000,
162
+ createdByAgentId: 'agent-a',
163
+ createdInSessionId: 'sess-1',
164
+ },
165
+ {
166
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
167
+ },
168
+ )
169
+
170
+ assert.ok(result, 'should fuzzy match reworded prompt within same session')
171
+ assert.equal(result?.id, 's1')
172
+ })
173
+
174
+ it('fuzzy-matches when cadence family is the same but exact cadence differs', () => {
175
+ const schedules: Record<string, ScheduleLike> = {
176
+ s1: makeSchedule({
177
+ id: 's1',
178
+ agentId: 'agent-a',
179
+ taskPrompt: 'Send daily weather report to Slack',
180
+ scheduleType: 'cron',
181
+ cron: '0 9 * * *',
182
+ intervalMs: null,
183
+ createdByAgentId: 'agent-a',
184
+ createdInSessionId: 'sess-1',
185
+ }),
186
+ }
187
+
188
+ // interval 86_400_000ms = daily family, cron "0 9 * * *" also resolves to daily
189
+ const result = findDuplicateSchedule(
190
+ schedules,
191
+ {
192
+ agentId: 'agent-a',
193
+ taskPrompt: 'send the weather report daily to slack channel',
194
+ scheduleType: 'interval',
195
+ intervalMs: 86_400_000,
196
+ createdByAgentId: 'agent-a',
197
+ createdInSessionId: 'sess-1',
198
+ },
199
+ {
200
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
201
+ },
202
+ )
203
+
204
+ assert.ok(result, 'should fuzzy match across cron/interval with same cadence family')
205
+ })
206
+ })
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // 3. Fuzzy matching rejection on low overlap
210
+ // ---------------------------------------------------------------------------
211
+ describe('fuzzy matching rejection on low overlap', () => {
212
+ it('rejects fuzzy match between unrelated prompts with same cadence', () => {
213
+ const schedules: Record<string, ScheduleLike> = {
214
+ s1: makeSchedule({
215
+ id: 's1',
216
+ agentId: 'agent-a',
217
+ taskPrompt: 'Check server status',
218
+ scheduleType: 'interval',
219
+ intervalMs: 3_600_000,
220
+ createdByAgentId: 'agent-a',
221
+ createdInSessionId: 'sess-1',
222
+ }),
223
+ }
224
+
225
+ const result = findDuplicateSchedule(
226
+ schedules,
227
+ {
228
+ agentId: 'agent-a',
229
+ taskPrompt: 'Send email newsletter',
230
+ scheduleType: 'interval',
231
+ intervalMs: 3_600_000,
232
+ createdByAgentId: 'agent-a',
233
+ createdInSessionId: 'sess-1',
234
+ },
235
+ {
236
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
237
+ },
238
+ )
239
+
240
+ assert.equal(result, null, 'completely different prompts should not fuzzy match')
241
+ })
242
+
243
+ it('rejects fuzzy match when only one token overlaps', () => {
244
+ const schedules: Record<string, ScheduleLike> = {
245
+ s1: makeSchedule({
246
+ id: 's1',
247
+ agentId: 'agent-a',
248
+ taskPrompt: 'Monitor database performance metrics',
249
+ scheduleType: 'interval',
250
+ intervalMs: 3_600_000,
251
+ createdByAgentId: 'agent-a',
252
+ createdInSessionId: 'sess-1',
253
+ }),
254
+ }
255
+
256
+ const result = findDuplicateSchedule(
257
+ schedules,
258
+ {
259
+ agentId: 'agent-a',
260
+ taskPrompt: 'Deploy database migration scripts',
261
+ scheduleType: 'interval',
262
+ intervalMs: 3_600_000,
263
+ createdByAgentId: 'agent-a',
264
+ createdInSessionId: 'sess-1',
265
+ },
266
+ {
267
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
268
+ },
269
+ )
270
+
271
+ // "database" overlaps but coverage/jaccard thresholds not met
272
+ assert.equal(result, null, 'single-token overlap should not pass fuzzy threshold')
273
+ })
274
+ })
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // 4. Cross-session scope isolation
278
+ // ---------------------------------------------------------------------------
279
+ describe('cross-session scope isolation', () => {
280
+ it('session-scoped search does NOT match schedule from different session', () => {
281
+ const schedules: Record<string, ScheduleLike> = {
282
+ s1: makeSchedule({
283
+ id: 's1',
284
+ agentId: 'agent-a',
285
+ taskPrompt: 'Daily standup summary',
286
+ scheduleType: 'cron',
287
+ cron: '0 9 * * *',
288
+ intervalMs: null,
289
+ createdByAgentId: 'agent-a',
290
+ createdInSessionId: 'sess-1',
291
+ }),
292
+ }
293
+
294
+ // Fuzzy match candidate from a different session — creatorScope sessionId filters it out
295
+ const result = findDuplicateSchedule(
296
+ schedules,
297
+ {
298
+ agentId: 'agent-a',
299
+ taskPrompt: 'Standup daily summary notes',
300
+ scheduleType: 'interval',
301
+ intervalMs: 86_400_000,
302
+ createdByAgentId: 'agent-a',
303
+ createdInSessionId: 'sess-2',
304
+ },
305
+ {
306
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-2' },
307
+ },
308
+ )
309
+
310
+ assert.equal(result, null, 'session scope should prevent cross-session fuzzy match')
311
+ })
312
+
313
+ it('agent-scoped search DOES match same-agent schedule from any session (exact match)', () => {
314
+ const schedules: Record<string, ScheduleLike> = {
315
+ s1: makeSchedule({
316
+ id: 's1',
317
+ agentId: 'agent-a',
318
+ taskPrompt: 'Daily standup summary',
319
+ scheduleType: 'cron',
320
+ cron: '0 9 * * *',
321
+ intervalMs: null,
322
+ createdByAgentId: 'agent-a',
323
+ createdInSessionId: 'sess-1',
324
+ }),
325
+ }
326
+
327
+ // Exact match (same prompt, same cadence) should work without session scope
328
+ const result = findDuplicateSchedule(
329
+ schedules,
330
+ {
331
+ agentId: 'agent-a',
332
+ taskPrompt: 'daily standup summary',
333
+ scheduleType: 'cron',
334
+ cron: '0 9 * * *',
335
+ createdByAgentId: 'agent-a',
336
+ createdInSessionId: 'sess-2',
337
+ },
338
+ )
339
+
340
+ assert.ok(result, 'exact match should work across sessions without session scope')
341
+ assert.equal(result?.id, 's1')
342
+ })
343
+
344
+ it('fuzzy match requires session scope to activate', () => {
345
+ const schedules: Record<string, ScheduleLike> = {
346
+ s1: makeSchedule({
347
+ id: 's1',
348
+ agentId: 'agent-a',
349
+ taskPrompt: 'Daily standup summary notes',
350
+ scheduleType: 'interval',
351
+ intervalMs: 86_400_000,
352
+ createdByAgentId: 'agent-a',
353
+ createdInSessionId: 'sess-1',
354
+ }),
355
+ }
356
+
357
+ // Without creatorScope.sessionId, fuzzy matching is off
358
+ const resultNoScope = findDuplicateSchedule(
359
+ schedules,
360
+ {
361
+ agentId: 'agent-a',
362
+ taskPrompt: 'Standup daily summary report notes',
363
+ scheduleType: 'interval',
364
+ intervalMs: 86_400_000,
365
+ createdByAgentId: 'agent-a',
366
+ createdInSessionId: 'sess-1',
367
+ },
368
+ )
369
+
370
+ assert.equal(resultNoScope, null, 'fuzzy match should NOT activate without session scope')
371
+
372
+ // With creatorScope.sessionId, fuzzy matching activates
373
+ const resultWithScope = findDuplicateSchedule(
374
+ schedules,
375
+ {
376
+ agentId: 'agent-a',
377
+ taskPrompt: 'Standup daily summary report notes',
378
+ scheduleType: 'interval',
379
+ intervalMs: 86_400_000,
380
+ createdByAgentId: 'agent-a',
381
+ createdInSessionId: 'sess-1',
382
+ },
383
+ {
384
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
385
+ },
386
+ )
387
+
388
+ assert.ok(resultWithScope, 'fuzzy match should activate with session scope')
389
+ })
390
+ })
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // 5. Cadence family matching
394
+ // ---------------------------------------------------------------------------
395
+ describe('cadence family matching', () => {
396
+ it('interval=900000 (15min) and interval=600000 (10min) are not the same family', () => {
397
+ // 15min is in the "15m" family (tolerance 1min), 10min = 600000 is outside that
398
+ // 10min doesn't match any family bucket — it becomes "interval:10m"
399
+ const schedules: Record<string, ScheduleLike> = {
400
+ s1: makeSchedule({
401
+ id: 's1',
402
+ agentId: 'agent-a',
403
+ taskPrompt: 'Monitor CPU temperature readings',
404
+ scheduleType: 'interval',
405
+ intervalMs: 900_000, // 15min
406
+ createdByAgentId: 'agent-a',
407
+ createdInSessionId: 'sess-1',
408
+ }),
409
+ }
410
+
411
+ const result = findDuplicateSchedule(
412
+ schedules,
413
+ {
414
+ agentId: 'agent-a',
415
+ taskPrompt: 'Monitor CPU temperature readings data',
416
+ scheduleType: 'interval',
417
+ intervalMs: 600_000, // 10min
418
+ createdByAgentId: 'agent-a',
419
+ createdInSessionId: 'sess-1',
420
+ },
421
+ {
422
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
423
+ },
424
+ )
425
+
426
+ assert.equal(result, null, '15min and 10min are in different cadence families')
427
+ })
428
+
429
+ it('two hourly intervals match within same cadence family', () => {
430
+ // 3600000 (60min) and 3_300_000 (55min) — hourly tolerance is 5min
431
+ const schedules: Record<string, ScheduleLike> = {
432
+ s1: makeSchedule({
433
+ id: 's1',
434
+ agentId: 'agent-a',
435
+ taskPrompt: 'Fetch latest crypto prices',
436
+ scheduleType: 'interval',
437
+ intervalMs: 3_600_000, // 60min
438
+ createdByAgentId: 'agent-a',
439
+ createdInSessionId: 'sess-1',
440
+ }),
441
+ }
442
+
443
+ const result = findDuplicateSchedule(
444
+ schedules,
445
+ {
446
+ agentId: 'agent-a',
447
+ taskPrompt: 'Fetch latest crypto prices data',
448
+ scheduleType: 'interval',
449
+ intervalMs: 3_300_000, // 55min — within 5min tolerance of hourly
450
+ createdByAgentId: 'agent-a',
451
+ createdInSessionId: 'sess-1',
452
+ },
453
+ {
454
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
455
+ },
456
+ )
457
+
458
+ assert.ok(result, 'both are in the "hourly" cadence family')
459
+ })
460
+
461
+ it('daily cron and daily interval share the same cadence family', () => {
462
+ const schedules: Record<string, ScheduleLike> = {
463
+ s1: makeSchedule({
464
+ id: 's1',
465
+ agentId: 'agent-a',
466
+ taskPrompt: 'Summarize server logs',
467
+ scheduleType: 'cron',
468
+ cron: '0 8 * * *', // once a day = ~86400000ms
469
+ intervalMs: null,
470
+ createdByAgentId: 'agent-a',
471
+ createdInSessionId: 'sess-1',
472
+ }),
473
+ }
474
+
475
+ const result = findDuplicateSchedule(
476
+ schedules,
477
+ {
478
+ agentId: 'agent-a',
479
+ taskPrompt: 'Summarize server log entries',
480
+ scheduleType: 'interval',
481
+ intervalMs: 86_400_000, // daily
482
+ createdByAgentId: 'agent-a',
483
+ createdInSessionId: 'sess-1',
484
+ },
485
+ {
486
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
487
+ },
488
+ )
489
+
490
+ assert.ok(result, 'daily cron and daily interval should be in same family')
491
+ })
492
+ })
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // 6. Once schedule window
496
+ // ---------------------------------------------------------------------------
497
+ describe('once schedule window', () => {
498
+ it('two "once" schedules within 15min window should fuzzy match', () => {
499
+ const baseTime = Date.now() + 3_600_000
500
+ const schedules: Record<string, ScheduleLike> = {
501
+ s1: makeSchedule({
502
+ id: 's1',
503
+ agentId: 'agent-a',
504
+ taskPrompt: 'Send reminder about meeting',
505
+ scheduleType: 'once',
506
+ intervalMs: null,
507
+ runAt: baseTime,
508
+ createdByAgentId: 'agent-a',
509
+ createdInSessionId: 'sess-1',
510
+ }),
511
+ }
512
+
513
+ const result = findDuplicateSchedule(
514
+ schedules,
515
+ {
516
+ agentId: 'agent-a',
517
+ taskPrompt: 'Send meeting reminder notification',
518
+ scheduleType: 'once',
519
+ runAt: baseTime + 5 * 60 * 1000, // 5 min later
520
+ createdByAgentId: 'agent-a',
521
+ createdInSessionId: 'sess-1',
522
+ },
523
+ {
524
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
525
+ },
526
+ )
527
+
528
+ assert.ok(result, 'once schedules 5min apart should fuzzy match')
529
+ assert.equal(result?.id, 's1')
530
+ })
531
+
532
+ it('two "once" schedules 30min apart should NOT match', () => {
533
+ const baseTime = Date.now() + 3_600_000
534
+ const schedules: Record<string, ScheduleLike> = {
535
+ s1: makeSchedule({
536
+ id: 's1',
537
+ agentId: 'agent-a',
538
+ taskPrompt: 'Send reminder about meeting',
539
+ scheduleType: 'once',
540
+ intervalMs: null,
541
+ runAt: baseTime,
542
+ createdByAgentId: 'agent-a',
543
+ createdInSessionId: 'sess-1',
544
+ }),
545
+ }
546
+
547
+ const result = findDuplicateSchedule(
548
+ schedules,
549
+ {
550
+ agentId: 'agent-a',
551
+ taskPrompt: 'Send meeting reminder notification',
552
+ scheduleType: 'once',
553
+ runAt: baseTime + 30 * 60 * 1000, // 30 min later — outside 15min window
554
+ createdByAgentId: 'agent-a',
555
+ createdInSessionId: 'sess-1',
556
+ },
557
+ {
558
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
559
+ },
560
+ )
561
+
562
+ assert.equal(result, null, 'once schedules 30min apart should NOT match')
563
+ })
564
+
565
+ it('two "once" schedules with exact same runAt (within 1s) are exact matches', () => {
566
+ const baseTime = Date.now() + 3_600_000
567
+ const schedules: Record<string, ScheduleLike> = {
568
+ s1: makeSchedule({
569
+ id: 's1',
570
+ agentId: 'agent-a',
571
+ taskPrompt: 'Send reminder about meeting',
572
+ scheduleType: 'once',
573
+ intervalMs: null,
574
+ runAt: baseTime,
575
+ }),
576
+ }
577
+
578
+ const result = findDuplicateSchedule(schedules, {
579
+ agentId: 'agent-a',
580
+ taskPrompt: 'send reminder about meeting',
581
+ scheduleType: 'once',
582
+ runAt: baseTime + 500, // within 1s tolerance
583
+ })
584
+
585
+ assert.ok(result, 'once schedules within 1s should exact match')
586
+ })
587
+ })
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // 7. Status filtering
591
+ // ---------------------------------------------------------------------------
592
+ describe('status filtering', () => {
593
+ it('active schedule is matched by default', () => {
594
+ const schedules: Record<string, ScheduleLike> = {
595
+ s1: makeSchedule({ id: 's1', status: 'active' }),
596
+ }
597
+
598
+ const result = findDuplicateSchedule(schedules, {
599
+ agentId: 'agent-a',
600
+ taskPrompt: 'do something',
601
+ scheduleType: 'interval',
602
+ intervalMs: 3_600_000,
603
+ })
604
+
605
+ assert.ok(result)
606
+ })
607
+
608
+ it('paused schedule is matched by default', () => {
609
+ const schedules: Record<string, ScheduleLike> = {
610
+ s1: makeSchedule({ id: 's1', status: 'paused' }),
611
+ }
612
+
613
+ const result = findDuplicateSchedule(schedules, {
614
+ agentId: 'agent-a',
615
+ taskPrompt: 'do something',
616
+ scheduleType: 'interval',
617
+ intervalMs: 3_600_000,
618
+ })
619
+
620
+ assert.ok(result)
621
+ })
622
+
623
+ it('completed schedule is NOT matched by default', () => {
624
+ const schedules: Record<string, ScheduleLike> = {
625
+ s1: makeSchedule({ id: 's1', status: 'completed' }),
626
+ }
627
+
628
+ const result = findDuplicateSchedule(schedules, {
629
+ agentId: 'agent-a',
630
+ taskPrompt: 'do something',
631
+ scheduleType: 'interval',
632
+ intervalMs: 3_600_000,
633
+ })
634
+
635
+ assert.equal(result, null)
636
+ })
637
+
638
+ it('failed schedule is NOT matched by default', () => {
639
+ const schedules: Record<string, ScheduleLike> = {
640
+ s1: makeSchedule({ id: 's1', status: 'failed' }),
641
+ }
642
+
643
+ const result = findDuplicateSchedule(schedules, {
644
+ agentId: 'agent-a',
645
+ taskPrompt: 'do something',
646
+ scheduleType: 'interval',
647
+ intervalMs: 3_600_000,
648
+ })
649
+
650
+ assert.equal(result, null)
651
+ })
652
+
653
+ it('completed schedule IS matched when explicitly included', () => {
654
+ const schedules: Record<string, ScheduleLike> = {
655
+ s1: makeSchedule({ id: 's1', status: 'completed' }),
656
+ }
657
+
658
+ const result = findDuplicateSchedule(
659
+ schedules,
660
+ {
661
+ agentId: 'agent-a',
662
+ taskPrompt: 'do something',
663
+ scheduleType: 'interval',
664
+ intervalMs: 3_600_000,
665
+ },
666
+ { includeStatuses: ['active', 'paused', 'completed'] },
667
+ )
668
+
669
+ assert.ok(result)
670
+ })
671
+ })
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // 8. Signature key stability
675
+ // ---------------------------------------------------------------------------
676
+ describe('signature key stability', () => {
677
+ it('same inputs produce identical keys', () => {
678
+ const keyA = getScheduleSignatureKey({
679
+ agentId: 'agent-a',
680
+ taskPrompt: ' Run daily backup ',
681
+ scheduleType: 'cron',
682
+ cron: '0 2 * * *',
683
+ })
684
+ const keyB = getScheduleSignatureKey({
685
+ agentId: 'agent-a',
686
+ taskPrompt: 'run daily backup',
687
+ scheduleType: 'cron',
688
+ cron: '0 2 * * *',
689
+ })
690
+
691
+ assert.ok(keyA, 'key should not be empty')
692
+ assert.equal(keyA, keyB, 'normalized equivalent inputs must produce same key')
693
+ })
694
+
695
+ it('different prompts produce different keys', () => {
696
+ const keyA = getScheduleSignatureKey({
697
+ agentId: 'agent-a',
698
+ taskPrompt: 'Run daily backup',
699
+ scheduleType: 'cron',
700
+ cron: '0 2 * * *',
701
+ })
702
+ const keyB = getScheduleSignatureKey({
703
+ agentId: 'agent-a',
704
+ taskPrompt: 'Deploy latest release',
705
+ scheduleType: 'cron',
706
+ cron: '0 2 * * *',
707
+ })
708
+
709
+ assert.notEqual(keyA, keyB, 'different prompts must produce different keys')
710
+ })
711
+
712
+ it('different agents produce different keys', () => {
713
+ const keyA = getScheduleSignatureKey({
714
+ agentId: 'agent-a',
715
+ taskPrompt: 'Run daily backup',
716
+ scheduleType: 'cron',
717
+ cron: '0 2 * * *',
718
+ })
719
+ const keyB = getScheduleSignatureKey({
720
+ agentId: 'agent-b',
721
+ taskPrompt: 'Run daily backup',
722
+ scheduleType: 'cron',
723
+ cron: '0 2 * * *',
724
+ })
725
+
726
+ assert.notEqual(keyA, keyB, 'different agents must produce different keys')
727
+ })
728
+
729
+ it('different cadence produces different keys', () => {
730
+ const keyA = getScheduleSignatureKey({
731
+ agentId: 'agent-a',
732
+ taskPrompt: 'Run backup',
733
+ scheduleType: 'cron',
734
+ cron: '0 2 * * *',
735
+ })
736
+ const keyB = getScheduleSignatureKey({
737
+ agentId: 'agent-a',
738
+ taskPrompt: 'Run backup',
739
+ scheduleType: 'cron',
740
+ cron: '0 6 * * *',
741
+ })
742
+
743
+ assert.notEqual(keyA, keyB, 'different cron cadence must produce different keys')
744
+ })
745
+
746
+ it('key format includes all four segments', () => {
747
+ const key = getScheduleSignatureKey({
748
+ agentId: 'agent-x',
749
+ taskPrompt: 'Ping server',
750
+ scheduleType: 'interval',
751
+ intervalMs: 60_000,
752
+ })
753
+
754
+ assert.ok(key)
755
+ const parts = key.split('::')
756
+ assert.equal(parts.length, 4, 'key must have 4 double-colon-separated segments')
757
+ assert.equal(parts[0], 'agent-x')
758
+ assert.equal(parts[2], 'interval')
759
+ })
760
+ })
761
+
762
+ // ---------------------------------------------------------------------------
763
+ // 9. Equivalent schedules ranking
764
+ // ---------------------------------------------------------------------------
765
+ describe('equivalent schedules ranking', () => {
766
+ it('returns exact match first, then fuzzy sorted by most recent updatedAt', () => {
767
+ const now = Date.now()
768
+ const schedules: Record<string, ScheduleLike> = {
769
+ fuzzyOld: makeSchedule({
770
+ id: 'fuzzyOld',
771
+ agentId: 'agent-a',
772
+ taskPrompt: 'Summarize server log entries daily',
773
+ scheduleType: 'interval',
774
+ intervalMs: 86_400_000,
775
+ updatedAt: now - 200_000,
776
+ createdByAgentId: 'agent-a',
777
+ createdInSessionId: 'sess-1',
778
+ }),
779
+ exact: makeSchedule({
780
+ id: 'exact',
781
+ agentId: 'agent-a',
782
+ taskPrompt: 'Summarize all server logs',
783
+ scheduleType: 'interval',
784
+ intervalMs: 86_400_000,
785
+ updatedAt: now - 300_000, // oldest, but exact
786
+ createdByAgentId: 'agent-a',
787
+ createdInSessionId: 'sess-1',
788
+ }),
789
+ fuzzyNew: makeSchedule({
790
+ id: 'fuzzyNew',
791
+ agentId: 'agent-a',
792
+ taskPrompt: 'Summarize server log data entries daily',
793
+ scheduleType: 'interval',
794
+ intervalMs: 86_400_000,
795
+ updatedAt: now - 100_000,
796
+ createdByAgentId: 'agent-a',
797
+ createdInSessionId: 'sess-1',
798
+ }),
799
+ }
800
+
801
+ const matches = findEquivalentSchedules(
802
+ schedules,
803
+ {
804
+ agentId: 'agent-a',
805
+ taskPrompt: 'Summarize all server logs',
806
+ scheduleType: 'interval',
807
+ intervalMs: 86_400_000,
808
+ createdByAgentId: 'agent-a',
809
+ createdInSessionId: 'sess-1',
810
+ },
811
+ {
812
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
813
+ },
814
+ )
815
+
816
+ assert.ok(matches.length >= 1, 'should have at least the exact match')
817
+
818
+ // The exact match should always be first regardless of updatedAt
819
+ assert.equal(matches[0]?.id, 'exact', 'exact match should come first')
820
+
821
+ // If fuzzy matches are present, they should be sorted by updatedAt desc
822
+ const fuzzyMatches = matches.slice(1)
823
+ if (fuzzyMatches.length >= 2) {
824
+ const fuzzyIds = fuzzyMatches.map((m) => m.id)
825
+ assert.equal(fuzzyIds[0], 'fuzzyNew', 'most recent fuzzy should come before older fuzzy')
826
+ assert.equal(fuzzyIds[1], 'fuzzyOld')
827
+ }
828
+ })
829
+
830
+ it('findDuplicateSchedule returns the first equivalent (exact over fuzzy)', () => {
831
+ const now = Date.now()
832
+ const schedules: Record<string, ScheduleLike> = {
833
+ fuzzyRecent: makeSchedule({
834
+ id: 'fuzzyRecent',
835
+ agentId: 'agent-a',
836
+ taskPrompt: 'Backup database snapshot tables',
837
+ scheduleType: 'interval',
838
+ intervalMs: 86_400_000,
839
+ updatedAt: now,
840
+ createdByAgentId: 'agent-a',
841
+ createdInSessionId: 'sess-1',
842
+ }),
843
+ exactOlder: makeSchedule({
844
+ id: 'exactOlder',
845
+ agentId: 'agent-a',
846
+ taskPrompt: 'Backup database tables',
847
+ scheduleType: 'interval',
848
+ intervalMs: 86_400_000,
849
+ updatedAt: now - 500_000,
850
+ createdByAgentId: 'agent-a',
851
+ createdInSessionId: 'sess-1',
852
+ }),
853
+ }
854
+
855
+ const result = findDuplicateSchedule(
856
+ schedules,
857
+ {
858
+ agentId: 'agent-a',
859
+ taskPrompt: 'backup database tables',
860
+ scheduleType: 'interval',
861
+ intervalMs: 86_400_000,
862
+ createdByAgentId: 'agent-a',
863
+ createdInSessionId: 'sess-1',
864
+ },
865
+ {
866
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
867
+ },
868
+ )
869
+
870
+ assert.ok(result)
871
+ assert.equal(result?.id, 'exactOlder', 'exact match should win over more recent fuzzy')
872
+ })
873
+ })
874
+
875
+ // ---------------------------------------------------------------------------
876
+ // 10. Agent-created vs manual schedules
877
+ // ---------------------------------------------------------------------------
878
+ describe('agent-created vs manual schedules', () => {
879
+ it('isAgentCreatedSchedule returns true when createdByAgentId is set', () => {
880
+ const schedule = makeFullSchedule({ createdByAgentId: 'agent-a' })
881
+ assert.equal(isAgentCreatedSchedule(schedule), true)
882
+ })
883
+
884
+ it('isAgentCreatedSchedule returns false when createdByAgentId is undefined', () => {
885
+ const schedule = makeFullSchedule({ createdByAgentId: undefined })
886
+ assert.equal(isAgentCreatedSchedule(schedule), false)
887
+ })
888
+
889
+ it('isAgentCreatedSchedule returns false when createdByAgentId is null', () => {
890
+ const schedule = makeFullSchedule({ createdByAgentId: null })
891
+ assert.equal(isAgentCreatedSchedule(schedule), false)
892
+ })
893
+
894
+ it('isAgentCreatedSchedule returns false when createdByAgentId is empty string', () => {
895
+ const schedule = makeFullSchedule({ createdByAgentId: '' })
896
+ assert.equal(isAgentCreatedSchedule(schedule), false)
897
+ })
898
+
899
+ it('isAgentCreatedSchedule returns false when createdByAgentId is whitespace', () => {
900
+ const schedule = makeFullSchedule({ createdByAgentId: ' ' })
901
+ assert.equal(isAgentCreatedSchedule(schedule), false)
902
+ })
903
+
904
+ it('isAgentCreatedSchedule returns false for null/undefined input', () => {
905
+ assert.equal(isAgentCreatedSchedule(null), false)
906
+ assert.equal(isAgentCreatedSchedule(undefined), false)
907
+ })
908
+ })
909
+
910
+ // ---------------------------------------------------------------------------
911
+ // 11. Auto-delete for terminal one-off agent schedules
912
+ // ---------------------------------------------------------------------------
913
+ describe('auto-delete for terminal one-off agent schedules', () => {
914
+ it('returns true for agent-created once-type schedule', () => {
915
+ const schedule = makeFullSchedule({
916
+ scheduleType: 'once',
917
+ status: 'completed',
918
+ createdByAgentId: 'agent-a',
919
+ })
920
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(schedule), true)
921
+ })
922
+
923
+ it('returns false for agent-created interval schedule', () => {
924
+ const schedule = makeFullSchedule({
925
+ scheduleType: 'interval',
926
+ status: 'completed',
927
+ createdByAgentId: 'agent-a',
928
+ })
929
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(schedule), false)
930
+ })
931
+
932
+ it('returns false for agent-created cron schedule', () => {
933
+ const schedule = makeFullSchedule({
934
+ scheduleType: 'cron',
935
+ status: 'completed',
936
+ createdByAgentId: 'agent-a',
937
+ })
938
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(schedule), false)
939
+ })
940
+
941
+ it('returns false for manual (user-created) once-type schedule', () => {
942
+ const schedule = makeFullSchedule({
943
+ scheduleType: 'once',
944
+ status: 'completed',
945
+ createdByAgentId: undefined,
946
+ })
947
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(schedule), false)
948
+ })
949
+
950
+ it('returns false for null/undefined input', () => {
951
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(null), false)
952
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(undefined), false)
953
+ })
954
+
955
+ it('returns true regardless of status field (function checks type + creator only)', () => {
956
+ // The function only checks scheduleType=once + createdByAgentId, not status
957
+ const activeOnce = makeFullSchedule({
958
+ scheduleType: 'once',
959
+ status: 'active',
960
+ createdByAgentId: 'agent-a',
961
+ })
962
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(activeOnce), true)
963
+
964
+ const failedOnce = makeFullSchedule({
965
+ scheduleType: 'once',
966
+ status: 'failed',
967
+ createdByAgentId: 'agent-a',
968
+ })
969
+ assert.equal(shouldAutoDeleteScheduleAfterTerminalRun(failedOnce), true)
970
+ })
971
+ })
972
+
973
+ // ---------------------------------------------------------------------------
974
+ // 12. Empty prompt handling
975
+ // ---------------------------------------------------------------------------
976
+ describe('empty prompt handling', () => {
977
+ it('getScheduleSignatureKey returns empty string for empty prompt', () => {
978
+ const key = getScheduleSignatureKey({
979
+ agentId: 'agent-a',
980
+ taskPrompt: '',
981
+ scheduleType: 'interval',
982
+ intervalMs: 60_000,
983
+ })
984
+ assert.equal(key, '', 'empty prompt should produce empty key')
985
+ })
986
+
987
+ it('getScheduleSignatureKey returns empty string for whitespace-only prompt', () => {
988
+ const key = getScheduleSignatureKey({
989
+ agentId: 'agent-a',
990
+ taskPrompt: ' \t\n ',
991
+ scheduleType: 'interval',
992
+ intervalMs: 60_000,
993
+ })
994
+ assert.equal(key, '', 'whitespace prompt should produce empty key')
995
+ })
996
+
997
+ it('getScheduleSignatureKey returns empty string for null prompt', () => {
998
+ const key = getScheduleSignatureKey({
999
+ agentId: 'agent-a',
1000
+ taskPrompt: null,
1001
+ scheduleType: 'interval',
1002
+ intervalMs: 60_000,
1003
+ })
1004
+ assert.equal(key, '', 'null prompt should produce empty key')
1005
+ })
1006
+
1007
+ it('getScheduleSignatureKey returns empty string for missing agentId', () => {
1008
+ const key = getScheduleSignatureKey({
1009
+ agentId: '',
1010
+ taskPrompt: 'do something',
1011
+ scheduleType: 'interval',
1012
+ intervalMs: 60_000,
1013
+ })
1014
+ assert.equal(key, '', 'empty agentId should produce empty key')
1015
+ })
1016
+
1017
+ it('findDuplicateSchedule returns null for empty prompt candidate', () => {
1018
+ const schedules: Record<string, ScheduleLike> = {
1019
+ s1: makeSchedule({ id: 's1' }),
1020
+ }
1021
+
1022
+ const result = findDuplicateSchedule(schedules, {
1023
+ agentId: 'agent-a',
1024
+ taskPrompt: '',
1025
+ scheduleType: 'interval',
1026
+ intervalMs: 3_600_000,
1027
+ })
1028
+
1029
+ assert.equal(result, null)
1030
+ })
1031
+
1032
+ it('findDuplicateSchedule returns null for missing agentId candidate', () => {
1033
+ const schedules: Record<string, ScheduleLike> = {
1034
+ s1: makeSchedule({ id: 's1' }),
1035
+ }
1036
+
1037
+ const result = findDuplicateSchedule(schedules, {
1038
+ agentId: '',
1039
+ taskPrompt: 'do something',
1040
+ scheduleType: 'interval',
1041
+ intervalMs: 3_600_000,
1042
+ })
1043
+
1044
+ assert.equal(result, null)
1045
+ })
1046
+ })
1047
+
1048
+ // ---------------------------------------------------------------------------
1049
+ // 13. Large batch dedup
1050
+ // ---------------------------------------------------------------------------
1051
+ describe('large batch dedup', () => {
1052
+ it('correctly groups 50 schedules across 5 agents with prompt variations', () => {
1053
+ const agents = ['agent-a', 'agent-b', 'agent-c', 'agent-d', 'agent-e']
1054
+ const basePrompts = [
1055
+ 'Check server health status',
1056
+ 'Generate weekly analytics report',
1057
+ 'Sync database backups offsite',
1058
+ 'Monitor API response times',
1059
+ 'Clean up temporary cache files',
1060
+ ]
1061
+
1062
+ const schedules: Record<string, ScheduleLike> = {}
1063
+ let counter = 0
1064
+
1065
+ // Create 50 schedules: 10 per agent, 2 variations per base prompt
1066
+ for (const agentId of agents) {
1067
+ for (let i = 0; i < basePrompts.length; i++) {
1068
+ for (let variant = 0; variant < 2; variant++) {
1069
+ const id = `sched-${counter++}`
1070
+ schedules[id] = makeSchedule({
1071
+ id,
1072
+ agentId,
1073
+ taskPrompt: variant === 0 ? basePrompts[i] : `${basePrompts[i]} now`,
1074
+ scheduleType: 'interval',
1075
+ intervalMs: 3_600_000,
1076
+ updatedAt: Date.now() - counter * 1000,
1077
+ })
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ assert.equal(Object.keys(schedules).length, 50, 'should have 50 schedules')
1083
+
1084
+ // For each agent, try to find a duplicate of the base prompt — should find exact match
1085
+ for (const agentId of agents) {
1086
+ const result = findDuplicateSchedule(schedules, {
1087
+ agentId,
1088
+ taskPrompt: 'check server health status',
1089
+ scheduleType: 'interval',
1090
+ intervalMs: 3_600_000,
1091
+ })
1092
+
1093
+ assert.ok(result, `should find exact duplicate for ${agentId}`)
1094
+ assert.equal(result?.agentId, agentId, 'match should be from the same agent')
1095
+ }
1096
+
1097
+ // Cross-agent: should NOT match agent-a prompt against agent-b schedule
1098
+ const crossResult = findDuplicateSchedule(schedules, {
1099
+ agentId: 'agent-nonexistent',
1100
+ taskPrompt: 'check server health status',
1101
+ scheduleType: 'interval',
1102
+ intervalMs: 3_600_000,
1103
+ })
1104
+
1105
+ assert.equal(crossResult, null, 'no schedule for non-existent agent')
1106
+ })
1107
+
1108
+ it('findEquivalentSchedules returns multiple matches within same agent', () => {
1109
+ const schedules: Record<string, ScheduleLike> = {}
1110
+ const now = Date.now()
1111
+
1112
+ // Create several schedules with the same agent and exact same prompt
1113
+ for (let i = 0; i < 5; i++) {
1114
+ const id = `dup-${i}`
1115
+ schedules[id] = makeSchedule({
1116
+ id,
1117
+ agentId: 'agent-a',
1118
+ taskPrompt: 'Run integration tests',
1119
+ scheduleType: 'interval',
1120
+ intervalMs: 3_600_000,
1121
+ status: 'active',
1122
+ updatedAt: now - i * 10_000,
1123
+ })
1124
+ }
1125
+
1126
+ const matches = findEquivalentSchedules(schedules, {
1127
+ agentId: 'agent-a',
1128
+ taskPrompt: 'run integration tests',
1129
+ scheduleType: 'interval',
1130
+ intervalMs: 3_600_000,
1131
+ })
1132
+
1133
+ assert.equal(matches.length, 5, 'should return all 5 exact matches')
1134
+
1135
+ // Verify sorted by updatedAt descending
1136
+ for (let i = 0; i < matches.length - 1; i++) {
1137
+ const current = matches[i].updatedAt ?? matches[i].createdAt ?? 0
1138
+ const next = matches[i + 1].updatedAt ?? matches[i + 1].createdAt ?? 0
1139
+ assert.ok(current >= next, 'matches should be sorted by updatedAt desc')
1140
+ }
1141
+ })
1142
+ })
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // 14. Cron vs interval differentiation
1146
+ // ---------------------------------------------------------------------------
1147
+ describe('cron vs interval differentiation', () => {
1148
+ it('same prompt with cron vs interval are NOT exact matches', () => {
1149
+ const schedules: Record<string, ScheduleLike> = {
1150
+ s1: makeSchedule({
1151
+ id: 's1',
1152
+ agentId: 'agent-a',
1153
+ taskPrompt: 'Send status report',
1154
+ scheduleType: 'cron',
1155
+ cron: '0 * * * *', // every hour
1156
+ intervalMs: null,
1157
+ }),
1158
+ }
1159
+
1160
+ // Same prompt but interval type — exact match requires same scheduleType
1161
+ const result = findDuplicateSchedule(schedules, {
1162
+ agentId: 'agent-a',
1163
+ taskPrompt: 'send status report',
1164
+ scheduleType: 'interval',
1165
+ intervalMs: 3_600_000,
1166
+ })
1167
+
1168
+ assert.equal(result, null, 'cron and interval with same prompt should NOT exact match')
1169
+ })
1170
+
1171
+ it('cron and interval of same family CAN fuzzy match with session scope', () => {
1172
+ const schedules: Record<string, ScheduleLike> = {
1173
+ s1: makeSchedule({
1174
+ id: 's1',
1175
+ agentId: 'agent-a',
1176
+ taskPrompt: 'Send status report summary',
1177
+ scheduleType: 'cron',
1178
+ cron: '0 * * * *', // every hour
1179
+ intervalMs: null,
1180
+ createdByAgentId: 'agent-a',
1181
+ createdInSessionId: 'sess-1',
1182
+ }),
1183
+ }
1184
+
1185
+ const result = findDuplicateSchedule(
1186
+ schedules,
1187
+ {
1188
+ agentId: 'agent-a',
1189
+ taskPrompt: 'Send status report data summary',
1190
+ scheduleType: 'interval',
1191
+ intervalMs: 3_600_000, // hourly
1192
+ createdByAgentId: 'agent-a',
1193
+ createdInSessionId: 'sess-1',
1194
+ },
1195
+ {
1196
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
1197
+ },
1198
+ )
1199
+
1200
+ // sameCadenceFamily considers cron resolved to hourly interval = same family
1201
+ assert.ok(result, 'cron hourly and interval hourly should fuzzy match via cadence family')
1202
+ })
1203
+
1204
+ it('once vs interval NEVER match even with session scope', () => {
1205
+ const schedules: Record<string, ScheduleLike> = {
1206
+ s1: makeSchedule({
1207
+ id: 's1',
1208
+ agentId: 'agent-a',
1209
+ taskPrompt: 'Send status report summary data',
1210
+ scheduleType: 'once',
1211
+ intervalMs: null,
1212
+ runAt: Date.now() + 60_000,
1213
+ createdByAgentId: 'agent-a',
1214
+ createdInSessionId: 'sess-1',
1215
+ }),
1216
+ }
1217
+
1218
+ const result = findDuplicateSchedule(
1219
+ schedules,
1220
+ {
1221
+ agentId: 'agent-a',
1222
+ taskPrompt: 'Send status report summary data entries',
1223
+ scheduleType: 'interval',
1224
+ intervalMs: 3_600_000,
1225
+ createdByAgentId: 'agent-a',
1226
+ createdInSessionId: 'sess-1',
1227
+ },
1228
+ {
1229
+ creatorScope: { agentId: 'agent-a', sessionId: 'sess-1' },
1230
+ },
1231
+ )
1232
+
1233
+ assert.equal(result, null, 'once and interval should never match')
1234
+ })
1235
+ })
1236
+
1237
+ // ---------------------------------------------------------------------------
1238
+ // Additional edge cases
1239
+ // ---------------------------------------------------------------------------
1240
+ describe('edge cases', () => {
1241
+ it('ignoreId option prevents self-matching', () => {
1242
+ const schedules: Record<string, ScheduleLike> = {
1243
+ s1: makeSchedule({ id: 's1' }),
1244
+ }
1245
+
1246
+ const result = findDuplicateSchedule(
1247
+ schedules,
1248
+ {
1249
+ id: 's1',
1250
+ agentId: 'agent-a',
1251
+ taskPrompt: 'do something',
1252
+ scheduleType: 'interval',
1253
+ intervalMs: 3_600_000,
1254
+ },
1255
+ )
1256
+
1257
+ assert.equal(result, null, 'candidate id in ignoreId should prevent self-match')
1258
+ })
1259
+
1260
+ it('explicit ignoreId overrides candidate id', () => {
1261
+ const schedules: Record<string, ScheduleLike> = {
1262
+ s1: makeSchedule({ id: 's1' }),
1263
+ s2: makeSchedule({ id: 's2' }),
1264
+ }
1265
+
1266
+ const result = findDuplicateSchedule(
1267
+ schedules,
1268
+ {
1269
+ id: 's1',
1270
+ agentId: 'agent-a',
1271
+ taskPrompt: 'do something',
1272
+ scheduleType: 'interval',
1273
+ intervalMs: 3_600_000,
1274
+ },
1275
+ { ignoreId: 's2' },
1276
+ )
1277
+
1278
+ // s2 is ignored via ignoreId, s1 is NOT ignored (explicit ignoreId overrides candidate.id)
1279
+ assert.ok(result)
1280
+ assert.equal(result?.id, 's1')
1281
+ })
1282
+
1283
+ it('empty schedule map returns null', () => {
1284
+ const result = findDuplicateSchedule({}, {
1285
+ agentId: 'agent-a',
1286
+ taskPrompt: 'do something',
1287
+ scheduleType: 'interval',
1288
+ intervalMs: 3_600_000,
1289
+ })
1290
+
1291
+ assert.equal(result, null)
1292
+ })
1293
+
1294
+ it('findEquivalentSchedules returns empty array for no matches', () => {
1295
+ const schedules: Record<string, ScheduleLike> = {
1296
+ s1: makeSchedule({
1297
+ id: 's1',
1298
+ agentId: 'agent-b',
1299
+ taskPrompt: 'completely different thing',
1300
+ }),
1301
+ }
1302
+
1303
+ const matches = findEquivalentSchedules(schedules, {
1304
+ agentId: 'agent-a',
1305
+ taskPrompt: 'do something',
1306
+ scheduleType: 'interval',
1307
+ intervalMs: 3_600_000,
1308
+ })
1309
+
1310
+ assert.deepEqual(matches, [])
1311
+ })
1312
+
1313
+ it('getScheduleSignatureKey handles interval with no intervalMs gracefully', () => {
1314
+ const key = getScheduleSignatureKey({
1315
+ agentId: 'agent-a',
1316
+ taskPrompt: 'do something',
1317
+ scheduleType: 'interval',
1318
+ intervalMs: null,
1319
+ })
1320
+
1321
+ // sameCadence returns false for interval with null intervalMs, so key is empty
1322
+ assert.equal(key, '', 'interval schedule without intervalMs should produce empty key')
1323
+ })
1324
+
1325
+ it('getScheduleSignatureKey handles cron with no cron string gracefully', () => {
1326
+ const key = getScheduleSignatureKey({
1327
+ agentId: 'agent-a',
1328
+ taskPrompt: 'do something',
1329
+ scheduleType: 'cron',
1330
+ cron: '',
1331
+ })
1332
+
1333
+ assert.equal(key, '', 'cron schedule without cron string should produce empty key')
1334
+ })
1335
+ })