@2en/clawly-plugins 1.16.3 → 1.17.0

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.
@@ -1,10 +1,12 @@
1
- import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
1
+ import {beforeEach, describe, expect, mock, test} from 'bun:test'
2
2
  import type {PluginApi} from '../index'
3
3
  import {
4
4
  PUSH_COOLDOWN_S,
5
5
  _resetCooldowns,
6
6
  getLastAssistantPreview,
7
+ getLastAssistantText,
7
8
  registerOfflinePush,
9
+ shouldSkipPushForMessage,
8
10
  } from './offline-push'
9
11
 
10
12
  // ── Mocks ────────────────────────────────────────────────────────
@@ -240,13 +242,13 @@ describe('offline-push', () => {
240
242
  })
241
243
  })
242
244
 
243
- // ── getLastAssistantPreview unit tests ──────────────────────────
245
+ // ── getLastAssistantText / getLastAssistantPreview unit tests ────
244
246
 
245
- describe('getLastAssistantPreview', () => {
247
+ describe('getLastAssistantText', () => {
246
248
  test('returns null for non-array input', () => {
247
- expect(getLastAssistantPreview(undefined)).toBeNull()
248
- expect(getLastAssistantPreview(null)).toBeNull()
249
- expect(getLastAssistantPreview('string')).toBeNull()
249
+ expect(getLastAssistantText(undefined)).toBeNull()
250
+ expect(getLastAssistantText(null)).toBeNull()
251
+ expect(getLastAssistantText('string')).toBeNull()
250
252
  })
251
253
 
252
254
  test('extracts string content from last assistant message', () => {
@@ -256,7 +258,7 @@ describe('getLastAssistantPreview', () => {
256
258
  {role: 'user', content: 'Another question'},
257
259
  {role: 'assistant', content: 'Second reply'},
258
260
  ]
259
- expect(getLastAssistantPreview(messages)).toBe('Second reply')
261
+ expect(getLastAssistantText(messages)).toBe('Second reply')
260
262
  })
261
263
 
262
264
  test('handles array content with text parts', () => {
@@ -269,14 +271,31 @@ describe('getLastAssistantPreview', () => {
269
271
  ],
270
272
  },
271
273
  ]
272
- expect(getLastAssistantPreview(messages)).toBe('Hello world!')
274
+ expect(getLastAssistantText(messages)).toBe('Hello world!')
273
275
  })
274
276
 
275
277
  test('collapses newlines into spaces', () => {
276
278
  const messages = [{role: 'assistant', content: 'Line one\n\nLine two\nLine three'}]
277
- expect(getLastAssistantPreview(messages)).toBe('Line one Line two Line three')
279
+ expect(getLastAssistantText(messages)).toBe('Line one Line two Line three')
280
+ })
281
+
282
+ test('returns empty string for last assistant with empty content', () => {
283
+ const messages = [
284
+ {role: 'assistant', content: 'Good reply'},
285
+ {role: 'assistant', content: ''},
286
+ ]
287
+ // Returns '' for the last assistant message (not fall-through to earlier)
288
+ // so the push filter can detect and skip it
289
+ expect(getLastAssistantText(messages)).toBe('')
290
+ })
291
+
292
+ test('returns null when no assistant messages exist', () => {
293
+ const messages = [{role: 'user', content: 'Hello'}]
294
+ expect(getLastAssistantText(messages)).toBeNull()
278
295
  })
296
+ })
279
297
 
298
+ describe('getLastAssistantPreview', () => {
280
299
  test('truncates long messages with ellipsis', () => {
281
300
  const longText = 'a'.repeat(200)
282
301
  const messages = [{role: 'assistant', content: longText}]
@@ -285,16 +304,126 @@ describe('getLastAssistantPreview', () => {
285
304
  expect(result.endsWith('…')).toBe(true)
286
305
  })
287
306
 
288
- test('skips assistant messages with empty content', () => {
289
- const messages = [
290
- {role: 'assistant', content: 'Good reply'},
291
- {role: 'assistant', content: ''},
292
- ]
293
- expect(getLastAssistantPreview(messages)).toBe('Good reply')
307
+ test('returns short messages untruncated', () => {
308
+ const messages = [{role: 'assistant', content: 'Short reply'}]
309
+ expect(getLastAssistantPreview(messages, 140)).toBe('Short reply')
294
310
  })
295
311
 
296
- test('returns null when no assistant messages exist', () => {
297
- const messages = [{role: 'user', content: 'Hello'}]
298
- expect(getLastAssistantPreview(messages)).toBeNull()
312
+ test('returns null when no assistant messages', () => {
313
+ expect(getLastAssistantPreview([])).toBeNull()
314
+ })
315
+ })
316
+
317
+ // ── Push-filter tests ───────────────────────────────────────────
318
+
319
+ describe('shouldSkipPushForMessage', () => {
320
+ test('skips empty assistant text', () => {
321
+ expect(shouldSkipPushForMessage('')).toBe('empty assistant')
322
+ expect(shouldSkipPushForMessage(' ')).toBe('empty assistant')
323
+ expect(shouldSkipPushForMessage('\n\n')).toBe('empty assistant')
324
+ })
325
+
326
+ test('skips NO_REPLY sentinel', () => {
327
+ expect(shouldSkipPushForMessage('NO_REPLY')).toBe('silent reply')
328
+ expect(shouldSkipPushForMessage(' NO_REPLY ')).toBe('silent reply')
329
+ })
330
+
331
+ test('skips short heartbeat ack', () => {
332
+ expect(shouldSkipPushForMessage('HEARTBEAT_OK')).toBe('heartbeat ack')
333
+ expect(shouldSkipPushForMessage('All good. HEARTBEAT_OK')).toBe('heartbeat ack')
334
+ })
335
+
336
+ test('does not skip long message containing HEARTBEAT_OK', () => {
337
+ const longMsg = 'HEARTBEAT_OK ' + 'x'.repeat(400)
338
+ expect(shouldSkipPushForMessage(longMsg)).toBeNull()
339
+ })
340
+
341
+ test('skips system prompt leak', () => {
342
+ expect(
343
+ shouldSkipPushForMessage('Here is some Conversation info (untrusted metadata) text'),
344
+ ).toBe('system prompt leak')
345
+ })
346
+
347
+ test('does not skip normal assistant messages', () => {
348
+ expect(shouldSkipPushForMessage('Hey! How can I help you?')).toBeNull()
349
+ expect(shouldSkipPushForMessage('Here is the weather report for today.')).toBeNull()
350
+ expect(shouldSkipPushForMessage('I completed the task you asked about.')).toBeNull()
351
+ })
352
+ })
353
+
354
+ describe('offline-push with filtered messages', () => {
355
+ test('skips push for NO_REPLY message', async () => {
356
+ const {api, logs, handlers} = createMockApi()
357
+ registerOfflinePush(api)
358
+
359
+ await handlers.get('agent_end')!({
360
+ sessionKey: 'sess-1',
361
+ messages: [{role: 'assistant', content: 'NO_REPLY'}],
362
+ })
363
+
364
+ expect(logs).toContainEqual({
365
+ level: 'info',
366
+ msg: expect.stringContaining('skipped (filtered: silent reply)'),
367
+ })
368
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
369
+ })
370
+
371
+ test('skips push for heartbeat ack', async () => {
372
+ const {api, logs, handlers} = createMockApi()
373
+ registerOfflinePush(api)
374
+
375
+ await handlers.get('agent_end')!({
376
+ sessionKey: 'sess-1',
377
+ messages: [{role: 'assistant', content: 'HEARTBEAT_OK'}],
378
+ })
379
+
380
+ expect(logs).toContainEqual({
381
+ level: 'info',
382
+ msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
383
+ })
384
+ expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
385
+ })
386
+
387
+ test('skips push for empty assistant text', async () => {
388
+ const {api, logs, handlers} = createMockApi()
389
+ registerOfflinePush(api)
390
+
391
+ await handlers.get('agent_end')!({
392
+ sessionKey: 'sess-1',
393
+ messages: [{role: 'assistant', content: ' '}],
394
+ })
395
+
396
+ expect(logs).toContainEqual({
397
+ level: 'info',
398
+ msg: expect.stringContaining('skipped (filtered: empty assistant)'),
399
+ })
400
+ })
401
+
402
+ test('sends push for normal message text', async () => {
403
+ const {api, logs, handlers} = createMockApi()
404
+ registerOfflinePush(api)
405
+
406
+ await handlers.get('agent_end')!({
407
+ sessionKey: 'sess-1',
408
+ messages: [{role: 'assistant', content: 'I finished the task you asked about!'}],
409
+ })
410
+
411
+ expect(logs).toContainEqual({
412
+ level: 'info',
413
+ msg: expect.stringContaining('notified (session=sess-1)'),
414
+ })
415
+ })
416
+
417
+ test('sends push when event has no messages (safe default)', async () => {
418
+ const {api, logs, handlers} = createMockApi()
419
+ registerOfflinePush(api)
420
+
421
+ // No messages field — can't determine if filtered, so send push
422
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
423
+
424
+ expect(logs).toContainEqual({
425
+ level: 'info',
426
+ msg: expect.stringContaining('notified (session=sess-1)'),
427
+ })
299
428
  })
300
429
  })
@@ -2,10 +2,14 @@
2
2
  * Offline push notification on agent_end — sends a push notification
3
3
  * when an agent run completes and the mobile client is disconnected.
4
4
  *
5
- * Hook: agent_end → check presence → send Expo push if offline
5
+ * Hook: agent_end → check presence → filter noise → send Expo push if offline
6
6
  *
7
7
  * Avoids double-push with clawly.agent.send (which pushes at dispatch
8
8
  * time for cron/external triggers) via a 30-second cooldown window.
9
+ *
10
+ * Skips push for messages the mobile UI would filter (heartbeat acks,
11
+ * NO_REPLY, compaction markers, etc.) — mirrors shouldFilterMessage()
12
+ * logic from apps/mobile/lib/messageFilter.ts.
9
13
  */
10
14
 
11
15
  import {$} from 'zx'
@@ -44,10 +48,11 @@ export async function getAgentIdentity(
44
48
  }
45
49
 
46
50
  /**
47
- * Extract the last assistant message text and truncate to `maxLen` chars.
48
- * Handles both string content and `{type:'text', text}[]` content formats.
51
+ * Extract the last assistant message's full text from a messages array.
52
+ * Handles both string and `{type:'text', text}[]` content formats.
53
+ * Returns the collapsed (newlines → spaces) but untruncated text, or null.
49
54
  */
50
- export function getLastAssistantPreview(messages: unknown, maxLen = 140): string | null {
55
+ export function getLastAssistantText(messages: unknown): string | null {
51
56
  if (!Array.isArray(messages)) return null
52
57
 
53
58
  // Walk backwards to find the last assistant message
@@ -69,15 +74,50 @@ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string
69
74
  .join('')
70
75
  }
71
76
 
72
- if (!text) continue
77
+ // Collapse newlines → spaces, trim. Return even if empty — this IS
78
+ // the last assistant message; returning '' lets the filter catch it.
79
+ const collapsed = (text ?? '').replace(/\n+/g, ' ').trim()
80
+ return collapsed
81
+ }
73
82
 
74
- // Collapse newlines → spaces, trim
75
- const collapsed = text.replace(/\n+/g, ' ').trim()
76
- if (!collapsed) continue
83
+ return null
84
+ }
77
85
 
78
- if (collapsed.length <= maxLen) return collapsed
79
- return `${collapsed.slice(0, maxLen)}…`
80
- }
86
+ /**
87
+ * Extract the last assistant message text and truncate to `maxLen` chars.
88
+ * Convenience wrapper around `getLastAssistantText`.
89
+ */
90
+ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string | null {
91
+ const text = getLastAssistantText(messages)
92
+ if (!text) return null
93
+ if (text.length <= maxLen) return text
94
+ return `${text.slice(0, maxLen)}…`
95
+ }
96
+
97
+ // ── Push-filter: skip push for messages the mobile UI would hide ──
98
+
99
+ /**
100
+ * Check whether the assistant message text is "noise" that the mobile
101
+ * UI would filter out via shouldFilterMessage(). If true, skip the push
102
+ * so the user isn't woken for nothing.
103
+ *
104
+ * Only covers assistant-role filter reasons — user-role triggers (heartbeat
105
+ * prompt, memory flush, etc.) are not directly available on the event.
106
+ */
107
+ export function shouldSkipPushForMessage(text: string): string | null {
108
+ const trimmed = text.trim()
109
+
110
+ // Agent replied with empty content — mobile hides as "emptyAssistant"
111
+ if (trimmed === '') return 'empty assistant'
112
+
113
+ // Agent sentinel "nothing to say" — mobile hides as "silentReply"
114
+ if (trimmed === 'NO_REPLY') return 'silent reply'
115
+
116
+ // Short heartbeat acknowledgment — mobile hides as "heartbeatAck"
117
+ if (text.includes('HEARTBEAT_OK') && text.length < 400) return 'heartbeat ack'
118
+
119
+ // Agent echoed system prompt metadata — mobile hides as "systemPromptLeak"
120
+ if (text.includes('Conversation info (untrusted metadata)')) return 'system prompt leak'
81
121
 
82
122
  return null
83
123
  }
@@ -89,6 +129,18 @@ export function registerOfflinePush(api: PluginApi) {
89
129
  const online = await isClientOnline()
90
130
  if (online) return
91
131
 
132
+ // Extract full assistant text for filtering and preview.
133
+ const fullText = getLastAssistantText(event.messages)
134
+
135
+ // Skip if the message would be filtered by the mobile UI.
136
+ if (fullText != null) {
137
+ const reason = shouldSkipPushForMessage(fullText)
138
+ if (reason) {
139
+ api.logger.info(`offline-push: skipped (filtered: ${reason})`)
140
+ return
141
+ }
142
+ }
143
+
92
144
  const sessionKey = typeof event.sessionKey === 'string' ? event.sessionKey : undefined
93
145
  const cooldownKey = sessionKey ?? '__global__'
94
146
 
@@ -104,7 +156,7 @@ export function registerOfflinePush(api: PluginApi) {
104
156
  const agentId = typeof event.agentId === 'string' ? event.agentId : undefined
105
157
  const identity = await getAgentIdentity(agentId)
106
158
  const title = `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
107
- const preview = getLastAssistantPreview(event.messages, 140)
159
+ const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
108
160
  const body = preview ?? 'Your response is ready'
109
161
 
110
162
  const sent = await sendPushNotification(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.16.3",
3
+ "version": "1.17.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {