@2en/clawly-plugins 1.29.0-beta.0 → 1.29.0-beta.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/gateway/audit.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import {$} from 'zx'
10
- import {stripCliLogsFull} from '../lib/stripCliLogs'
10
+ import {stripCliLogs} from '../lib/stripCliLogs'
11
11
  import type {PluginApi} from '../types'
12
12
 
13
13
  const TIMEOUT_MS = 30_000
@@ -27,7 +27,7 @@ export function registerAudit(api: PluginApi) {
27
27
  })
28
28
  return
29
29
  }
30
- const json = stripCliLogsFull(result.stdout)
30
+ const json = stripCliLogs(result.stdout)
31
31
  respond(true, JSON.parse(json))
32
32
  } catch (err) {
33
33
  respond(false, undefined, {message: err instanceof Error ? err.message : 'Unknown error'})
@@ -0,0 +1,385 @@
1
+ import {beforeEach, describe, expect, mock, test} from 'bun:test'
2
+ import type {PluginApi} from '../types'
3
+
4
+ // ── Mocks ────────────────────────────────────────────────────────
5
+
6
+ let mockResolvedKey = 'agent:clawly:main'
7
+ let mockInjectResult = {ok: true, messageId: 'msg-001'}
8
+ let mockResolveError: Error | null = null
9
+ let mockInjectError: Error | null = null
10
+ let resolveSessionKeyCalls: string[] = []
11
+ let injectCalls: {sessionKey: string; message: string}[] = []
12
+
13
+ mock.module('./inject', () => ({
14
+ resolveSessionKey: async (agentId: string) => {
15
+ resolveSessionKeyCalls.push(agentId)
16
+ if (mockResolveError) throw mockResolveError
17
+ return mockResolvedKey
18
+ },
19
+ injectAssistantMessage: async (params: {sessionKey: string; message: string}) => {
20
+ injectCalls.push(params)
21
+ if (mockInjectError) throw mockInjectError
22
+ return mockInjectResult
23
+ },
24
+ }))
25
+
26
+ // Do NOT mock cron-telemetry — it uses module-internal state (Maps) that
27
+ // cron-telemetry.test.ts depends on, and Bun's mock.module contaminates
28
+ // across test files in the same process. Assert on logs + inject calls instead.
29
+
30
+ // autoUpdateJobId is a `let` export — ESM namespace objects are frozen,
31
+ // so we can't mutate it between tests. We set it to 'test-auto-update-id'
32
+ // and test the job-ID matching path using a sessionKey that embeds this ID.
33
+ const MOCK_AUTO_UPDATE_ID = 'test-auto-update-id'
34
+
35
+ mock.module('../internal/hooks/auto-update', () => ({
36
+ autoUpdateJobId: MOCK_AUTO_UPDATE_ID,
37
+ JOB_NAME: 'Clawly Plugins Auto-Update',
38
+ }))
39
+
40
+ // Must import after mocks
41
+ const {registerCronDelivery} = await import('./cron-delivery')
42
+
43
+ // ── Helpers ──────────────────────────────────────────────────────
44
+
45
+ type Handler = (event: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<void>
46
+
47
+ function createMockApi(): {
48
+ api: PluginApi
49
+ logs: {level: string; msg: string}[]
50
+ handlers: Map<string, Handler>
51
+ } {
52
+ const logs: {level: string; msg: string}[] = []
53
+ const handlers = new Map<string, Handler>()
54
+ const api = {
55
+ id: 'test',
56
+ name: 'test',
57
+ logger: {
58
+ info: (msg: string) => logs.push({level: 'info', msg}),
59
+ warn: (msg: string) => logs.push({level: 'warn', msg}),
60
+ error: (msg: string) => logs.push({level: 'error', msg}),
61
+ },
62
+ on: (hookName: string, handler: (...args: any[]) => any) => {
63
+ handlers.set(hookName, handler as any)
64
+ },
65
+ } as unknown as PluginApi
66
+ return {api, logs, handlers}
67
+ }
68
+
69
+ function makeMessages(assistantContent: string | object[]) {
70
+ return [
71
+ {role: 'user', content: 'trigger message'},
72
+ {role: 'assistant', content: assistantContent},
73
+ ]
74
+ }
75
+
76
+ // ── Tests ────────────────────────────────────────────────────────
77
+
78
+ beforeEach(() => {
79
+ mockResolvedKey = 'agent:clawly:main'
80
+ mockInjectResult = {ok: true, messageId: 'msg-001'}
81
+ mockResolveError = null
82
+ mockInjectError = null
83
+ resolveSessionKeyCalls = []
84
+ injectCalls = []
85
+ })
86
+
87
+ describe('cron-delivery', () => {
88
+ describe('session filtering', () => {
89
+ test('skips non-cron sessions (main)', async () => {
90
+ const {api, handlers} = createMockApi()
91
+ registerCronDelivery(api)
92
+
93
+ await handlers.get('agent_end')!(
94
+ {messages: makeMessages('Hello!')},
95
+ {sessionKey: 'agent:clawly:main', agentId: 'clawly'},
96
+ )
97
+
98
+ expect(injectCalls).toHaveLength(0)
99
+ expect(resolveSessionKeyCalls).toHaveLength(0)
100
+ })
101
+
102
+ test('skips non-cron sessions (telegram)', async () => {
103
+ const {api, handlers} = createMockApi()
104
+ registerCronDelivery(api)
105
+
106
+ await handlers.get('agent_end')!(
107
+ {messages: makeMessages('Hello!')},
108
+ {sessionKey: 'agent:clawly:telegram:12345', agentId: 'clawly'},
109
+ )
110
+
111
+ expect(injectCalls).toHaveLength(0)
112
+ })
113
+
114
+ test('skips when sessionKey is missing', async () => {
115
+ const {api, handlers} = createMockApi()
116
+ registerCronDelivery(api)
117
+
118
+ await handlers.get('agent_end')!({messages: makeMessages('Hello!')}, {})
119
+
120
+ expect(injectCalls).toHaveLength(0)
121
+ })
122
+ })
123
+
124
+ describe('assistant text extraction', () => {
125
+ test('skips when no assistant message in event', async () => {
126
+ const {api, logs, handlers} = createMockApi()
127
+ registerCronDelivery(api)
128
+
129
+ await handlers.get('agent_end')!(
130
+ {messages: [{role: 'user', content: 'Hello'}]},
131
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
132
+ )
133
+
134
+ expect(injectCalls).toHaveLength(0)
135
+ expect(logs).toContainEqual({
136
+ level: 'info',
137
+ msg: 'cron-delivery: skipped (no assistant message)',
138
+ })
139
+ })
140
+
141
+ test('skips when messages is undefined', async () => {
142
+ const {api, logs, handlers} = createMockApi()
143
+ registerCronDelivery(api)
144
+
145
+ await handlers.get('agent_end')!(
146
+ {},
147
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
148
+ )
149
+
150
+ expect(injectCalls).toHaveLength(0)
151
+ expect(logs).toContainEqual({
152
+ level: 'info',
153
+ msg: 'cron-delivery: skipped (no assistant message)',
154
+ })
155
+ })
156
+
157
+ test('extracts string content', async () => {
158
+ const {api, handlers} = createMockApi()
159
+ registerCronDelivery(api)
160
+
161
+ await handlers.get('agent_end')!(
162
+ {messages: makeMessages('Weather is sunny today!')},
163
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
164
+ )
165
+
166
+ expect(injectCalls).toHaveLength(1)
167
+ expect(injectCalls[0].message).toBe('Weather is sunny today!')
168
+ })
169
+
170
+ test('extracts array content (multi-part text)', async () => {
171
+ const {api, handlers} = createMockApi()
172
+ registerCronDelivery(api)
173
+
174
+ await handlers.get('agent_end')!(
175
+ {
176
+ messages: makeMessages([
177
+ {type: 'text', text: 'Part 1 '},
178
+ {type: 'text', text: 'Part 2'},
179
+ ]),
180
+ },
181
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
182
+ )
183
+
184
+ expect(injectCalls).toHaveLength(1)
185
+ expect(injectCalls[0].message).toBe('Part 1 Part 2')
186
+ })
187
+
188
+ test('preserves raw formatting (newlines)', async () => {
189
+ const {api, handlers} = createMockApi()
190
+ registerCronDelivery(api)
191
+
192
+ await handlers.get('agent_end')!(
193
+ {messages: makeMessages('Line one\n\nLine two')},
194
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
195
+ )
196
+
197
+ expect(injectCalls).toHaveLength(1)
198
+ // cron-delivery uses getRawLastAssistantText which preserves newlines
199
+ expect(injectCalls[0].message).toBe('Line one\n\nLine two')
200
+ })
201
+ })
202
+
203
+ describe('auto-update filtering', () => {
204
+ test('skips by job ID match in sessionKey', async () => {
205
+ const {api, logs, handlers} = createMockApi()
206
+ registerCronDelivery(api)
207
+
208
+ // sessionKey embeds MOCK_AUTO_UPDATE_ID — matches the primary job-ID check
209
+ await handlers.get('agent_end')!(
210
+ {messages: makeMessages('Update complete')},
211
+ {sessionKey: `agent:clawly:cron:${MOCK_AUTO_UPDATE_ID}`, agentId: 'clawly'},
212
+ )
213
+
214
+ expect(injectCalls).toHaveLength(0)
215
+ expect(logs).toContainEqual({level: 'info', msg: 'cron-delivery: skipped (auto-update job)'})
216
+ })
217
+
218
+ test('skips by JOB_NAME text match (fallback)', async () => {
219
+ const {api, logs, handlers} = createMockApi()
220
+ registerCronDelivery(api)
221
+
222
+ // sessionKey does NOT match the job ID — falls through to text-based detection
223
+ await handlers.get('agent_end')!(
224
+ {messages: makeMessages('Clawly Plugins Auto-Update completed successfully')},
225
+ {sessionKey: 'agent:clawly:cron:some-other-id', agentId: 'clawly'},
226
+ )
227
+
228
+ expect(injectCalls).toHaveLength(0)
229
+ expect(logs).toContainEqual({level: 'info', msg: 'cron-delivery: skipped (auto-update job)'})
230
+ })
231
+ })
232
+
233
+ describe('noise filtering', () => {
234
+ test('skips NO_REPLY', async () => {
235
+ const {api, logs, handlers} = createMockApi()
236
+ registerCronDelivery(api)
237
+
238
+ await handlers.get('agent_end')!(
239
+ {messages: makeMessages('NO_REPLY')},
240
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
241
+ )
242
+
243
+ expect(injectCalls).toHaveLength(0)
244
+ expect(logs).toContainEqual({
245
+ level: 'info',
246
+ msg: expect.stringContaining('skipped (filtered: silent reply)'),
247
+ })
248
+ })
249
+
250
+ test('skips HEARTBEAT_OK (bare)', async () => {
251
+ const {api, logs, handlers} = createMockApi()
252
+ registerCronDelivery(api)
253
+
254
+ await handlers.get('agent_end')!(
255
+ {messages: makeMessages('HEARTBEAT_OK')},
256
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
257
+ )
258
+
259
+ expect(injectCalls).toHaveLength(0)
260
+ expect(logs).toContainEqual({
261
+ level: 'info',
262
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
263
+ })
264
+ })
265
+
266
+ test('skips empty text', async () => {
267
+ const {api, logs, handlers} = createMockApi()
268
+ registerCronDelivery(api)
269
+
270
+ await handlers.get('agent_end')!(
271
+ {messages: makeMessages(' ')},
272
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
273
+ )
274
+
275
+ expect(injectCalls).toHaveLength(0)
276
+ expect(logs).toContainEqual({
277
+ level: 'info',
278
+ msg: expect.stringContaining('skipped (filtered: empty assistant)'),
279
+ })
280
+ })
281
+
282
+ test('does not skip meaningful content ending with HEARTBEAT_OK', async () => {
283
+ const {api, handlers} = createMockApi()
284
+ registerCronDelivery(api)
285
+
286
+ await handlers.get('agent_end')!(
287
+ {messages: makeMessages('Weather is good today. HEARTBEAT_OK')},
288
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
289
+ )
290
+
291
+ expect(injectCalls).toHaveLength(1)
292
+ })
293
+ })
294
+
295
+ describe('agentId validation', () => {
296
+ test('skips when agentId is missing', async () => {
297
+ const {api, logs, handlers} = createMockApi()
298
+ registerCronDelivery(api)
299
+
300
+ await handlers.get('agent_end')!(
301
+ {messages: makeMessages('Weather report')},
302
+ {sessionKey: 'agent:clawly:cron:weather-check'},
303
+ )
304
+
305
+ expect(injectCalls).toHaveLength(0)
306
+ expect(resolveSessionKeyCalls).toHaveLength(0)
307
+ expect(logs).toContainEqual({
308
+ level: 'error',
309
+ msg: 'cron-delivery: skipped (no agentId on ctx)',
310
+ })
311
+ })
312
+ })
313
+
314
+ describe('happy path', () => {
315
+ test('resolves session key and injects message', async () => {
316
+ const {api, logs, handlers} = createMockApi()
317
+ registerCronDelivery(api)
318
+
319
+ await handlers.get('agent_end')!(
320
+ {messages: makeMessages('Weather is sunny today!')},
321
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
322
+ )
323
+
324
+ expect(resolveSessionKeyCalls).toEqual(['clawly'])
325
+ expect(injectCalls).toEqual([
326
+ {sessionKey: 'agent:clawly:main', message: 'Weather is sunny today!'},
327
+ ])
328
+ expect(logs).toContainEqual({
329
+ level: 'info',
330
+ msg: 'cron-delivery: injected into agent:clawly:main (messageId=msg-001)',
331
+ })
332
+ })
333
+
334
+ test('uses resolved session key (not hardcoded)', async () => {
335
+ mockResolvedKey = 'agent:luna:main'
336
+ const {api, handlers} = createMockApi()
337
+ registerCronDelivery(api)
338
+
339
+ await handlers.get('agent_end')!(
340
+ {messages: makeMessages('Report ready')},
341
+ {sessionKey: 'agent:clawly:cron:daily-report', agentId: 'luna'},
342
+ )
343
+
344
+ expect(resolveSessionKeyCalls).toEqual(['luna'])
345
+ expect(injectCalls[0].sessionKey).toBe('agent:luna:main')
346
+ })
347
+ })
348
+
349
+ describe('error handling', () => {
350
+ test('handles resolveSessionKey failure gracefully', async () => {
351
+ mockResolveError = new Error('sessions.resolve failed: JSON parse error')
352
+ const {api, logs, handlers} = createMockApi()
353
+ registerCronDelivery(api)
354
+
355
+ await handlers.get('agent_end')!(
356
+ {messages: makeMessages('Weather report')},
357
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
358
+ )
359
+
360
+ expect(injectCalls).toHaveLength(0)
361
+ expect(logs).toContainEqual({
362
+ level: 'error',
363
+ msg: 'cron-delivery: sessions.resolve failed: JSON parse error',
364
+ })
365
+ })
366
+
367
+ test('handles injectAssistantMessage failure gracefully', async () => {
368
+ mockInjectError = new Error('chat.inject failed: timeout')
369
+ const {api, logs, handlers} = createMockApi()
370
+ registerCronDelivery(api)
371
+
372
+ await handlers.get('agent_end')!(
373
+ {messages: makeMessages('Weather report')},
374
+ {sessionKey: 'agent:clawly:cron:weather-check', agentId: 'clawly'},
375
+ )
376
+
377
+ // injectAssistantMessage was called (recorded) but threw
378
+ expect(injectCalls).toHaveLength(1)
379
+ expect(logs).toContainEqual({
380
+ level: 'error',
381
+ msg: 'cron-delivery: chat.inject failed: timeout',
382
+ })
383
+ })
384
+ })
385
+ })
@@ -52,12 +52,13 @@ export function registerCronDelivery(api: PluginApi) {
52
52
  api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
53
53
  const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
54
54
  const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
55
- api.logger.info(
56
- `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
57
- )
58
55
  // Only fire for cron sessions
59
56
  if (!sessionKey?.startsWith('agent:clawly:cron:')) return
60
57
 
58
+ api.logger.info(
59
+ `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
60
+ )
61
+
61
62
  try {
62
63
  // Extract raw assistant text (preserving formatting)
63
64
  const text = getRawLastAssistantText(event.messages)
@@ -78,7 +79,10 @@ export function registerCronDelivery(api: PluginApi) {
78
79
  return
79
80
  }
80
81
 
81
- // Filter noise — reuse the same logic as offline-push
82
+ // Filter noise — reuse the same logic as offline-push.
83
+ // Note: shouldSkipPushForMessage was designed for newline-collapsed text
84
+ // (via getLastAssistantText), but works correctly here with raw text
85
+ // because all its regexes use \s (matches \n) and $ without /m flag.
82
86
  const reason = shouldSkipPushForMessage(text)
83
87
  if (reason) {
84
88
  api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)