@2en/clawly-plugins 1.16.2 → 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,20 +1,44 @@
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
- import {PUSH_COOLDOWN_S, _resetCooldowns, registerOfflinePush} from './offline-push'
3
+ import {
4
+ PUSH_COOLDOWN_S,
5
+ _resetCooldowns,
6
+ getLastAssistantPreview,
7
+ getLastAssistantText,
8
+ registerOfflinePush,
9
+ shouldSkipPushForMessage,
10
+ } from './offline-push'
4
11
 
5
12
  // ── Mocks ────────────────────────────────────────────────────────
6
13
 
7
14
  let mockOnline = false
8
15
  let mockPushSent = true
16
+ let lastPushOpts: {body: string; title?: string; data?: Record<string, unknown>} | null = null
17
+ let mockIdentity: {name?: string; emoji?: string} | null = null
9
18
 
10
19
  mock.module('./presence', () => ({
11
20
  isClientOnline: async () => mockOnline,
12
21
  }))
13
22
 
14
23
  mock.module('./notification', () => ({
15
- sendPushNotification: async () => mockPushSent,
24
+ sendPushNotification: async (opts: {
25
+ body: string
26
+ title?: string
27
+ data?: Record<string, unknown>
28
+ }) => {
29
+ lastPushOpts = opts
30
+ return mockPushSent
31
+ },
16
32
  }))
17
33
 
34
+ mock.module('./offline-push', () => {
35
+ const original = require('./offline-push')
36
+ return {
37
+ ...original,
38
+ getAgentIdentity: async () => mockIdentity,
39
+ }
40
+ })
41
+
18
42
  // ── Helpers ──────────────────────────────────────────────────────
19
43
 
20
44
  function createMockApi(): {
@@ -44,6 +68,8 @@ function createMockApi(): {
44
68
  beforeEach(() => {
45
69
  mockOnline = false
46
70
  mockPushSent = true
71
+ mockIdentity = null
72
+ lastPushOpts = null
47
73
  _resetCooldowns()
48
74
  })
49
75
 
@@ -146,4 +172,258 @@ describe('offline-push', () => {
146
172
  test('exports PUSH_COOLDOWN_S as 30', () => {
147
173
  expect(PUSH_COOLDOWN_S).toBe(30)
148
174
  })
175
+
176
+ // ── Rich title/body tests ────────────────────────────────────
177
+
178
+ test('title shows agent emoji + name when identity is available', async () => {
179
+ mockIdentity = {name: 'Luna', emoji: '🐱'}
180
+ const {api, handlers} = createMockApi()
181
+ registerOfflinePush(api)
182
+
183
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
184
+
185
+ expect(lastPushOpts?.title).toBe('🐱 Luna')
186
+ })
187
+
188
+ test('title falls back to 🦞 Clawly when identity fetch fails', async () => {
189
+ mockIdentity = null
190
+ const {api, handlers} = createMockApi()
191
+ registerOfflinePush(api)
192
+
193
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
194
+
195
+ expect(lastPushOpts?.title).toBe('🦞 Clawly')
196
+ })
197
+
198
+ test('title uses default emoji when identity has name but no emoji', async () => {
199
+ mockIdentity = {name: 'Atlas'}
200
+ const {api, handlers} = createMockApi()
201
+ registerOfflinePush(api)
202
+
203
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
204
+
205
+ expect(lastPushOpts?.title).toBe('🦞 Atlas')
206
+ })
207
+
208
+ test('body shows assistant message preview when messages are present', async () => {
209
+ const {api, handlers} = createMockApi()
210
+ registerOfflinePush(api)
211
+
212
+ await handlers.get('agent_end')!({
213
+ sessionKey: 'sess-1',
214
+ messages: [
215
+ {role: 'user', content: 'Hello'},
216
+ {role: 'assistant', content: 'Hi there! How can I help you today?'},
217
+ ],
218
+ })
219
+
220
+ expect(lastPushOpts?.body).toBe('Hi there! How can I help you today?')
221
+ })
222
+
223
+ test('body falls back when no messages', async () => {
224
+ const {api, handlers} = createMockApi()
225
+ registerOfflinePush(api)
226
+
227
+ await handlers.get('agent_end')!({sessionKey: 'sess-1'})
228
+
229
+ expect(lastPushOpts?.body).toBe('Your response is ready')
230
+ })
231
+
232
+ test('body falls back when messages has no assistant role', async () => {
233
+ const {api, handlers} = createMockApi()
234
+ registerOfflinePush(api)
235
+
236
+ await handlers.get('agent_end')!({
237
+ sessionKey: 'sess-1',
238
+ messages: [{role: 'user', content: 'Hello'}],
239
+ })
240
+
241
+ expect(lastPushOpts?.body).toBe('Your response is ready')
242
+ })
243
+ })
244
+
245
+ // ── getLastAssistantText / getLastAssistantPreview unit tests ────
246
+
247
+ describe('getLastAssistantText', () => {
248
+ test('returns null for non-array input', () => {
249
+ expect(getLastAssistantText(undefined)).toBeNull()
250
+ expect(getLastAssistantText(null)).toBeNull()
251
+ expect(getLastAssistantText('string')).toBeNull()
252
+ })
253
+
254
+ test('extracts string content from last assistant message', () => {
255
+ const messages = [
256
+ {role: 'user', content: 'Hi'},
257
+ {role: 'assistant', content: 'First reply'},
258
+ {role: 'user', content: 'Another question'},
259
+ {role: 'assistant', content: 'Second reply'},
260
+ ]
261
+ expect(getLastAssistantText(messages)).toBe('Second reply')
262
+ })
263
+
264
+ test('handles array content with text parts', () => {
265
+ const messages = [
266
+ {
267
+ role: 'assistant',
268
+ content: [
269
+ {type: 'text', text: 'Hello '},
270
+ {type: 'text', text: 'world!'},
271
+ ],
272
+ },
273
+ ]
274
+ expect(getLastAssistantText(messages)).toBe('Hello world!')
275
+ })
276
+
277
+ test('collapses newlines into spaces', () => {
278
+ const messages = [{role: 'assistant', content: 'Line one\n\nLine two\nLine 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()
295
+ })
296
+ })
297
+
298
+ describe('getLastAssistantPreview', () => {
299
+ test('truncates long messages with ellipsis', () => {
300
+ const longText = 'a'.repeat(200)
301
+ const messages = [{role: 'assistant', content: longText}]
302
+ const result = getLastAssistantPreview(messages, 140)!
303
+ expect(result.length).toBe(141) // 140 chars + "…"
304
+ expect(result.endsWith('…')).toBe(true)
305
+ })
306
+
307
+ test('returns short messages untruncated', () => {
308
+ const messages = [{role: 'assistant', content: 'Short reply'}]
309
+ expect(getLastAssistantPreview(messages, 140)).toBe('Short reply')
310
+ })
311
+
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
+ })
428
+ })
149
429
  })
