@swarmclawai/swarmclaw 0.8.0 → 0.8.2

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 (49) hide show
  1. package/README.md +8 -7
  2. package/package.json +2 -2
  3. package/src/app/api/notifications/route.ts +11 -12
  4. package/src/app/page.tsx +9 -0
  5. package/src/components/chat/chat-list.tsx +10 -9
  6. package/src/components/home/home-view.tsx +13 -2
  7. package/src/components/layout/app-layout.tsx +1 -0
  8. package/src/components/shared/command-palette.tsx +4 -1
  9. package/src/components/shared/notification-center.tsx +7 -1
  10. package/src/components/shared/search-dialog.tsx +10 -2
  11. package/src/lib/local-observability.test.ts +73 -0
  12. package/src/lib/local-observability.ts +47 -0
  13. package/src/lib/notification-utils.test.ts +72 -0
  14. package/src/lib/notification-utils.ts +68 -0
  15. package/src/lib/providers/openclaw.test.ts +21 -1
  16. package/src/lib/providers/openclaw.ts +22 -0
  17. package/src/lib/runtime-loop.ts +1 -1
  18. package/src/lib/server/agent-thread-session.test.ts +41 -0
  19. package/src/lib/server/agent-thread-session.ts +1 -0
  20. package/src/lib/server/chat-execution-advanced.test.ts +7 -0
  21. package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
  22. package/src/lib/server/chat-execution.ts +22 -5
  23. package/src/lib/server/create-notification.test.ts +94 -0
  24. package/src/lib/server/create-notification.ts +31 -25
  25. package/src/lib/server/daemon-state.test.ts +50 -0
  26. package/src/lib/server/daemon-state.ts +121 -38
  27. package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
  28. package/src/lib/server/eval/agent-regression.test.ts +13 -1
  29. package/src/lib/server/eval/agent-regression.ts +221 -1
  30. package/src/lib/server/memory-policy.test.ts +32 -0
  31. package/src/lib/server/memory-policy.ts +25 -0
  32. package/src/lib/server/plugins-advanced.test.ts +7 -0
  33. package/src/lib/server/runtime-settings.test.ts +2 -2
  34. package/src/lib/server/session-tools/crud.test.ts +136 -0
  35. package/src/lib/server/session-tools/crud.ts +44 -2
  36. package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
  37. package/src/lib/server/session-tools/delegate.ts +30 -0
  38. package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
  39. package/src/lib/server/session-tools/discovery.ts +7 -6
  40. package/src/lib/server/session-tools/memory.ts +156 -6
  41. package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
  42. package/src/lib/server/session-tools/subagent.ts +4 -4
  43. package/src/lib/server/storage.ts +14 -1
  44. package/src/lib/server/stream-agent-chat.test.ts +78 -1
  45. package/src/lib/server/stream-agent-chat.ts +225 -22
  46. package/src/lib/server/tool-aliases.ts +1 -1
  47. package/src/lib/server/tool-capability-policy.ts +1 -1
  48. package/src/stores/use-app-store.ts +26 -1
  49. package/src/types/index.ts +4 -0
@@ -22,7 +22,7 @@ export const CLAUDE_CODE_TIMEOUT_SEC_MAX = 7200
22
22
  export const CLI_PROCESS_TIMEOUT_SEC_MIN = 10
23
23
  export const CLI_PROCESS_TIMEOUT_SEC_MAX = 7200
24
24
 
25
- export const DEFAULT_AGENT_LOOP_RECURSION_LIMIT = 60
25
+ export const DEFAULT_AGENT_LOOP_RECURSION_LIMIT = 120
26
26
  export const DEFAULT_ORCHESTRATOR_LOOP_RECURSION_LIMIT = 80
27
27
  export const DEFAULT_LEGACY_ORCHESTRATOR_MAX_TURNS = 16
28
28
  export const DEFAULT_ONGOING_LOOP_MAX_ITERATIONS = 250
@@ -133,4 +133,45 @@ describe('ensureAgentThreadSession', () => {
133
133
  assert.equal(output.threadSessionId, null)
134
134
  assert.equal(output.sessionCount, 0)
135
135
  })
