@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.
- package/README.md +8 -7
- package/package.json +2 -2
- package/src/app/api/notifications/route.ts +11 -12
- package/src/app/page.tsx +9 -0
- package/src/components/chat/chat-list.tsx +10 -9
- package/src/components/home/home-view.tsx +13 -2
- package/src/components/layout/app-layout.tsx +1 -0
- package/src/components/shared/command-palette.tsx +4 -1
- package/src/components/shared/notification-center.tsx +7 -1
- package/src/components/shared/search-dialog.tsx +10 -2
- package/src/lib/local-observability.test.ts +73 -0
- package/src/lib/local-observability.ts +47 -0
- package/src/lib/notification-utils.test.ts +72 -0
- package/src/lib/notification-utils.ts +68 -0
- package/src/lib/providers/openclaw.test.ts +21 -1
- package/src/lib/providers/openclaw.ts +22 -0
- package/src/lib/runtime-loop.ts +1 -1
- package/src/lib/server/agent-thread-session.test.ts +41 -0
- package/src/lib/server/agent-thread-session.ts +1 -0
- package/src/lib/server/chat-execution-advanced.test.ts +7 -0
- package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
- package/src/lib/server/chat-execution.ts +22 -5
- package/src/lib/server/create-notification.test.ts +94 -0
- package/src/lib/server/create-notification.ts +31 -25
- package/src/lib/server/daemon-state.test.ts +50 -0
- package/src/lib/server/daemon-state.ts +121 -38
- package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
- package/src/lib/server/eval/agent-regression.test.ts +13 -1
- package/src/lib/server/eval/agent-regression.ts +221 -1
- package/src/lib/server/memory-policy.test.ts +32 -0
- package/src/lib/server/memory-policy.ts +25 -0
- package/src/lib/server/plugins-advanced.test.ts +7 -0
- package/src/lib/server/runtime-settings.test.ts +2 -2
- package/src/lib/server/session-tools/crud.test.ts +136 -0
- package/src/lib/server/session-tools/crud.ts +44 -2
- package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
- package/src/lib/server/session-tools/delegate.ts +30 -0
- package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
- package/src/lib/server/session-tools/discovery.ts +7 -6
- package/src/lib/server/session-tools/memory.ts +156 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
- package/src/lib/server/session-tools/subagent.ts +4 -4
- package/src/lib/server/storage.ts +14 -1
- package/src/lib/server/stream-agent-chat.test.ts +78 -1
- package/src/lib/server/stream-agent-chat.ts +225 -22
- package/src/lib/server/tool-aliases.ts +1 -1
- package/src/lib/server/tool-capability-policy.ts +1 -1
- package/src/stores/use-app-store.ts +26 -1
- package/src/types/index.ts +4 -0
package/src/lib/runtime-loop.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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 {
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
+
})
|