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