@swarmclawai/swarmclaw 0.7.2 → 0.7.4

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 (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,222 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import type { Connector, Session } from '@/types'
4
+ import type { InboundMessage } from './types'
5
+ import {
6
+ buildConnectorConversationKey,
7
+ buildConnectorDoctorWarnings,
8
+ buildInboundDedupeKey,
9
+ getConnectorSessionStaleness,
10
+ isReplyToLastOutbound,
11
+ mergeInboundMessages,
12
+ normalizeConnectorGroupPolicy,
13
+ normalizeConnectorReplyMode,
14
+ normalizeConnectorSessionScope,
15
+ normalizeConnectorThreadBinding,
16
+ resetConnectorSessionRuntime,
17
+ resolveConnectorSessionPolicy,
18
+ shouldReplyToInboundMessage,
19
+ textMentionsAlias,
20
+ } from './policy'
21
+
22
+ function makeConnector(config: Record<string, string> = {}): Connector {
23
+ return {
24
+ id: 'connector-1',
25
+ name: 'Test Connector',
26
+ platform: 'slack',
27
+ agentId: 'agent-1',
28
+ chatroomId: null,
29
+ credentialId: 'cred-1',
30
+ config,
31
+ isEnabled: true,
32
+ status: 'running',
33
+ createdAt: 1,
34
+ updatedAt: 1,
35
+ }
36
+ }
37
+
38
+ function makeInbound(overrides: Partial<InboundMessage> = {}): InboundMessage {
39
+ return {
40
+ platform: 'slack',
41
+ channelId: 'C123',
42
+ channelName: 'general',
43
+ senderId: 'U123',
44
+ senderName: 'Alice',
45
+ text: 'hello',
46
+ isGroup: false,
47
+ ...overrides,
48
+ }
49
+ }
50
+
51
+ test('normalizers fall back safely', () => {
52
+ assert.equal(normalizeConnectorSessionScope('THREAD', 'channel'), 'thread')
53
+ assert.equal(normalizeConnectorSessionScope('unknown', 'channel'), 'channel')
54
+ assert.equal(normalizeConnectorReplyMode('ALL'), 'all')
55
+ assert.equal(normalizeConnectorReplyMode('weird'), 'first')
56
+ assert.equal(normalizeConnectorThreadBinding('STRICT'), 'strict')
57
+ assert.equal(normalizeConnectorThreadBinding('weird'), 'prefer')
58
+ assert.equal(normalizeConnectorGroupPolicy('MENTION'), 'mention')
59
+ assert.equal(normalizeConnectorGroupPolicy('nope'), 'reply-or-mention')
60
+ })
61
+
62
+ test('policy resolves DM and group defaults', () => {
63
+ const dmPolicy = resolveConnectorSessionPolicy(makeConnector(), makeInbound())
64
+ assert.equal(dmPolicy.scope, 'channel-peer')
65
+ assert.equal(dmPolicy.groupPolicy, 'reply-or-mention')
66
+ assert.equal(dmPolicy.typingIndicators, true)
67
+
68
+ const groupPolicy = resolveConnectorSessionPolicy(makeConnector(), makeInbound({ isGroup: true }))
69
+ assert.equal(groupPolicy.scope, 'channel')
70
+ })
71
+
72
+ test('policy resolves connector runtime defaults', () => {
73
+ const policy = resolveConnectorSessionPolicy(
74
+ makeConnector({
75
+ thinkingLevel: 'high',
76
+ providerOverride: 'openai',
77
+ modelOverride: 'gpt-4.1-mini',
78
+ typingIndicators: 'false',
79
+ }),
80
+ makeInbound(),
81
+ )
82
+ assert.equal(policy.thinkingLevel, 'high')
83
+ assert.equal(policy.providerOverride, 'openai')
84
+ assert.equal(policy.modelOverride, 'gpt-4.1-mini')
85
+ assert.equal(policy.typingIndicators, false)
86
+ })
87
+
88
+ test('conversation key uses thread scope when configured', () => {
89
+ const connector = makeConnector({ sessionScope: 'thread', threadBinding: 'strict' })
90
+ const msg = makeInbound({ isGroup: true, channelId: 'C999', threadId: 'T321' })
91
+ const policy = resolveConnectorSessionPolicy(connector, msg)
92
+ const key = buildConnectorConversationKey({ connector, msg, agentId: 'agent-1', policy })
93
+ assert.equal(key, 'connector:connector-1:agent:agent-1:channel:C999:thread:T321')
94
+ })
95
+
96
+ test('staleness detects idle and max age expiry', () => {
97
+ const connector = makeConnector({ idleTimeoutSec: '10', maxAgeSec: '20' })
98
+ const msg = makeInbound()
99
+ const policy = resolveConnectorSessionPolicy(connector, msg)
100
+ const session = {
101
+ id: 's1',
102
+ createdAt: 0,
103
+ lastActiveAt: 0,
104
+ messages: [{ role: 'user', text: 'hi', time: 0 }],
105
+ } as Partial<Session>
106
+ assert.deepEqual(getConnectorSessionStaleness(session, policy, 11_000), { stale: true, reason: 'idle_timeout:10' })
107
+ assert.deepEqual(getConnectorSessionStaleness(session, policy, 25_000), { stale: true, reason: 'idle_timeout:10' })
108
+ })
109
+
110
+ test('connector staleness supports daily reset mode', () => {
111
+ const connector = makeConnector({ sessionResetMode: 'daily', sessionDailyResetAt: '04:00', idleTimeoutSec: '0', maxAgeSec: '999999' })
112
+ const msg = makeInbound()
113
+ const policy = resolveConnectorSessionPolicy(connector, msg)
114
+ const session = {
115
+ id: 's2',
116
+ createdAt: Date.parse('2026-03-04T00:00:00.000Z'),
117
+ lastActiveAt: Date.parse('2026-03-05T03:30:00.000Z'),
118
+ messages: [{ role: 'user', text: 'hi', time: 0 }],
119
+ } as Partial<Session>
120
+ assert.deepEqual(
121
+ getConnectorSessionStaleness(session, { ...policy, resetTimezone: 'UTC' }, Date.parse('2026-03-05T10:00:00.000Z')),
122
+ { stale: true, reason: 'daily_reset:04:00' },
123
+ )
124
+ })
125
+
126
+ test('resetConnectorSessionRuntime clears conversation state', () => {
127
+ const session = {
128
+ id: 's1',
129
+ name: 'test',
130
+ cwd: '/',
131
+ user: 'u',
132
+ provider: 'openai',
133
+ model: 'gpt-4.1',
134
+ claudeSessionId: 'claude',
135
+ codexThreadId: 'codex',
136
+ opencodeSessionId: 'open',
137
+ delegateResumeIds: { claudeCode: 'x', codex: 'y', opencode: 'z', gemini: 'g' },
138
+ messages: [{ role: 'user', text: 'hi', time: 0 }],
139
+ createdAt: 0,
140
+ lastActiveAt: 0,
141
+ connectorContext: { lastOutboundMessageId: 'm1' },
142
+ } as Session
143
+ const cleared = resetConnectorSessionRuntime(session, 'idle_timeout:10')
144
+ assert.equal(cleared, 1)
145
+ assert.deepEqual(session.messages, [])
146
+ assert.equal(session.claudeSessionId, null)
147
+ assert.equal(session.connectorContext?.lastOutboundMessageId, null)
148
+ assert.equal(session.connectorContext?.lastResetReason, 'idle_timeout:10')
149
+ })
150
+
151
+ test('reply policy uses first reply once per session', () => {
152
+ const connector = makeConnector({ replyMode: 'first' })
153
+ const msg = makeInbound({ messageId: 'in-1' })
154
+ const policy = resolveConnectorSessionPolicy(connector, msg)
155
+ assert.deepEqual(shouldReplyToInboundMessage({ msg, policy }), { replyToMessageId: 'in-1', threadId: undefined })
156
+ assert.deepEqual(shouldReplyToInboundMessage({
157
+ msg,
158
+ policy,
159
+ session: { connectorContext: { lastOutboundMessageId: 'out-1' } } as Partial<Session>,
160
+ }), { replyToMessageId: undefined, threadId: undefined })
161
+ })
162
+
163
+ test('reply detection matches last outbound', () => {
164
+ const session = { connectorContext: { lastOutboundMessageId: 'out-1' } } as Partial<Session>
165
+ assert.equal(isReplyToLastOutbound(makeInbound({ replyToMessageId: 'out-1' }), session), true)
166
+ assert.equal(isReplyToLastOutbound(makeInbound({ replyToMessageId: 'out-2' }), session), false)
167
+ })
168
+
169
+ test('mergeInboundMessages combines text and media', () => {
170
+ const merged = mergeInboundMessages([
171
+ makeInbound({ text: 'first' }),
172
+ makeInbound({ text: 'second', media: [{ type: 'image', url: 'https://example.com/a.png' }] }),
173
+ ])
174
+ assert.equal(merged.text, 'first\nsecond')
175
+ assert.equal(merged.media?.length, 1)
176
+ })
177
+
178
+ test('textMentionsAlias catches plain and @ mentions', () => {
179
+ assert.equal(textMentionsAlias('hey swarmy can you help?', ['Swarmy']), true)
180
+ assert.equal(textMentionsAlias('@swarmy help', ['Swarmy']), true)
181
+ assert.equal(textMentionsAlias('hello team', ['Swarmy']), false)
182
+ })
183
+
184
+ test('dedupe key prefers explicit message ids', () => {
185
+ const connector = makeConnector()
186
+ assert.equal(buildInboundDedupeKey(connector, makeInbound({ messageId: 'm123' })), 'msg:connector-1:C123:m123')
187
+ assert.match(buildInboundDedupeKey(connector, makeInbound({ text: 'Hello there' })), /^text:connector-1:C123:U123:none:none:hello there$/)
188
+ })
189
+
190
+ test('doctor warnings flag unsafe defaults', () => {
191
+ const warnings = buildConnectorDoctorWarnings({
192
+ connector: makeConnector({
193
+ sessionScope: 'main',
194
+ groupPolicy: 'open',
195
+ replyMode: 'off',
196
+ threadBinding: 'off',
197
+ idleTimeoutSec: '0',
198
+ maxAgeSec: '0',
199
+ inboundDebounceMs: '0',
200
+ }),
201
+ msg: makeInbound({ isGroup: true }),
202
+ })
203
+ assert.ok(warnings.some((item) => item.includes('blend unrelated connector conversations')))
204
+ assert.ok(warnings.some((item) => item.includes('may speak in group chats without being mentioned')))
205
+ assert.ok(warnings.some((item) => item.includes('Inbound debounce is disabled')))
206
+ })
207
+
208
+ test('doctor warnings flag daily reset timezone and chatroom overrides', () => {
209
+ const connector = makeConnector({
210
+ sessionResetMode: 'daily',
211
+ sessionDailyResetAt: '04:00',
212
+ providerOverride: 'openai',
213
+ modelOverride: 'gpt-4.1-mini',
214
+ })
215
+ connector.chatroomId = 'chatroom-1'
216
+ const warnings = buildConnectorDoctorWarnings({
217
+ connector,
218
+ msg: makeInbound(),
219
+ })
220
+ assert.ok(warnings.some((item) => item.includes('server timezone')))
221
+ assert.ok(warnings.some((item) => item.includes('routes to a chatroom')))
222
+ })
@@ -0,0 +1,452 @@
1
+ import type { Connector, Session, SessionResetMode, SessionResetType } from '@/types'
2
+ import { getProvider } from '@/lib/providers'
3
+ import type { InboundMessage } from './types'
4
+ import { evaluateSessionFreshness, inferSessionResetType, resetSessionRuntime, resolveSessionResetPolicy } from '../session-reset-policy'
5
+ import { listStoredAllowedSenders, parseAllowFromCsv, parsePairingPolicy } from './pairing'
6
+ import { loadAgents, loadChatrooms, loadCredentials } from '../storage'
7
+
8
+ export type ConnectorSessionScope = 'main' | 'channel' | 'peer' | 'channel-peer' | 'thread'
9
+ export type ConnectorReplyMode = 'off' | 'first' | 'all'
10
+ export type ConnectorThreadBinding = 'off' | 'prefer' | 'strict'
11
+ export type ConnectorGroupPolicy = 'open' | 'mention' | 'reply-or-mention' | 'disabled'
12
+ export type ConnectorThinkingLevel = 'minimal' | 'low' | 'medium' | 'high'
13
+
14
+ export interface ResolvedConnectorSessionPolicy {
15
+ scope: ConnectorSessionScope
16
+ replyMode: ConnectorReplyMode
17
+ threadBinding: ConnectorThreadBinding
18
+ groupPolicy: ConnectorGroupPolicy
19
+ thinkingLevel: ConnectorThinkingLevel | null
20
+ providerOverride: string | null
21
+ modelOverride: string | null
22
+ resetType: SessionResetType
23
+ resetMode: SessionResetMode
24
+ idleTimeoutSec: number | null
25
+ maxAgeSec: number | null
26
+ dailyResetAt: string | null
27
+ resetTimezone: string | null
28
+ inboundDebounceMs: number
29
+ statusReactions: boolean
30
+ typingIndicators: boolean
31
+ }
32
+
33
+ export interface ConnectorSessionStaleness {
34
+ stale: boolean
35
+ reason?: string
36
+ }
37
+
38
+ const DEFAULT_DM_SCOPE: ConnectorSessionScope = 'channel-peer'
39
+ const DEFAULT_GROUP_SCOPE: ConnectorSessionScope = 'channel'
40
+ const DEFAULT_REPLY_MODE: ConnectorReplyMode = 'first'
41
+ const DEFAULT_THREAD_BINDING: ConnectorThreadBinding = 'prefer'
42
+ const DEFAULT_GROUP_POLICY: ConnectorGroupPolicy = 'reply-or-mention'
43
+ const DEFAULT_IDLE_TIMEOUT_SEC = 12 * 60 * 60
44
+ const DEFAULT_MAX_AGE_SEC = 7 * 24 * 60 * 60
45
+ const DEFAULT_INBOUND_DEBOUNCE_MS = 700
46
+
47
+ function parseIntBounded(raw: unknown, min: number, max: number): number | null {
48
+ if (raw === null || raw === undefined) return null
49
+ const parsed = typeof raw === 'number'
50
+ ? raw
51
+ : typeof raw === 'string'
52
+ ? Number.parseInt(raw.trim(), 10)
53
+ : Number.NaN
54
+ if (!Number.isFinite(parsed)) return null
55
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
56
+ }
57
+
58
+ function parseBool(raw: unknown, fallback: boolean): boolean {
59
+ if (typeof raw === 'boolean') return raw
60
+ if (typeof raw !== 'string') return fallback
61
+ const normalized = raw.trim().toLowerCase()
62
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true
63
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false
64
+ return fallback
65
+ }
66
+
67
+ function normalizeEnum<T extends string>(raw: unknown, allowed: readonly T[], fallback: T): T {
68
+ const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
69
+ return (allowed as readonly string[]).includes(normalized) ? normalized as T : fallback
70
+ }
71
+
72
+ export function normalizeConnectorSessionScope(raw: unknown, fallback: ConnectorSessionScope): ConnectorSessionScope {
73
+ return normalizeEnum(raw, ['main', 'channel', 'peer', 'channel-peer', 'thread'] as const, fallback)
74
+ }
75
+
76
+ export function normalizeConnectorReplyMode(raw: unknown, fallback: ConnectorReplyMode = DEFAULT_REPLY_MODE): ConnectorReplyMode {
77
+ return normalizeEnum(raw, ['off', 'first', 'all'] as const, fallback)
78
+ }
79
+
80
+ export function normalizeConnectorThreadBinding(raw: unknown, fallback: ConnectorThreadBinding = DEFAULT_THREAD_BINDING): ConnectorThreadBinding {
81
+ return normalizeEnum(raw, ['off', 'prefer', 'strict'] as const, fallback)
82
+ }
83
+
84
+ export function normalizeConnectorGroupPolicy(raw: unknown, fallback: ConnectorGroupPolicy = DEFAULT_GROUP_POLICY): ConnectorGroupPolicy {
85
+ return normalizeEnum(raw, ['open', 'mention', 'reply-or-mention', 'disabled'] as const, fallback)
86
+ }
87
+
88
+ export function normalizeConnectorThinkingLevel(raw: unknown, fallback: ConnectorThinkingLevel | null = null): ConnectorThinkingLevel | null {
89
+ if (raw === null || raw === undefined) return fallback
90
+ const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
91
+ if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium' || normalized === 'high') {
92
+ return normalized
93
+ }
94
+ return fallback
95
+ }
96
+
97
+ function normalizeSessionResetMode(raw: unknown, fallback: SessionResetMode): SessionResetMode {
98
+ return normalizeEnum(raw, ['idle', 'daily'] as const, fallback)
99
+ }
100
+
101
+ function normalizeTimeHHMM(raw: unknown, fallback: string | null): string | null {
102
+ if (typeof raw !== 'string') return fallback
103
+ const trimmed = raw.trim()
104
+ const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
105
+ if (!match) return fallback
106
+ const hours = Number.parseInt(match[1], 10)
107
+ const minutes = Number.parseInt(match[2], 10)
108
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return fallback
109
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return fallback
110
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
111
+ }
112
+
113
+ function normalizeTimezone(raw: unknown, fallback: string | null): string | null {
114
+ if (typeof raw !== 'string') return fallback
115
+ const trimmed = raw.trim()
116
+ return trimmed || fallback
117
+ }
118
+
119
+ function normalizeNonEmptyText(raw: unknown): string | null {
120
+ if (typeof raw !== 'string') return null
121
+ const trimmed = raw.trim()
122
+ return trimmed || null
123
+ }
124
+
125
+ function normalizeProviderOverride(raw: unknown): string | null {
126
+ const trimmed = normalizeNonEmptyText(raw)
127
+ if (!trimmed) return null
128
+ return getProvider(trimmed) ? trimmed : null
129
+ }
130
+
131
+ export function textMentionsAlias(text: string, aliases: string[]): boolean {
132
+ const normalized = text.trim()
133
+ if (!normalized) return false
134
+ for (const alias of aliases) {
135
+ const trimmed = alias.trim()
136
+ if (!trimmed) continue
137
+ const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
138
+ const pattern = new RegExp(`(^|\\s)(@?${escaped})(?=$|[\\s:,.!?])`, 'i')
139
+ if (pattern.test(normalized)) return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ export function resolveConnectorSessionPolicy(
145
+ connector: Connector,
146
+ msg: InboundMessage,
147
+ session?: Partial<Session> | null,
148
+ ): ResolvedConnectorSessionPolicy {
149
+ const fallbackScope = msg.isGroup ? DEFAULT_GROUP_SCOPE : DEFAULT_DM_SCOPE
150
+ const scope = normalizeConnectorSessionScope(
151
+ session?.connectorSessionScope ?? connector.config?.sessionScope,
152
+ fallbackScope,
153
+ )
154
+ const resetType = inferSessionResetType(session, {
155
+ isGroup: msg.isGroup,
156
+ threadId: msg.threadId || null,
157
+ })
158
+ const baseReset = resolveSessionResetPolicy({ session, resetType })
159
+ return {
160
+ scope,
161
+ replyMode: normalizeConnectorReplyMode(session?.connectorReplyMode ?? connector.config?.replyMode),
162
+ threadBinding: normalizeConnectorThreadBinding(session?.connectorThreadBinding ?? connector.config?.threadBinding),
163
+ groupPolicy: normalizeConnectorGroupPolicy(session?.connectorGroupPolicy ?? connector.config?.groupPolicy),
164
+ thinkingLevel: normalizeConnectorThinkingLevel(session?.connectorThinkLevel ?? connector.config?.thinkingLevel, null),
165
+ providerOverride: normalizeProviderOverride(connector.config?.providerOverride),
166
+ modelOverride: normalizeNonEmptyText(connector.config?.modelOverride),
167
+ resetType,
168
+ resetMode: normalizeSessionResetMode(
169
+ session?.sessionResetMode ?? connector.config?.sessionResetMode,
170
+ baseReset.mode,
171
+ ),
172
+ idleTimeoutSec: parseIntBounded(
173
+ session?.connectorIdleTimeoutSec ?? connector.config?.idleTimeoutSec,
174
+ 0,
175
+ 30 * 24 * 60 * 60,
176
+ ) ?? baseReset.idleTimeoutSec ?? DEFAULT_IDLE_TIMEOUT_SEC,
177
+ maxAgeSec: parseIntBounded(
178
+ session?.connectorMaxAgeSec ?? connector.config?.maxAgeSec,
179
+ 0,
180
+ 90 * 24 * 60 * 60,
181
+ ) ?? baseReset.maxAgeSec ?? DEFAULT_MAX_AGE_SEC,
182
+ dailyResetAt: normalizeTimeHHMM(
183
+ session?.sessionDailyResetAt ?? connector.config?.sessionDailyResetAt,
184
+ baseReset.dailyResetAt,
185
+ ),
186
+ resetTimezone: normalizeTimezone(
187
+ session?.sessionResetTimezone ?? connector.config?.sessionResetTimezone,
188
+ baseReset.timezone,
189
+ ),
190
+ inboundDebounceMs: parseIntBounded(
191
+ connector.config?.inboundDebounceMs,
192
+ 0,
193
+ 60_000,
194
+ ) ?? DEFAULT_INBOUND_DEBOUNCE_MS,
195
+ statusReactions: parseBool(connector.config?.statusReactions, true),
196
+ typingIndicators: parseBool(connector.config?.typingIndicators, true),
197
+ }
198
+ }
199
+
200
+ function normalizeKeyPart(raw: string | null | undefined, fallback = 'none'): string {
201
+ const normalized = (raw || '').trim()
202
+ return normalized || fallback
203
+ }
204
+
205
+ export function buildConnectorConversationKey(params: {
206
+ connector: Connector
207
+ msg: InboundMessage
208
+ agentId: string
209
+ policy: ResolvedConnectorSessionPolicy
210
+ }): string {
211
+ const { connector, msg, agentId, policy } = params
212
+ let scope = policy.scope
213
+ if (scope === 'thread' && !msg.threadId) {
214
+ scope = msg.isGroup ? 'channel' : 'channel-peer'
215
+ }
216
+ if (policy.threadBinding === 'strict' && msg.threadId) {
217
+ scope = 'thread'
218
+ }
219
+
220
+ const parts = [`connector:${connector.id}`, `agent:${normalizeKeyPart(agentId)}`]
221
+ switch (scope) {
222
+ case 'main':
223
+ parts.push('main')
224
+ break
225
+ case 'channel':
226
+ parts.push(`channel:${normalizeKeyPart(msg.channelId)}`)
227
+ break
228
+ case 'peer':
229
+ parts.push(`peer:${normalizeKeyPart(msg.senderId)}`)
230
+ break
231
+ case 'channel-peer':
232
+ parts.push(`channel:${normalizeKeyPart(msg.channelId)}`, `peer:${normalizeKeyPart(msg.senderId)}`)
233
+ break
234
+ case 'thread':
235
+ parts.push(
236
+ `channel:${normalizeKeyPart(msg.channelId)}`,
237
+ `thread:${normalizeKeyPart(msg.threadId || msg.replyToMessageId || msg.messageId)}`,
238
+ )
239
+ break
240
+ }
241
+ return parts.join(':')
242
+ }
243
+
244
+ export function buildInboundDedupeKey(connector: Connector, msg: InboundMessage): string {
245
+ if (msg.messageId) return `msg:${connector.id}:${normalizeKeyPart(msg.channelId)}:${normalizeKeyPart(msg.messageId)}`
246
+ const rawText = msg.text.trim().replace(/\s+/g, ' ').toLowerCase()
247
+ const textKey = rawText.slice(0, 240) || '(empty)'
248
+ return [
249
+ 'text',
250
+ connector.id,
251
+ normalizeKeyPart(msg.channelId),
252
+ normalizeKeyPart(msg.senderId),
253
+ normalizeKeyPart(msg.threadId),
254
+ normalizeKeyPart(msg.replyToMessageId),
255
+ textKey,
256
+ ].join(':')
257
+ }
258
+
259
+ export function buildInboundDebounceKey(connector: Connector, msg: InboundMessage): string {
260
+ return [
261
+ connector.id,
262
+ normalizeKeyPart(msg.channelId),
263
+ normalizeKeyPart(msg.senderId),
264
+ normalizeKeyPart(msg.threadId),
265
+ ].join(':')
266
+ }
267
+
268
+ export function mergeInboundMessages(messages: InboundMessage[]): InboundMessage {
269
+ if (!messages.length) {
270
+ throw new Error('Cannot merge zero inbound messages')
271
+ }
272
+ if (messages.length === 1) return messages[0]
273
+
274
+ const last = messages[messages.length - 1]
275
+ const sameSender = messages.every((msg) => msg.senderId === last.senderId)
276
+ const text = messages
277
+ .map((msg) => {
278
+ const content = msg.text.trim()
279
+ if (!content) return ''
280
+ return sameSender ? content : `[${msg.senderName}] ${content}`
281
+ })
282
+ .filter(Boolean)
283
+ .join('\n')
284
+ const media = messages.flatMap((msg) => msg.media || [])
285
+ const imageUrl = messages.map((msg) => msg.imageUrl).find(Boolean)
286
+
287
+ return {
288
+ ...last,
289
+ text,
290
+ media: media.length ? media : undefined,
291
+ imageUrl,
292
+ }
293
+ }
294
+
295
+ export function getConnectorSessionStaleness(
296
+ session: Partial<Session> | null | undefined,
297
+ policy: ResolvedConnectorSessionPolicy,
298
+ now = Date.now(),
299
+ ): ConnectorSessionStaleness {
300
+ const freshness = evaluateSessionFreshness({
301
+ session,
302
+ now,
303
+ policy: {
304
+ type: policy.resetType,
305
+ mode: policy.resetMode,
306
+ idleTimeoutSec: policy.idleTimeoutSec,
307
+ maxAgeSec: policy.maxAgeSec,
308
+ dailyResetAt: policy.dailyResetAt,
309
+ timezone: policy.resetTimezone,
310
+ },
311
+ })
312
+ return freshness.fresh ? { stale: false } : { stale: true, reason: freshness.reason }
313
+ }
314
+
315
+ export function resetConnectorSessionRuntime(session: Session, reason: string): number {
316
+ return resetSessionRuntime(session, reason)
317
+ }
318
+
319
+ export function shouldReplyToInboundMessage(params: {
320
+ msg: InboundMessage
321
+ session?: Partial<Session> | null
322
+ policy: ResolvedConnectorSessionPolicy
323
+ }): { replyToMessageId?: string; threadId?: string } {
324
+ const { msg, session, policy } = params
325
+ const replyToMessageId = (() => {
326
+ if (!msg.messageId) return undefined
327
+ if (policy.replyMode === 'off') return undefined
328
+ if (policy.replyMode === 'all') return msg.messageId
329
+ const priorOutbound = session?.connectorContext?.lastOutboundMessageId
330
+ return priorOutbound ? undefined : msg.messageId
331
+ })()
332
+ const threadId = policy.threadBinding !== 'off'
333
+ ? (msg.threadId || session?.connectorContext?.threadId || undefined)
334
+ : undefined
335
+ return { replyToMessageId, threadId }
336
+ }
337
+
338
+ export function isReplyToLastOutbound(msg: InboundMessage, session?: Partial<Session> | null): boolean {
339
+ if (!msg.replyToMessageId) return false
340
+ return msg.replyToMessageId === session?.connectorContext?.lastOutboundMessageId
341
+ }
342
+
343
+ export function buildConnectorDoctorWarnings(params: {
344
+ connector: Connector
345
+ msg?: InboundMessage | null
346
+ session?: Partial<Session> | null
347
+ }): string[] {
348
+ const { connector, msg, session } = params
349
+ const sampleMsg = msg || {
350
+ platform: connector.platform,
351
+ channelId: 'sample-channel',
352
+ senderId: 'sample-user',
353
+ senderName: 'Sample User',
354
+ text: 'sample',
355
+ isGroup: false,
356
+ } as InboundMessage
357
+ const policy = resolveConnectorSessionPolicy(connector, sampleMsg, session)
358
+ const warnings: string[] = []
359
+ const agents = loadAgents()
360
+ const chatrooms = loadChatrooms()
361
+
362
+ if (!connector.agentId && !connector.chatroomId) {
363
+ warnings.push('No agent or chatroom is assigned, so inbound messages cannot be handled.')
364
+ }
365
+ if (connector.agentId && connector.chatroomId) {
366
+ warnings.push('Both agentId and chatroomId are set; chatroom routing will win and the direct agent assignment is ignored.')
367
+ }
368
+ if (connector.agentId && !agents[connector.agentId]) {
369
+ warnings.push(`Assigned agent "${connector.agentId}" was not found, so direct connector routing will fail.`)
370
+ }
371
+ if (connector.chatroomId) {
372
+ const chatroom = chatrooms[connector.chatroomId]
373
+ if (!chatroom) {
374
+ warnings.push(`Assigned chatroom "${connector.chatroomId}" was not found, so inbound messages cannot be routed.`)
375
+ } else if (!Array.isArray(chatroom.agentIds) || chatroom.agentIds.length === 0) {
376
+ warnings.push(`Assigned chatroom "${chatroom.name || connector.chatroomId}" has no agents, so inbound messages will not get a response.`)
377
+ }
378
+ }
379
+ const dmPolicy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
380
+ const configuredAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
381
+ const storedAllowFrom = listStoredAllowedSenders(connector.id)
382
+ if (parseBool(connector.config?.statusReactions, true) && connector.platform === 'telegram') {
383
+ warnings.push('Status reactions are enabled, but Telegram support is partial and may no-op depending on bot permissions.')
384
+ }
385
+ if (parseBool(connector.config?.typingIndicators, true) && connector.platform === 'slack') {
386
+ warnings.push('Typing indicators are enabled, but Slack support is unavailable over the current connector transport.')
387
+ }
388
+ if (policy.scope === 'main') {
389
+ warnings.push('Session scope is "main", which can blend unrelated connector conversations into one session.')
390
+ }
391
+ if (policy.groupPolicy === 'open') {
392
+ warnings.push('Group policy is "open", so the agent may speak in group chats without being mentioned or replied to.')
393
+ }
394
+ if (policy.replyMode === 'off') {
395
+ warnings.push('Reply mode is "off", so outbound messages will not stay attached to the originating inbound message.')
396
+ }
397
+ if (policy.threadBinding === 'off') {
398
+ warnings.push('Thread binding is disabled, so threaded conversations may collapse into the parent channel session.')
399
+ }
400
+ if ((policy.idleTimeoutSec ?? 0) === 0 || (policy.maxAgeSec ?? 0) === 0) {
401
+ warnings.push('Session freshness reset is disabled, so stale connector context can accumulate indefinitely.')
402
+ }
403
+ if (policy.resetMode === 'daily' && !policy.dailyResetAt) {
404
+ warnings.push('Daily reset mode is enabled without a valid reset time, so freshness falls back to max-age or idle checks only.')
405
+ }
406
+ if (policy.resetMode === 'daily' && !policy.resetTimezone) {
407
+ warnings.push('Daily reset mode uses the server timezone. Set sessionResetTimezone explicitly when the connector follows a different local day boundary.')
408
+ }
409
+ if (policy.inboundDebounceMs === 0) {
410
+ warnings.push('Inbound debounce is disabled, so rapid message bursts can trigger duplicate or fragmented autonomous runs.')
411
+ }
412
+ if (!sampleMsg.isGroup && dmPolicy === 'open') {
413
+ warnings.push('DM policy is "open", so any direct sender can start a connector session without approval.')
414
+ }
415
+ if (dmPolicy === 'allowlist' && configuredAllowFrom.length === 0 && storedAllowFrom.length === 0) {
416
+ warnings.push('DM policy is "allowlist", but no approved sender IDs are configured or paired yet.')
417
+ }
418
+ if (dmPolicy === 'pairing' && configuredAllowFrom.length === 0 && storedAllowFrom.length === 0) {
419
+ warnings.push('DM policy is "pairing" with no approved senders, so the first pairing approval will bootstrap trust from any DM.')
420
+ }
421
+ if (connector.config?.providerOverride && !policy.providerOverride) {
422
+ warnings.push(`Provider override "${connector.config.providerOverride}" is invalid, so connector runs fall back to the agent provider.`)
423
+ }
424
+ if ((policy.providerOverride || policy.modelOverride) && connector.chatroomId) {
425
+ warnings.push('Provider/model overrides are configured, but this connector routes to a chatroom. Those overrides only apply to direct agent connector sessions.')
426
+ }
427
+ if (policy.providerOverride && policy.modelOverride) {
428
+ const provider = getProvider(policy.providerOverride)
429
+ if (provider && provider.models.length > 0 && !provider.models.includes(policy.modelOverride)) {
430
+ warnings.push(`Model override "${policy.modelOverride}" is not in the advertised model list for provider "${policy.providerOverride}".`)
431
+ }
432
+ }
433
+ if (policy.providerOverride && connector.agentId) {
434
+ const credentials = loadCredentials()
435
+ const agent = agents[connector.agentId]
436
+ if (agent) {
437
+ const provider = getProvider(policy.providerOverride)
438
+ const candidateIds = [agent.credentialId, ...(agent.fallbackCredentialIds || [])].filter(Boolean) as string[]
439
+ const hasMatchingCredential = candidateIds.some((credentialId) => credentials[credentialId]?.provider === policy.providerOverride)
440
+ if (provider?.requiresApiKey && !hasMatchingCredential) {
441
+ warnings.push(`Provider override "${policy.providerOverride}" requires matching API credentials, but the assigned agent has no primary/fallback credential for that provider.`)
442
+ }
443
+ if (provider?.requiresEndpoint && !(agent.apiEndpoint || provider.defaultEndpoint)) {
444
+ warnings.push(`Provider override "${policy.providerOverride}" requires an endpoint, but the assigned agent does not provide one.`)
445
+ }
446
+ }
447
+ }
448
+ if (!connector.credentialId && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
449
+ warnings.push('This connector does not have stored credentials, so startup depends on inline config or will fail.')
450
+ }
451
+ return warnings
452
+ }