@2en/clawly-plugins 1.25.0 → 1.26.0-beta.1
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/auto-pair.ts +5 -0
- package/config-setup.ts +5 -0
- package/gateway/analytics.ts +83 -0
- package/gateway/clawhub2gateway.ts +15 -0
- package/gateway/cron-delivery.ts +13 -2
- package/gateway/cron-telemetry.test.ts +407 -0
- package/gateway/cron-telemetry.ts +253 -0
- package/gateway/index.ts +33 -0
- package/gateway/offline-push.test.ts +209 -0
- package/gateway/offline-push.ts +107 -12
- package/gateway/otel.test.ts +88 -0
- package/gateway/otel.ts +57 -0
- package/gateway/plugins.ts +3 -0
- package/gateway/posthog.test.ts +73 -0
- package/gateway/posthog.ts +61 -0
- package/gateway/telemetry-config.test.ts +58 -0
- package/gateway/telemetry-config.ts +27 -0
- package/index.ts +7 -1
- package/model-gateway-setup.ts +6 -0
- package/openclaw.plugin.json +6 -1
- package/outbound.ts +67 -32
- package/package.json +7 -1
- package/tools/clawly-send-image.ts +228 -0
- package/tools/index.ts +2 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron lifecycle telemetry — captures creation, execution, and deletion
|
|
3
|
+
* events for cron jobs via the plugin hook system.
|
|
4
|
+
*
|
|
5
|
+
* Events are emitted as OTel LogRecords via @opentelemetry/sdk-logs when
|
|
6
|
+
* plugin telemetry config contains Axiom credentials.
|
|
7
|
+
* Human-readable logs are always emitted via api.logger regardless.
|
|
8
|
+
*
|
|
9
|
+
* Hooks:
|
|
10
|
+
* - after_tool_call — captures cron add/remove tool results
|
|
11
|
+
* - agent_end (priority 100) — captures cron execution outcomes
|
|
12
|
+
* Runs after cron-delivery and offline-push so delivery/push flags are set.
|
|
13
|
+
*
|
|
14
|
+
* Cross-hook coordination:
|
|
15
|
+
* - markCronDelivered(sessionKey) — called by cron-delivery on successful inject
|
|
16
|
+
* - markCronDeliverySkipped(sessionKey, reason) — called by cron-delivery on skip
|
|
17
|
+
* - markCronPushSent(sessionKey) — called by offline-push on successful push
|
|
18
|
+
* - markCronPushSkipped(sessionKey, reason, clientOnline) — called by offline-push on skip
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {SeverityNumber} from '@opentelemetry/api-logs'
|
|
22
|
+
import type {PluginApi} from '../types'
|
|
23
|
+
import {getOtelLogger} from './otel'
|
|
24
|
+
import {captureEvent} from './posthog'
|
|
25
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
26
|
+
|
|
27
|
+
// ── Structured telemetry attributes (OTel LogRecord) ────────────
|
|
28
|
+
|
|
29
|
+
export type CronTelemetryAttributes = {
|
|
30
|
+
'cron.lifecycle': 'created' | 'executed' | 'deleted'
|
|
31
|
+
'cron.job.id': string
|
|
32
|
+
'cron.job.name'?: string
|
|
33
|
+
'cron.job.schedule'?: string
|
|
34
|
+
'cron.execution.success'?: boolean
|
|
35
|
+
'cron.execution.error'?: string
|
|
36
|
+
'cron.execution.duration_ms'?: number
|
|
37
|
+
'cron.delivery.app_message_sent'?: boolean
|
|
38
|
+
'cron.delivery.app_message_skip_reason'?: string
|
|
39
|
+
'cron.delivery.push_notification_sent'?: boolean
|
|
40
|
+
'cron.delivery.push_skip_reason'?: string
|
|
41
|
+
'instance.id'?: string
|
|
42
|
+
'agent.id'?: string
|
|
43
|
+
'client.online'?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Cross-hook coordination flags ───────────────────────────────
|
|
47
|
+
|
|
48
|
+
type CronRunFlags = {
|
|
49
|
+
delivered: boolean
|
|
50
|
+
deliverySkipReason?: string
|
|
51
|
+
pushSent: boolean
|
|
52
|
+
pushSkipReason?: string
|
|
53
|
+
clientOnline?: boolean
|
|
54
|
+
createdAt: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cronRunFlags = new Map<string, CronRunFlags>()
|
|
58
|
+
|
|
59
|
+
/** Max age for flag entries (10 minutes). Safety net against leaks. */
|
|
60
|
+
const FLAG_TTL_MS = 10 * 60 * 1000
|
|
61
|
+
|
|
62
|
+
function getOrCreate(sessionKey: string): CronRunFlags {
|
|
63
|
+
let flags = cronRunFlags.get(sessionKey)
|
|
64
|
+
if (!flags) {
|
|
65
|
+
// Evict stale entries on insertion to bound map size
|
|
66
|
+
if (cronRunFlags.size >= 50) {
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
for (const [key, val] of cronRunFlags) {
|
|
69
|
+
if (now - val.createdAt > FLAG_TTL_MS) cronRunFlags.delete(key)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
flags = {delivered: false, pushSent: false, createdAt: Date.now()}
|
|
73
|
+
cronRunFlags.set(sessionKey, flags)
|
|
74
|
+
}
|
|
75
|
+
return flags
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function markCronDelivered(sessionKey: string) {
|
|
79
|
+
getOrCreate(sessionKey).delivered = true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function markCronDeliverySkipped(sessionKey: string, reason: string) {
|
|
83
|
+
const flags = getOrCreate(sessionKey)
|
|
84
|
+
flags.delivered = false
|
|
85
|
+
flags.deliverySkipReason = reason
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function markCronPushSent(sessionKey: string) {
|
|
89
|
+
getOrCreate(sessionKey).pushSent = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function markCronPushSkipped(sessionKey: string, reason: string, clientOnline?: boolean) {
|
|
93
|
+
const flags = getOrCreate(sessionKey)
|
|
94
|
+
flags.pushSent = false
|
|
95
|
+
flags.pushSkipReason = reason
|
|
96
|
+
if (clientOnline != null) flags.clientOnline = clientOnline
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function extractJobId(sessionKey: string): string | null {
|
|
102
|
+
const prefix = 'agent:clawly:cron:'
|
|
103
|
+
if (!sessionKey.startsWith(prefix)) return null
|
|
104
|
+
return sessionKey.slice(prefix.length) || null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function emit(attributes: CronTelemetryAttributes, api: PluginApi) {
|
|
108
|
+
// Emit structured OTel LogRecord (if provider is initialized)
|
|
109
|
+
const otelLogger = getOtelLogger()
|
|
110
|
+
if (otelLogger) {
|
|
111
|
+
otelLogger.emit({
|
|
112
|
+
severityNumber: SeverityNumber.INFO,
|
|
113
|
+
severityText: 'INFO',
|
|
114
|
+
body: `cron.lifecycle.${attributes['cron.lifecycle']}`,
|
|
115
|
+
attributes: attributes as Record<string, unknown>,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Emit PostHog event
|
|
120
|
+
captureEvent(`cron.${attributes['cron.lifecycle']}`, attributes as Record<string, unknown>)
|
|
121
|
+
|
|
122
|
+
// Always log human-readable summary
|
|
123
|
+
const a = attributes
|
|
124
|
+
switch (a['cron.lifecycle']) {
|
|
125
|
+
case 'created':
|
|
126
|
+
api.logger.info(
|
|
127
|
+
`cron-telemetry: job created jobId=${a['cron.job.id']}${a['cron.job.name'] ? ` name="${a['cron.job.name']}"` : ''}${a['cron.job.schedule'] ? ` schedule="${a['cron.job.schedule']}"` : ''}`,
|
|
128
|
+
)
|
|
129
|
+
break
|
|
130
|
+
case 'deleted':
|
|
131
|
+
api.logger.info(`cron-telemetry: job deleted jobId=${a['cron.job.id']}`)
|
|
132
|
+
break
|
|
133
|
+
case 'executed':
|
|
134
|
+
api.logger.info(
|
|
135
|
+
`cron-telemetry: job executed jobId=${a['cron.job.id']} success=${a['cron.execution.success']} appMessage=${a['cron.delivery.app_message_sent']} push=${a['cron.delivery.push_notification_sent']}${a['cron.execution.duration_ms'] != null ? ` duration=${a['cron.execution.duration_ms']}ms` : ''}`,
|
|
136
|
+
)
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Registration ─────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function registerCronTelemetry(api: PluginApi) {
|
|
144
|
+
// 1. after_tool_call: capture cron add/remove
|
|
145
|
+
api.on('after_tool_call', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
146
|
+
api.logger.info(
|
|
147
|
+
`cron-telemetry[debug]: after_tool_call fired toolName=${String(event.toolName)} params.action=${String((event.params as any)?.action)} ctx=${JSON.stringify(ctx ?? {})}`,
|
|
148
|
+
)
|
|
149
|
+
if (event.toolName !== 'cron') return
|
|
150
|
+
|
|
151
|
+
const params = (event.params ?? {}) as Record<string, unknown>
|
|
152
|
+
const result = event.result as Record<string, unknown> | undefined
|
|
153
|
+
api.logger.info(
|
|
154
|
+
`cron-telemetry[debug]: cron tool detected action=${String(params.action)} result=${JSON.stringify(result ?? null)} error=${String(event.error ?? 'none')}`,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if (params.action === 'add' && result) {
|
|
158
|
+
const job = (params.job ?? params) as Record<string, unknown>
|
|
159
|
+
const jobId =
|
|
160
|
+
typeof result.id === 'string'
|
|
161
|
+
? result.id
|
|
162
|
+
: typeof result.jobId === 'string'
|
|
163
|
+
? result.jobId
|
|
164
|
+
: 'unknown'
|
|
165
|
+
const jobName = typeof job.name === 'string' ? job.name : undefined
|
|
166
|
+
const schedule = typeof job.schedule === 'string' ? job.schedule : undefined
|
|
167
|
+
|
|
168
|
+
emit(
|
|
169
|
+
{
|
|
170
|
+
'cron.lifecycle': 'created',
|
|
171
|
+
'cron.job.id': jobId,
|
|
172
|
+
...(jobName ? {'cron.job.name': jobName} : {}),
|
|
173
|
+
...(schedule ? {'cron.job.schedule': schedule} : {}),
|
|
174
|
+
},
|
|
175
|
+
api,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (params.action === 'remove') {
|
|
180
|
+
const jobId =
|
|
181
|
+
typeof params.id === 'string'
|
|
182
|
+
? params.id
|
|
183
|
+
: typeof params.jobId === 'string'
|
|
184
|
+
? params.jobId
|
|
185
|
+
: 'unknown'
|
|
186
|
+
|
|
187
|
+
emit({'cron.lifecycle': 'deleted', 'cron.job.id': jobId}, api)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Read instance.id once at registration time
|
|
192
|
+
const instanceId =
|
|
193
|
+
readTelemetryPluginConfig(api.pluginConfig).instanceId ?? process.env.INSTANCE_ID
|
|
194
|
+
|
|
195
|
+
// 2. agent_end: capture cron execution (priority 100 = runs after delivery/push hooks)
|
|
196
|
+
api.on(
|
|
197
|
+
'agent_end',
|
|
198
|
+
(event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
199
|
+
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
200
|
+
api.logger.info(
|
|
201
|
+
`cron-telemetry[debug]: agent_end fired trigger=${String(ctx?.trigger ?? 'undefined')} sessionKey=${sessionKey ?? 'undefined'} agentId=${String(ctx?.agentId ?? 'undefined')} eventKeys=${Object.keys(event).join(',')}`,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// Detect cron sessions by sessionKey prefix (consistent with cron-delivery)
|
|
205
|
+
// instead of ctx.trigger which may not be set by all OpenClaw versions.
|
|
206
|
+
if (!sessionKey?.startsWith('agent:clawly:cron:')) return
|
|
207
|
+
|
|
208
|
+
const jobId = extractJobId(sessionKey)
|
|
209
|
+
if (!jobId) {
|
|
210
|
+
api.logger.info(
|
|
211
|
+
`cron-telemetry[debug]: agent_end skipped — extractJobId returned null for sessionKey=${sessionKey}`,
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const agentId = typeof ctx.agentId === 'string' ? ctx.agentId : undefined
|
|
217
|
+
const flags: CronRunFlags = cronRunFlags.get(sessionKey) ?? {
|
|
218
|
+
delivered: false,
|
|
219
|
+
pushSent: false,
|
|
220
|
+
}
|
|
221
|
+
cronRunFlags.delete(sessionKey)
|
|
222
|
+
|
|
223
|
+
const success = event.error == null
|
|
224
|
+
const error = typeof event.error === 'string' ? event.error : undefined
|
|
225
|
+
const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
|
|
226
|
+
|
|
227
|
+
emit(
|
|
228
|
+
{
|
|
229
|
+
'cron.lifecycle': 'executed',
|
|
230
|
+
'cron.job.id': jobId,
|
|
231
|
+
'cron.execution.success': success,
|
|
232
|
+
...(error ? {'cron.execution.error': error} : {}),
|
|
233
|
+
...(durationMs != null ? {'cron.execution.duration_ms': durationMs} : {}),
|
|
234
|
+
'cron.delivery.app_message_sent': flags.delivered,
|
|
235
|
+
...(flags.deliverySkipReason
|
|
236
|
+
? {'cron.delivery.app_message_skip_reason': flags.deliverySkipReason}
|
|
237
|
+
: {}),
|
|
238
|
+
'cron.delivery.push_notification_sent': flags.pushSent,
|
|
239
|
+
...(flags.pushSkipReason ? {'cron.delivery.push_skip_reason': flags.pushSkipReason} : {}),
|
|
240
|
+
...(instanceId ? {'instance.id': instanceId} : {}),
|
|
241
|
+
...(agentId ? {'agent.id': agentId} : {}),
|
|
242
|
+
...(flags.clientOnline != null ? {'client.online': flags.clientOnline} : {}),
|
|
243
|
+
},
|
|
244
|
+
api,
|
|
245
|
+
)
|
|
246
|
+
},
|
|
247
|
+
{priority: 100},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
api.logger.info(
|
|
251
|
+
'cron-telemetry: registered after_tool_call + agent_end hooks (debug-instrumented)',
|
|
252
|
+
)
|
|
253
|
+
}
|
package/gateway/index.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type {PluginApi} from '../types'
|
|
2
2
|
import {registerAgentSend} from './agent'
|
|
3
|
+
import {registerAnalytics} from './analytics'
|
|
3
4
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
4
5
|
import {registerConfigRepair} from './config-repair'
|
|
5
6
|
import {registerCronDelivery} from './cron-delivery'
|
|
7
|
+
import {registerCronTelemetry} from './cron-telemetry'
|
|
8
|
+
import {initOtel, shutdownOtel} from './otel'
|
|
9
|
+
import {initPostHog, setOpenClawVersion, shutdownPostHog} from './posthog'
|
|
6
10
|
import {registerPairing} from './pairing'
|
|
7
11
|
import {registerMemoryBrowser} from './memory'
|
|
8
12
|
import {registerNotification} from './notification'
|
|
@@ -12,6 +16,33 @@ import {registerPresence} from './presence'
|
|
|
12
16
|
import {registerVersion} from './version'
|
|
13
17
|
|
|
14
18
|
export function registerGateway(api: PluginApi) {
|
|
19
|
+
// Initialize OTel logs provider (no-op if plugin telemetry config is missing)
|
|
20
|
+
const otelEnabled = initOtel(api.pluginConfig)
|
|
21
|
+
if (otelEnabled) {
|
|
22
|
+
api.logger.info('otel: LoggerProvider initialized (OTLP/HTTP)')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Initialize PostHog analytics (no-op if plugin telemetry config is missing)
|
|
26
|
+
const posthogEnabled = initPostHog(api.pluginConfig)
|
|
27
|
+
if (posthogEnabled) {
|
|
28
|
+
const openclawVersion = api.config?.meta?.lastTouchedVersion
|
|
29
|
+
if (openclawVersion) setOpenClawVersion(openclawVersion)
|
|
30
|
+
api.logger.info('posthog: client initialized')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (otelEnabled || posthogEnabled) {
|
|
34
|
+
api.on('gateway_stop', async () => {
|
|
35
|
+
if (otelEnabled) {
|
|
36
|
+
await shutdownOtel()
|
|
37
|
+
api.logger.info('otel: LoggerProvider shut down')
|
|
38
|
+
}
|
|
39
|
+
if (posthogEnabled) {
|
|
40
|
+
await shutdownPostHog()
|
|
41
|
+
api.logger.info('posthog: client shut down')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
registerPresence(api)
|
|
16
47
|
registerNotification(api)
|
|
17
48
|
registerAgentSend(api)
|
|
@@ -20,6 +51,8 @@ export function registerGateway(api: PluginApi) {
|
|
|
20
51
|
registerPlugins(api)
|
|
21
52
|
registerOfflinePush(api)
|
|
22
53
|
registerCronDelivery(api)
|
|
54
|
+
registerCronTelemetry(api)
|
|
55
|
+
registerAnalytics(api)
|
|
23
56
|
registerConfigRepair(api)
|
|
24
57
|
registerPairing(api)
|
|
25
58
|
registerVersion(api)
|
|
@@ -3,6 +3,8 @@ import type {PluginApi} from '../types'
|
|
|
3
3
|
import {
|
|
4
4
|
getLastAssistantPreview,
|
|
5
5
|
getLastAssistantText,
|
|
6
|
+
getTriggeringUserText,
|
|
7
|
+
isHeartbeatTriggered,
|
|
6
8
|
registerOfflinePush,
|
|
7
9
|
shouldSkipPushForMessage,
|
|
8
10
|
} from './offline-push'
|
|
@@ -515,3 +517,210 @@ describe('offline-push with filtered messages', () => {
|
|
|
515
517
|
})
|
|
516
518
|
})
|
|
517
519
|
})
|
|
520
|
+
|
|
521
|
+
// ── getTriggeringUserText unit tests ─────────────────────────────
|
|
522
|
+
|
|
523
|
+
describe('getTriggeringUserText', () => {
|
|
524
|
+
test('returns null for non-array input', () => {
|
|
525
|
+
expect(getTriggeringUserText(undefined)).toBeNull()
|
|
526
|
+
expect(getTriggeringUserText(null)).toBeNull()
|
|
527
|
+
expect(getTriggeringUserText('string')).toBeNull()
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('extracts user text preceding the last assistant message', () => {
|
|
531
|
+
const messages = [
|
|
532
|
+
{role: 'user', content: 'Hello'},
|
|
533
|
+
{role: 'assistant', content: 'Hi there!'},
|
|
534
|
+
]
|
|
535
|
+
expect(getTriggeringUserText(messages)).toBe('Hello')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test('returns the user message just before the last assistant, not earlier ones', () => {
|
|
539
|
+
const messages = [
|
|
540
|
+
{role: 'user', content: 'First question'},
|
|
541
|
+
{role: 'assistant', content: 'First reply'},
|
|
542
|
+
{role: 'user', content: 'Second question'},
|
|
543
|
+
{role: 'assistant', content: 'Second reply'},
|
|
544
|
+
]
|
|
545
|
+
expect(getTriggeringUserText(messages)).toBe('Second question')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('skips system messages between assistant and user', () => {
|
|
549
|
+
const messages = [
|
|
550
|
+
{role: 'user', content: 'Trigger text'},
|
|
551
|
+
{role: 'system', content: 'System instruction'},
|
|
552
|
+
{role: 'assistant', content: 'Response'},
|
|
553
|
+
]
|
|
554
|
+
expect(getTriggeringUserText(messages)).toBe('Trigger text')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('returns null when no user message precedes assistant', () => {
|
|
558
|
+
const messages = [{role: 'assistant', content: 'Unprompted response'}]
|
|
559
|
+
expect(getTriggeringUserText(messages)).toBeNull()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('returns null when no assistant message exists', () => {
|
|
563
|
+
const messages = [{role: 'user', content: 'Hello'}]
|
|
564
|
+
expect(getTriggeringUserText(messages)).toBeNull()
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test('handles array content format for user message', () => {
|
|
568
|
+
const messages = [
|
|
569
|
+
{
|
|
570
|
+
role: 'user',
|
|
571
|
+
content: [
|
|
572
|
+
{type: 'text', text: 'Read HEARTBEAT.md'},
|
|
573
|
+
{type: 'text', text: ' and follow instructions'},
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
577
|
+
]
|
|
578
|
+
expect(getTriggeringUserText(messages)).toBe('Read HEARTBEAT.md and follow instructions')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('returns null for empty messages array', () => {
|
|
582
|
+
expect(getTriggeringUserText([])).toBeNull()
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// ── isHeartbeatTriggered unit tests ──────────────────────────────
|
|
587
|
+
|
|
588
|
+
describe('isHeartbeatTriggered', () => {
|
|
589
|
+
test('returns true for heartbeat prompt', () => {
|
|
590
|
+
const messages = [
|
|
591
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
592
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
593
|
+
]
|
|
594
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('returns true for verbose heartbeat response', () => {
|
|
598
|
+
const messages = [
|
|
599
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
600
|
+
{
|
|
601
|
+
role: 'assistant',
|
|
602
|
+
content: 'Checked your inbox, nothing new. All clear! HEARTBEAT_OK',
|
|
603
|
+
},
|
|
604
|
+
]
|
|
605
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('returns true for heartbeat response without HEARTBEAT_OK', () => {
|
|
609
|
+
const messages = [
|
|
610
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
611
|
+
{
|
|
612
|
+
role: 'assistant',
|
|
613
|
+
content: 'Nothing to report right now. Everything looks quiet.',
|
|
614
|
+
},
|
|
615
|
+
]
|
|
616
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('returns false for normal user messages', () => {
|
|
620
|
+
const messages = [
|
|
621
|
+
{role: 'user', content: 'What is the weather today?'},
|
|
622
|
+
{role: 'assistant', content: 'Let me check...'},
|
|
623
|
+
]
|
|
624
|
+
expect(isHeartbeatTriggered(messages)).toBe(false)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('returns false for no messages', () => {
|
|
628
|
+
expect(isHeartbeatTriggered([])).toBe(false)
|
|
629
|
+
expect(isHeartbeatTriggered(undefined)).toBe(false)
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// ── Heartbeat-triggered integration tests ────────────────────────
|
|
634
|
+
|
|
635
|
+
describe('offline-push with heartbeat-triggered turns', () => {
|
|
636
|
+
test('skips push for verbose heartbeat response', async () => {
|
|
637
|
+
const {api, logs, handlers} = createMockApi()
|
|
638
|
+
registerOfflinePush(api)
|
|
639
|
+
|
|
640
|
+
await handlers.get('agent_end')!(
|
|
641
|
+
{
|
|
642
|
+
messages: [
|
|
643
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
644
|
+
{
|
|
645
|
+
role: 'assistant',
|
|
646
|
+
content: 'Checked your inbox, nothing new. All clear! HEARTBEAT_OK',
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
},
|
|
650
|
+
{sessionKey: 'agent:clawly:main'},
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
expect(logs).toContainEqual({
|
|
654
|
+
level: 'info',
|
|
655
|
+
msg: 'offline-push: skipped (heartbeat-triggered turn)',
|
|
656
|
+
})
|
|
657
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
test('skips push for heartbeat response without HEARTBEAT_OK sentinel', async () => {
|
|
661
|
+
const {api, logs, handlers} = createMockApi()
|
|
662
|
+
registerOfflinePush(api)
|
|
663
|
+
|
|
664
|
+
await handlers.get('agent_end')!(
|
|
665
|
+
{
|
|
666
|
+
messages: [
|
|
667
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
668
|
+
{
|
|
669
|
+
role: 'assistant',
|
|
670
|
+
content: 'Nothing to report. Everything looks quiet on all fronts.',
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
{sessionKey: 'agent:clawly:main'},
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
expect(logs).toContainEqual({
|
|
678
|
+
level: 'info',
|
|
679
|
+
msg: 'offline-push: skipped (heartbeat-triggered turn)',
|
|
680
|
+
})
|
|
681
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('bare HEARTBEAT_OK is caught by existing filter before heartbeat-context check', async () => {
|
|
685
|
+
const {api, logs, handlers} = createMockApi()
|
|
686
|
+
registerOfflinePush(api)
|
|
687
|
+
|
|
688
|
+
await handlers.get('agent_end')!(
|
|
689
|
+
{
|
|
690
|
+
messages: [
|
|
691
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
692
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
{sessionKey: 'agent:clawly:main'},
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
// Should be caught by shouldSkipPushForMessage (heartbeat ack), not the trigger check
|
|
699
|
+
expect(logs).toContainEqual({
|
|
700
|
+
level: 'info',
|
|
701
|
+
msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
|
|
702
|
+
})
|
|
703
|
+
expect(logs.filter((l) => l.msg.includes('heartbeat-triggered turn'))).toHaveLength(0)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
test('non-heartbeat turn sends push normally (no regression)', async () => {
|
|
707
|
+
const {api, logs, handlers} = createMockApi()
|
|
708
|
+
registerOfflinePush(api)
|
|
709
|
+
|
|
710
|
+
await handlers.get('agent_end')!(
|
|
711
|
+
{
|
|
712
|
+
messages: [
|
|
713
|
+
{role: 'user', content: 'What is the weather like today?'},
|
|
714
|
+
{role: 'assistant', content: 'It is sunny and 72°F!'},
|
|
715
|
+
],
|
|
716
|
+
},
|
|
717
|
+
{sessionKey: 'agent:clawly:main'},
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
expect(logs).toContainEqual({
|
|
721
|
+
level: 'info',
|
|
722
|
+
msg: expect.stringContaining('notified (session=agent:clawly:main)'),
|
|
723
|
+
})
|
|
724
|
+
expect(logs.filter((l) => l.msg.includes('heartbeat-triggered'))).toHaveLength(0)
|
|
725
|
+
})
|
|
726
|
+
})
|