@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.
- package/gateway/offline-push.test.ts +283 -3
- package/gateway/offline-push.ts +125 -2
- package/package.json +1 -1
|
@@ -1,20 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
2
|
import type {PluginApi} from '../index'
|
|
3
|
-
import {
|
|
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 (
|
|
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
|
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -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
|
-
|
|
164
|
+
title,
|
|
165
|
+
body,
|
|
43
166
|
data: {
|
|
44
167
|
type: 'agent_end',
|
|
45
168
|
...(sessionKey ? {sessionKey} : {}),
|