136
+
137
+ it('propagates explicit OpenClaw gateway agent ids into the shortcut session', () => {
138
+ const output = runWithTempDataDir(`
139
+ const storageMod = await import('./src/lib/server/storage.ts')
140
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
141
+ const helperMod = await import('./src/lib/server/agent-thread-session.ts')
142
+ const ensureAgentThreadSession = helperMod.ensureAgentThreadSession
143
+ || helperMod.default?.ensureAgentThreadSession
144
+ || helperMod['module.exports']?.ensureAgentThreadSession
145
+
146
+ const now = Date.now()
147
+ storage.saveAgents({
148
+ oc: {
149
+ id: 'oc',
150
+ name: 'OpenClaw Ops',
151
+ description: 'OpenClaw-backed helper',
152
+ provider: 'openclaw',
153
+ model: 'default',
154
+ credentialId: null,
155
+ apiEndpoint: null,
156
+ gatewayProfileId: 'gateway-test',
157
+ fallbackCredentialIds: [],
158
+ openclawAgentId: 'main',
159
+ heartbeatEnabled: true,
160
+ heartbeatIntervalSec: 600,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ plugins: [],
164
+ },
165
+ })
166
+
167
+ const session = ensureAgentThreadSession('oc')
168
+ const sessions = storage.loadSessions()
169
+
170
+ console.log(JSON.stringify({
171
+ session: session ? sessions[session.id] : null,
172
+ }))
173
+ `)
174
+
175
+ assert.equal(output.session.openclawAgentId, 'main')
176
+ })
136
177
  })