@@ -2,22 +2,126 @@
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
 
15
+ import {$} from 'zx'
11
16
  import type {PluginApi} from '../index'
17
+ import {stripCliLogs} from '../lib/stripCliLogs'
12
18
  import {sendPushNotification} from './notification'
13
19
  import {isClientOnline} from './presence'
14
20
 
21
+ $.verbose = false
22
+
15
23
  /** Minimum seconds between consecutive push notifications per session. */
16
24
  export const PUSH_COOLDOWN_S = 30
17
25
 
18
26
  /** Per-session cooldown tracker. Key = sessionKey (or "__global__" for unknown). */
19
27
  const lastPushBySession = new Map<string, number>()
20
28
 
29
+ /**
30
+ * Fetch agent display identity (name, emoji) via gateway RPC.
31
+ * Returns null on failure so the caller can fall back to defaults.
32
+ */
33
+ export async function getAgentIdentity(
34
+ agentId: string | undefined,
35
+ ): Promise<{name?: string; emoji?: string} | null> {
36
+ if (!agentId) return null
37
+ try {
38
+ const rpcParams = JSON.stringify({agentId})
39
+ const result = await $`openclaw gateway call agent.identity.get --json --params ${rpcParams}`
40
+ const parsed = JSON.parse(stripCliLogs(result.stdout))
41
+ return {
42
+ name: typeof parsed.name === 'string' ? parsed.name : undefined,
43
+ emoji: typeof parsed.emoji === 'string' ? parsed.emoji : undefined,
44
+ }
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ /**
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.
54
+ */
55
+ export function getLastAssistantText(messages: unknown): string | null {
56
+ if (!Array.isArray(messages)) return null
57
+
58
+ // Walk backwards to find the last assistant message
59
+ for (let i = messages.length - 1; i >= 0; i--) {
60
+ const msg = messages[i]
61
+ if (typeof msg !== 'object' || msg === null) continue
62
+ if ((msg as any).role !== 'assistant') continue
63
+
64
+ const content = (msg as any).content
65
+ let text: string | undefined
66
+
67
+ if (typeof content === 'string') {
68
+ text = content
69
+ } else if (Array.isArray(content)) {
70
+ // Concatenate all text parts
71
+ text = content
72
+ .filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
73
+ .map((p: any) => p.text)
74
+ .join('')
75
+ }
76
+
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
+ }
82
+
83
+ return null
84
+ }
85
+
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'
121
+
122
+ return null
123
+ }
124
+
21
125
  export function registerOfflinePush(api: PluginApi) {
22
126
  api.on('agent_end', async (event: Record<string, unknown>) => {
23
127
  try {
@@ -25,6 +129,18 @@ export function registerOfflinePush(api: PluginApi) {
25
129
  const online = await isClientOnline()
26
130
  if (online) return
27
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
+
28
144
  const sessionKey = typeof event.sessionKey === 'string' ? event.sessionKey : undefined
29
145
  const cooldownKey = sessionKey ?? '__global__'
30
146
 
@@ -37,9 +153,16 @@ export function registerOfflinePush(api: PluginApi) {
37
153
  return
38
154
  }
39
155
 
156
+ const agentId = typeof event.agentId === 'string' ? event.agentId : undefined
157
+ const identity = await getAgentIdentity(agentId)
158
+ const title = `${identity?.emoji ?? '🦞'} ${identity?.name ?? 'Clawly'}`
159
+ const preview = fullText && fullText.length > 140 ? `${fullText.slice(0, 140)}…` : fullText
160
+ const body = preview ?? 'Your response is ready'
161
+
40
162
  const sent = await sendPushNotification(
41
163
  {
42
- body: 'Your response is ready',
164
+ title,
165
+ body,
43
166
  data: {
44
167
  type: 'agent_end',
45
168
  ...(sessionKey ? {sessionKey} : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {