@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.
- package/gateway/offline-push.test.ts +147 -18
- package/gateway/offline-push.ts +64 -12
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
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('
|
|
247
|
+
describe('getLastAssistantText', () => {
|
|
246
248
|
test('returns null for non-array input', () => {
|
|
247
|
-
expect(
|
|
248
|
-
expect(
|
|
249
|
-
expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
289
|
-
const messages = [
|
|
290
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
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
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -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
|
|
48
|
-
* Handles both string
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
if (!collapsed) continue
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
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 =
|
|
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(
|