@@ -18,6 +18,7 @@ function buildThreadSession(agent: Agent, sessionId: string, user: string, creat
18
18
  const baseSession: Session = {
19
19
  id: sessionId,
20
20
  name: agent.name,
21
+ openclawAgentId: agent.openclawAgentId || existing?.openclawAgentId || null,
21
22
  shortcutForAgentId: agent.id,
22
23
  cwd: existing?.cwd || WORKSPACE_DIR,
23
24
  user: existing?.user || user,
@@ -271,6 +271,13 @@ describe('requestedToolNamesFromMessage advanced', () => {
271
271
  assert.ok(result.includes('memory_tool'))
272
272
  })
273
273
 
274
+ it('extracts narrow memory tool names when explicitly requested', () => {
275
+ const result = requestedToolNamesFromMessage('Use `memory_search` first, then `memory_get`, and finish with `memory_store` if needed')
276
+ assert.ok(result.includes('memory_search'))
277
+ assert.ok(result.includes('memory_get'))
278
+ assert.ok(result.includes('memory_store'))
279
+ })
280
+
274
281
  it('extracts multiple tools from complex request', () => {
275
282
  const result = requestedToolNamesFromMessage('Use `web` to research, `browser` to screenshot, and `connector_message_tool` to send via Slack')
276
283
  assert.ok(result.includes('web'))
@@ -0,0 +1,111 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import test from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chat-eval-history-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ BROWSER_PROFILES_DIR: path.join(tempDir, 'browser-profiles'),
20
+ },
21
+ encoding: 'utf-8',
22
+ })
23
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
24
+ const lines = (result.stdout || '')
25
+ .trim()
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
30
+ return JSON.parse(jsonLine || '{}')
31
+ } finally {
32
+ fs.rmSync(tempDir, { recursive: true, force: true })
33
+ }
34
+ }
35
+
36
+ test('executeSessionChatTurn persists internal eval user turns for same-thread recall', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const storage = storageMod.default || storageMod['module.exports'] || storageMod
40
+ const providersMod = await import('./src/lib/providers/index.ts')
41
+ const execMod = await import('./src/lib/server/chat-execution.ts')
42
+ const executeSessionChatTurn = execMod.executeSessionChatTurn
43
+ || execMod.default?.executeSessionChatTurn
44
+ || execMod['module.exports']?.executeSessionChatTurn
45
+ const providers = providersMod.PROVIDERS
46
+ || providersMod.default?.PROVIDERS
47
+ || providersMod['module.exports']?.PROVIDERS
48
+
49
+ providers['test-provider'] = {
50
+ id: 'test-provider',
51
+ name: 'Test Provider',
52
+ models: ['unit'],
53
+ requiresApiKey: false,
54
+ requiresEndpoint: false,
55
+ handler: {
56
+ async streamChat({ session, message, loadHistory }) {
57
+ if (/what is project kodiak's code name\\??/i.test(message)) {
58
+ const history = loadHistory(session.id)
59
+ const remembered = history.find((entry) =>
60
+ entry?.role === 'user' && typeof entry.text === 'string' && entry.text.includes('code name Sunbird')
61
+ )
62
+ return remembered ? 'Project Kodiak\\'s code name is Sunbird.' : 'I cannot find the code name in the thread history.'
63
+ }
64
+ return 'Stored.'
65
+ },
66
+ },
67
+ }
68
+
69
+ const now = Date.now()
70
+ const sessions = storage.loadSessions()
71
+ sessions['eval-history'] = {
72
+ id: 'eval-history',
73
+ name: 'Eval History',
74
+ cwd: process.cwd(),
75
+ user: 'eval-runner',
76
+ provider: 'test-provider',
77
+ model: 'unit',
78
+ claudeSessionId: null,
79
+ messages: [],
80
+ createdAt: now,
81
+ lastActiveAt: now,
82
+ plugins: [],
83
+ }
84
+ storage.saveSessions(sessions)
85
+
86
+ await executeSessionChatTurn({
87
+ sessionId: 'eval-history',
88
+ message: 'Remember that Project Kodiak uses the code name Sunbird.',
89
+ internal: true,
90
+ source: 'eval',
91
+ })
92
+
93
+ const recall = await executeSessionChatTurn({
94
+ sessionId: 'eval-history',
95
+ message: 'What is Project Kodiak\\'s code name?',
96
+ internal: true,
97
+ source: 'eval',
98
+ })
99
+
100
+ const storedSession = storage.loadSessions()['eval-history']
101
+ console.log(JSON.stringify({
102
+ recallText: recall.text,
103
+ roles: storedSession.messages.map((entry) => entry.role),
104
+ texts: storedSession.messages.map((entry) => entry.text),
105
+ }))
106
+ `)
107
+
108
+ assert.match(String(output.recallText || ''), /Sunbird/)
109
+ assert.deepEqual(output.roles, ['user', 'assistant', 'user', 'assistant'])
110
+ assert.match(String(output.texts?.[0] || ''), /Project Kodiak uses the code name Sunbird/)
111
+ })
@@ -109,6 +109,11 @@ export function shouldApplySessionFreshnessReset(source: string): boolean {
109
109
  return source !== 'eval'
110
110
  }
111
111
 
112
+ function shouldPersistInboundUserMessage(internal: boolean, source: string): boolean {
113
+ if (!internal) return true
114
+ return source === 'eval'
115
+ }
116
+
112
117
  function extractEventJson(line: string): SSEEvent | null {
113
118
  if (!line.startsWith('data: ')) return null
114
119
  try {
@@ -456,6 +461,10 @@ export function requestedToolNamesFromMessage(message: string): string[] {
456
461
  'monitor_tool',
457
462
  'plugin_creator_tool',
458
463
  'memory_tool',
464
+ 'memory_search',
465
+ 'memory_get',
466
+ 'memory_store',
467
+ 'memory_update',
459
468
  'wallet_tool',
460
469
  'http_request',
461
470
  'send_file',
@@ -856,6 +865,11 @@ function syncSessionFromAgent(sessionId: string): void {
856
865
  session.projectId = desiredProjectId
857
866
  changed = true
858
867
  }
868
+ const desiredOpenClawAgentId = agent.openclawAgentId ?? null
869
+ if ((session.openclawAgentId ?? null) !== desiredOpenClawAgentId) {
870
+ session.openclawAgentId = desiredOpenClawAgentId
871
+ changed = true
872
+ }
859
873
  }
860
874
 
861
875
  if (changed) {
@@ -1212,8 +1226,9 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1212
1226
 
1213
1227
  const apiKey = resolveApiKeyForSession(sessionForRun, provider)
1214
1228
 
1215
- if (!internal) {
1216
- const linkAnalysis = await runLinkUnderstanding(message)
1229
+ const shouldPersistUserMessage = shouldPersistInboundUserMessage(internal, source)
1230
+ if (shouldPersistUserMessage) {
1231
+ const linkAnalysis = !internal ? await runLinkUnderstanding(message) : []
1217
1232
  const nextUserMessage: Message = {
1218
1233
  role: 'user',
1219
1234
  text: message,
@@ -1234,9 +1249,11 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1234
1249
  }
1235
1250
  session.lastActiveAt = Date.now()
1236
1251
  saveSessions(sessions)
1237
- try {
1238
- await getPluginManager().runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
1239
- } catch { /* onMessage hooks are non-critical */ }
1252
+ if (!internal) {
1253
+ try {
1254
+ await getPluginManager().runHook('onMessage', { session, message: nextUserMessage }, { enabledIds: pluginsForRun })
1255
+ } catch { /* onMessage hooks are non-critical */ }
1256
+ }
1240
1257
  }
1241
1258
 
1242
1259
  const systemPrompt = buildAgentSystemPrompt(session)
@@ -0,0 +1,94 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import type { AppNotification } from '@/types'
5
+ import { createNotification } from './create-notification'
6
+
7
+ describe('createNotification', () => {
8
+ it('coalesces repeated dedupKey events into one notification record', () => {
9
+ const store = new Map<string, AppNotification>()
10
+ const notifyTopics: string[] = []
11
+ const dispatched: string[] = []
12
+
13
+ const deps = {
14
+ now: (() => {
15
+ let current = 1_700_000_000_000
16
+ return () => {
17
+ current += 1_000
18
+ return current
19
+ }
20
+ })(),
21
+ save: (id: string, data: AppNotification) => {
22
+ store.set(id, data)
23
+ },
24
+ notifyTopic: (topic: string) => {
25
+ notifyTopics.push(topic)
26
+ },
27
+ dispatch: async (notification: AppNotification) => {
28
+ dispatched.push(notification.id)
29
+ },
30
+ findByDedupKey: (dedupKey: string) => {
31
+ for (const notification of store.values()) {
32
+ if (notification.dedupKey === dedupKey) return notification
33
+ }
34
+ return null
35
+ },
36
+ createId: (() => {
37
+ let seq = 0
38
+ return () => `notif_${++seq}`
39
+ })(),
40
+ }
41
+
42
+ const first = createNotification({
43
+ type: 'warning',
44
+ title: 'Provider unreachable',
45
+ message: 'Timeout',
46
+ dedupKey: 'provider-down:test',
47
+ }, deps)
48
+
49
+ const second = createNotification({
50
+ type: 'warning',
51
+ title: 'Provider unreachable',
52
+ message: 'Still timing out',
53
+ dedupKey: 'provider-down:test',
54
+ }, deps)
55
+
56
+ assert.equal(first.created, true)
57
+ assert.equal(second.created, false)
58
+ assert.equal(store.size, 1)
59
+ assert.equal(second.notification.id, first.notification.id)
60
+ assert.equal(second.notification.message, 'Still timing out')
61
+ assert.equal(second.notification.occurrenceCount, 2)
62
+ assert.equal(second.notification.read, false)
63
+ assert.deepEqual(notifyTopics, ['notifications', 'notifications'])
64
+ assert.deepEqual(dispatched, [first.notification.id])
65
+ })
66
+
67
+ it('can keep a notification in-app only without external dispatch', () => {
68
+ const store = new Map<string, AppNotification>()
69
+ const dispatched: string[] = []
70
+
71
+ const result = createNotification({
72
+ type: 'warning',
73
+ title: 'SwarmClaw health alert',
74
+ message: 'Connector recovered.',
75
+ dedupKey: 'health-alert:connector-recovered',
76
+ dispatchExternally: false,
77
+ }, {
78
+ now: () => 1_700_000_000_000,
79
+ save: (id: string, data: AppNotification) => {
80
+ store.set(id, data)
81
+ },
82
+ notifyTopic: () => {},
83
+ dispatch: async (notification: AppNotification) => {
84
+ dispatched.push(notification.id)
85
+ },
86
+ findByDedupKey: () => null,
87
+ createId: () => 'notif_health',
88
+ })
89
+
90
+ assert.equal(result.created, true)
91
+ assert.equal(store.get('notif_health')?.title, 'SwarmClaw health alert')
92
+ assert.deepEqual(dispatched, [])
93
+ })
94
+ })
@@ -1,13 +1,14 @@
1
1
  import { genId } from '@/lib/id'
2
- import { saveNotification, hasUnreadNotificationWithKey } from '@/lib/server/storage'
2
+ import { upsertNotificationRecord } from '@/lib/notification-utils'
3
+ import { findNotificationByDedupKey, saveNotification } from '@/lib/server/storage'
3
4
  import { notify } from '@/lib/server/ws-hub'
4
5
  import { dispatchAlert } from '@/lib/server/alert-dispatch'
5
6
  import type { AppNotification } from '@/types'
6
7
 
7
8
  /**
8
- * Create and persist a notification, then push a WS invalidation.
9
- * If `dedupKey` is provided and an unread notification with the same key
10
- * already exists, returns `null` (no insert, no WS push).
9
+ * Create or refresh a notification, then push a WS invalidation.
10
+ * Repeated events with the same `dedupKey` update one notification record
11
+ * instead of creating a new row every time.
11
12
  */
12
13
  export function createNotification(opts: {
13
14
  type: AppNotification['type']
@@ -18,27 +19,32 @@ export function createNotification(opts: {
18
19
  entityType?: string
19
20
  entityId?: string
20
21
  dedupKey?: string
21
- }): AppNotification | null {
22
- if (opts.dedupKey && hasUnreadNotificationWithKey(opts.dedupKey)) {
23
- return null
24
- }
22
+ dispatchExternally?: boolean
23
+ }, deps: {
24
+ now?: () => number
25
+ save?: (id: string, data: AppNotification) => void
26
+ notifyTopic?: (topic: string) => void
27
+ dispatch?: (notification: AppNotification) => Promise<unknown>
28
+ findByDedupKey?: (dedupKey: string) => AppNotification | null
29
+ createId?: () => string
30
+ } = {}): { notification: AppNotification; created: boolean } {
31
+ const now = deps.now?.() ?? Date.now()
32
+ const save = deps.save ?? saveNotification
33
+ const emit = deps.notifyTopic ?? notify
34
+ const sendAlert = deps.dispatch ?? dispatchAlert
35
+ const existing = opts.dedupKey
36
+ ? (deps.findByDedupKey ?? findNotificationByDedupKey)(opts.dedupKey)
37
+ : null
38
+
39
+ const { notification, created } = upsertNotificationRecord(existing, opts, {
40
+ now,
41
+ createId: deps.createId ?? (() => genId()),
42
+ })
25
43
 
26
- const id = genId()
27
- const notification: AppNotification = {
28
- id,
29
- type: opts.type,
30
- title: opts.title,
31
- message: opts.message,
32
- actionLabel: opts.actionLabel,
33
- actionUrl: opts.actionUrl,
34
- entityType: opts.entityType,
35
- entityId: opts.entityId,
36
- dedupKey: opts.dedupKey,
37
- read: false,
38
- createdAt: Date.now(),
44
+ save(notification.id, notification)
45
+ emit('notifications')
46
+ if (created && opts.dispatchExternally !== false) {
47
+ sendAlert(notification).catch(() => {})
39
48
  }
40
- saveNotification(id, notification)
41
- notify('notifications')
42
- dispatchAlert(notification).catch(() => {})
43
- return notification
49
+ return { notification, created }
44
50
  }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ buildSessionHeartbeatHealthDedupKey,
6
+ shouldSuppressSyntheticAgentHealthAlert,
7
+ shouldSuppressSessionHeartbeatHealthAlert,
8
+ } from './daemon-state'
9
+
10
+ describe('daemon heartbeat health alerts', () => {
11
+ it('suppresses synthetic workbench and benchmark sessions', () => {
12
+ assert.equal(shouldSuppressSessionHeartbeatHealthAlert({
13
+ id: 'wb-123',
14
+ name: 'Workbench wb-123',
15
+ user: 'workbench',
16
+ shortcutForAgentId: null,
17
+ }), true)
18
+
19
+ assert.equal(shouldSuppressSessionHeartbeatHealthAlert({
20
+ id: 'agent-chat-cmp-1',
21
+ name: 'Assistant Benchmark seo_content',
22
+ user: 'default',
23
+ shortcutForAgentId: 'cmp-sc-2026-03-08t19-15-21-415z-seo_content-agent',
24
+ }), true)
25
+
26
+ assert.equal(shouldSuppressSessionHeartbeatHealthAlert({
27
+ id: 'agent-chat-real-1',
28
+ name: 'Molly',
29
+ user: 'default',
30
+ shortcutForAgentId: 'agent-real-1',
31
+ }), false)
32
+ })
33
+
34
+ it('builds stable per-session heartbeat dedup keys', () => {
35
+ assert.equal(
36
+ buildSessionHeartbeatHealthDedupKey('session-123', 'stale'),
37
+ 'health-alert:session-heartbeat:stale:session-123',
38
+ )
39
+ assert.equal(
40
+ buildSessionHeartbeatHealthDedupKey('session-123', 'auto-disabled'),
41
+ 'health-alert:session-heartbeat:auto-disabled:session-123',
42
+ )
43
+ })
44
+
45
+ it('suppresses synthetic benchmark agent health alerts', () => {
46
+ assert.equal(shouldSuppressSyntheticAgentHealthAlert('wb-wb-20260308190158-blog-outline'), true)
47
+ assert.equal(shouldSuppressSyntheticAgentHealthAlert('cmp-oc-2026-03-08t19-15-21-755z-agent'), true)
48
+ assert.equal(shouldSuppressSyntheticAgentHealthAlert('agent-real-123'), false)
49
+ })
50
+ })