@2en/clawly-plugins 1.24.7-beta.0 → 1.24.7-beta.2
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/cron-hook.ts +8 -21
- package/gateway/config-repair.ts +8 -16
- package/gateway/cron-delivery.ts +98 -0
- package/gateway/index.ts +2 -0
- package/gateway/offline-push.test.ts +4 -1
- package/gateway/offline-push.ts +4 -1
- package/gateway/presence.test.ts +2 -2
- package/gateway/presence.ts +4 -2
- package/index.ts +1 -1
- package/model-gateway-setup.ts +5 -16
- package/package.json +1 -1
- package/tools/clawly-send-message.test.ts +247 -0
- package/tools/clawly-send-message.ts +24 -1
package/cron-hook.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* before_tool_call hook for cron (action=add) — ensures delivery fields are
|
|
3
3
|
* always set correctly, even when the LLM omits them.
|
|
4
4
|
*
|
|
5
|
-
* Forces: delivery.mode = "none" (
|
|
6
|
-
*
|
|
5
|
+
* Forces: delivery.mode = "none" (delivery is handled by the cron-delivery
|
|
6
|
+
* agent_end hook which injects results into the main session via chat.inject)
|
|
7
7
|
* Patches: payload.kind "systemEvent" → "agentTurn"
|
|
8
8
|
*
|
|
9
9
|
* The cron tool name is "cron" (not "cron.create"). The LLM passes
|
|
@@ -19,27 +19,14 @@ function isRecord(v: unknown): v is UnknownRecord {
|
|
|
19
19
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const DELIVERY_SUFFIX = [
|
|
23
|
-
'',
|
|
24
|
-
'---',
|
|
25
|
-
'DELIVERY INSTRUCTIONS (mandatory):',
|
|
26
|
-
'When done, you MUST deliver your result to the user:',
|
|
27
|
-
'1. Call the clawly_send_message tool with role="assistant" and a brief, natural summary of your result.',
|
|
28
|
-
].join('\n')
|
|
29
|
-
|
|
30
22
|
function patchJob(job: UnknownRecord): UnknownRecord {
|
|
31
23
|
const patched: UnknownRecord = {...job}
|
|
32
24
|
|
|
33
|
-
// Force delivery.mode = "none" —
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
patched.payload = {
|
|
39
|
-
...job.payload,
|
|
40
|
-
message: job.payload.message + DELIVERY_SUFFIX,
|
|
41
|
-
}
|
|
42
|
-
}
|
|
25
|
+
// Force delivery.mode = "none" — delivery is handled by the cron-delivery
|
|
26
|
+
// agent_end hook (injects result into main session via chat.inject)
|
|
27
|
+
// Preserve other delivery fields (e.g. channel) if the agent set them
|
|
28
|
+
const existing = isRecord(job.delivery) ? job.delivery : {}
|
|
29
|
+
patched.delivery = {...existing, mode: 'none'}
|
|
43
30
|
|
|
44
31
|
// Patch payload.kind: systemEvent → agentTurn
|
|
45
32
|
if (isRecord(patched.payload) && (patched.payload as UnknownRecord).kind === 'systemEvent') {
|
|
@@ -73,5 +60,5 @@ export function registerCronHook(api: PluginApi) {
|
|
|
73
60
|
return {params}
|
|
74
61
|
})
|
|
75
62
|
|
|
76
|
-
api.logger.info('hook: registered before_tool_call for cron add
|
|
63
|
+
api.logger.info('hook: registered before_tool_call for cron add (none + agentTurn enforcement)')
|
|
77
64
|
}
|
package/gateway/config-repair.ts
CHANGED
|
@@ -91,28 +91,23 @@ export function registerConfigRepair(api: PluginApi) {
|
|
|
91
91
|
return
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// Repair: derive models from agents.defaults (same logic as model-gateway-setup)
|
|
94
|
+
// Repair: derive models from agents.defaults (same logic as model-gateway-setup).
|
|
95
|
+
// Image fallback is handled server-side by the model-gateway proxy, so only
|
|
96
|
+
// the default chat model is registered (with image support).
|
|
95
97
|
const defaultModelFull: string = (config.agents as any)?.defaults?.model?.primary ?? ''
|
|
96
|
-
const imageModelFull: string = (config.agents as any)?.defaults?.imageModel?.primary ?? ''
|
|
97
98
|
|
|
98
99
|
const prefix = `${PROVIDER_NAME}/`
|
|
99
100
|
const defaultModel = defaultModelFull.startsWith(prefix)
|
|
100
101
|
? defaultModelFull.slice(prefix.length)
|
|
101
102
|
: defaultModelFull
|
|
102
|
-
const imageModel = imageModelFull.startsWith(prefix)
|
|
103
|
-
? imageModelFull.slice(prefix.length)
|
|
104
|
-
: imageModelFull
|
|
105
103
|
|
|
106
|
-
const
|
|
104
|
+
const defaultIds = new Set([defaultModel])
|
|
105
|
+
const extraModels = EXTRA_GATEWAY_MODELS.filter((m) => !defaultIds.has(m.id)).map(
|
|
106
|
+
({id, name, input}) => ({id, name, input}),
|
|
107
|
+
)
|
|
107
108
|
const models = !defaultModel
|
|
108
109
|
? (provider?.models ?? [])
|
|
109
|
-
: defaultModel
|
|
110
|
-
? [{id: defaultModel, name: defaultModel, input: ['text', 'image']}, ...extraModels]
|
|
111
|
-
: [
|
|
112
|
-
{id: defaultModel, name: defaultModel, input: ['text']},
|
|
113
|
-
{id: imageModel, name: imageModel, input: ['text', 'image']},
|
|
114
|
-
...extraModels,
|
|
115
|
-
]
|
|
110
|
+
: [{id: defaultModel, name: defaultModel, input: ['text', 'image']}, ...extraModels]
|
|
116
111
|
|
|
117
112
|
if (!config.models) config.models = {}
|
|
118
113
|
if (!(config.models as any).providers) (config.models as any).providers = {}
|
|
@@ -130,9 +125,6 @@ export function registerConfigRepair(api: PluginApi) {
|
|
|
130
125
|
const defaults = agents.defaults ?? {}
|
|
131
126
|
const modelsMap: Record<string, {alias: string}> = {
|
|
132
127
|
[`${PROVIDER_NAME}/${defaultModel}`]: {alias: defaultModel},
|
|
133
|
-
...(imageModel && imageModel !== defaultModel
|
|
134
|
-
? {[`${PROVIDER_NAME}/${imageModel}`]: {alias: imageModel}}
|
|
135
|
-
: {}),
|
|
136
128
|
}
|
|
137
129
|
for (const m of EXTRA_GATEWAY_MODELS) {
|
|
138
130
|
modelsMap[`${PROVIDER_NAME}/${m.id}`] = {alias: m.alias}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron delivery via agent_end hook — injects cron session results into the
|
|
3
|
+
* main chat session programmatically via `chat.inject`.
|
|
4
|
+
*
|
|
5
|
+
* When a cron job completes in an isolated session, this hook:
|
|
6
|
+
* 1. Detects cron sessions via `ctx.sessionKey.startsWith('agent:clawly:cron:')`
|
|
7
|
+
* 2. Extracts the last assistant message (raw, preserving formatting)
|
|
8
|
+
* 3. Filters noise (empty, NO_REPLY, heartbeat-only, system message leak)
|
|
9
|
+
* 4. Resolves the main session key via `sessions.resolve`
|
|
10
|
+
* 5. Injects the result into the main session via `chat.inject`
|
|
11
|
+
*
|
|
12
|
+
* This runs independently of offline-push — both fire on agent_end but serve
|
|
13
|
+
* different purposes. `chat.inject` does NOT trigger a new agent_end (it's a
|
|
14
|
+
* transcript injection, not an agent turn), so no infinite loop risk.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {PluginApi} from '../types'
|
|
18
|
+
import {injectAssistantMessage, resolveSessionKey} from './inject'
|
|
19
|
+
import {shouldSkipPushForMessage} from './offline-push'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract the last assistant message's full text from a messages array.
|
|
23
|
+
* Preserves original formatting (newlines, whitespace) — unlike the
|
|
24
|
+
* collapsed version in offline-push used for notification previews.
|
|
25
|
+
*/
|
|
26
|
+
function getRawLastAssistantText(messages: unknown): string | null {
|
|
27
|
+
if (!Array.isArray(messages)) return null
|
|
28
|
+
|
|
29
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
30
|
+
const msg = messages[i]
|
|
31
|
+
if (typeof msg !== 'object' || msg === null) continue
|
|
32
|
+
if ((msg as any).role !== 'assistant') continue
|
|
33
|
+
|
|
34
|
+
const content = (msg as any).content
|
|
35
|
+
if (typeof content === 'string') return content.trim()
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
const text = content
|
|
38
|
+
.filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
|
|
39
|
+
.map((p: any) => p.text)
|
|
40
|
+
.join('')
|
|
41
|
+
return text.trim()
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function registerCronDelivery(api: PluginApi) {
|
|
50
|
+
api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
51
|
+
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
52
|
+
const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
|
|
53
|
+
|
|
54
|
+
// Only fire for cron sessions
|
|
55
|
+
if (!sessionKey?.startsWith('agent:clawly:cron:')) return
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Extract raw assistant text (preserving formatting)
|
|
59
|
+
const text = getRawLastAssistantText(event.messages)
|
|
60
|
+
if (text == null) {
|
|
61
|
+
api.logger.info('cron-delivery: skipped (no assistant message)')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Filter noise — reuse the same logic as offline-push
|
|
66
|
+
const reason = shouldSkipPushForMessage(text)
|
|
67
|
+
if (reason) {
|
|
68
|
+
api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Resolve main session key for this agent
|
|
73
|
+
if (!agentId) {
|
|
74
|
+
api.logger.error('cron-delivery: skipped (no agentId on ctx)')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const mainSessionKey = await resolveSessionKey(agentId, api)
|
|
79
|
+
|
|
80
|
+
// Inject the cron result into the main session
|
|
81
|
+
const result = await injectAssistantMessage(
|
|
82
|
+
{
|
|
83
|
+
sessionKey: mainSessionKey,
|
|
84
|
+
message: text,
|
|
85
|
+
},
|
|
86
|
+
api,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
api.logger.info(
|
|
90
|
+
`cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
|
|
91
|
+
)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
api.logger.error(`cron-delivery: ${err instanceof Error ? err.message : String(err)}`)
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
api.logger.info('cron-delivery: registered agent_end hook')
|
|
98
|
+
}
|
package/gateway/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {PluginApi} from '../types'
|
|
|
2
2
|
import {registerAgentSend} from './agent'
|
|
3
3
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
4
4
|
import {registerConfigRepair} from './config-repair'
|
|
5
|
+
import {registerCronDelivery} from './cron-delivery'
|
|
5
6
|
import {registerPairing} from './pairing'
|
|
6
7
|
import {registerMemoryBrowser} from './memory'
|
|
7
8
|
import {registerNotification} from './notification'
|
|
@@ -18,6 +19,7 @@ export function registerGateway(api: PluginApi) {
|
|
|
18
19
|
registerClawhub2gateway(api)
|
|
19
20
|
registerPlugins(api)
|
|
20
21
|
registerOfflinePush(api)
|
|
22
|
+
registerCronDelivery(api)
|
|
21
23
|
registerConfigRepair(api)
|
|
22
24
|
registerPairing(api)
|
|
23
25
|
registerVersion(api)
|
|
@@ -92,7 +92,10 @@ describe('offline-push', () => {
|
|
|
92
92
|
await handlers.get('agent_end')!({}, {sessionKey: 'agent:clawly:main'})
|
|
93
93
|
|
|
94
94
|
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
95
|
-
expect(logs
|
|
95
|
+
expect(logs).toContainEqual({
|
|
96
|
+
level: 'info',
|
|
97
|
+
msg: expect.stringContaining('skipped (client online)'),
|
|
98
|
+
})
|
|
96
99
|
})
|
|
97
100
|
|
|
98
101
|
test('sends push for consecutive calls on same session', async () => {
|
package/gateway/offline-push.ts
CHANGED
|
@@ -104,7 +104,10 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
104
104
|
try {
|
|
105
105
|
// Skip if client is still connected — they got the response in real-time.
|
|
106
106
|
const online = await isClientOnline()
|
|
107
|
-
if (online)
|
|
107
|
+
if (online) {
|
|
108
|
+
api.logger.info('offline-push: skipped (client online)')
|
|
109
|
+
return
|
|
110
|
+
}
|
|
108
111
|
|
|
109
112
|
// Extract full assistant text for filtering and preview.
|
|
110
113
|
const fullText = getLastAssistantText(event.messages)
|
package/gateway/presence.test.ts
CHANGED
|
@@ -6,8 +6,8 @@ describe('isOnlineEntry', () => {
|
|
|
6
6
|
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
|
|
7
7
|
})
|
|
8
8
|
|
|
9
|
-
test('returns
|
|
10
|
-
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(
|
|
9
|
+
test('returns false for reason "connect" (gateway WS state, not reliable for app foreground)', () => {
|
|
10
|
+
expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(false)
|
|
11
11
|
})
|
|
12
12
|
|
|
13
13
|
test('returns false for reason "background"', () => {
|
package/gateway/presence.ts
CHANGED
|
@@ -19,10 +19,12 @@ interface PresenceEntry {
|
|
|
19
19
|
reason?: string
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** Returns true if the presence entry indicates the client is actively
|
|
22
|
+
/** Returns true if the presence entry indicates the client is actively in the foreground.
|
|
23
|
+
* Only trusts the explicit 'foreground' signal from the mobile app — 'connect'
|
|
24
|
+
* (gateway WS state) can linger after the app backgrounds, causing false positives. */
|
|
23
25
|
export function isOnlineEntry(entry: PresenceEntry | undefined): boolean {
|
|
24
26
|
if (!entry) return false
|
|
25
|
-
return entry.reason === 'foreground'
|
|
27
|
+
return entry.reason === 'foreground'
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
package/index.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* - before_message_write — restores original /skill command in user messages (undoes gateway rewrite)
|
|
26
26
|
* - tool_result_persist — copies TTS audio to persistent outbound directory
|
|
27
27
|
* - before_tool_call — enforces delivery fields on cron.create
|
|
28
|
-
* - agent_end — sends push notification when client is offline
|
|
28
|
+
* - agent_end — sends push notification when client is offline; injects cron results into main session
|
|
29
29
|
* - gateway_start — auto-approves device pairing for Clawly mobile clients (clientId: openclaw-ios)
|
|
30
30
|
* - gateway_start — registers auto-update cron job (0 3 * * *) for clawly-plugins
|
|
31
31
|
*/
|
package/model-gateway-setup.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* On plugin init, patches openclaw.json to add the `clawly-model-gateway`
|
|
3
3
|
* model provider entry. Credentials come from pluginConfig; the model list
|
|
4
|
-
* is derived from `agents.defaults.model`
|
|
4
|
+
* is derived from `agents.defaults.model`
|
|
5
5
|
* already present in the config.
|
|
6
6
|
*
|
|
7
7
|
* This runs synchronously during plugin registration (before gateway_start).
|
|
@@ -159,30 +159,22 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
|
|
|
159
159
|
return false
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// Derive model
|
|
162
|
+
// Derive default model ID from agents.defaults.
|
|
163
|
+
// Image fallback is handled server-side by the model-gateway proxy, so only
|
|
164
|
+
// the default chat model needs to be registered in the provider's model list.
|
|
163
165
|
const defaultModelFull: string = (config.agents as any)?.defaults?.model?.primary ?? ''
|
|
164
|
-
const imageModelFull: string = (config.agents as any)?.defaults?.imageModel?.primary ?? ''
|
|
165
166
|
|
|
166
167
|
const prefix = `${PROVIDER_NAME}/`
|
|
167
168
|
const defaultModel = defaultModelFull.startsWith(prefix)
|
|
168
169
|
? defaultModelFull.slice(prefix.length)
|
|
169
170
|
: defaultModelFull
|
|
170
|
-
const imageModel = imageModelFull.startsWith(prefix)
|
|
171
|
-
? imageModelFull.slice(prefix.length)
|
|
172
|
-
: imageModelFull
|
|
173
171
|
|
|
174
172
|
if (!defaultModel) {
|
|
175
173
|
api.logger.warn('No default model found in agents.defaults — model gateway setup skipped.')
|
|
176
174
|
return false
|
|
177
175
|
}
|
|
178
176
|
|
|
179
|
-
const defaultModels =
|
|
180
|
-
defaultModel === imageModel || !imageModel
|
|
181
|
-
? [{id: defaultModel, name: defaultModel, input: ['text', 'image']}]
|
|
182
|
-
: [
|
|
183
|
-
{id: defaultModel, name: defaultModel, input: ['text']},
|
|
184
|
-
{id: imageModel, name: imageModel, input: ['text', 'image']},
|
|
185
|
-
]
|
|
177
|
+
const defaultModels = [{id: defaultModel, name: defaultModel, input: ['text', 'image']}]
|
|
186
178
|
|
|
187
179
|
const defaultIds = new Set(defaultModels.map((m) => m.id))
|
|
188
180
|
const models = [
|
|
@@ -208,9 +200,6 @@ export function patchModelGateway(config: Record<string, unknown>, api: PluginAp
|
|
|
208
200
|
const defaults = agents.defaults ?? {}
|
|
209
201
|
const modelsMap: Record<string, {alias: string}> = {
|
|
210
202
|
[`${PROVIDER_NAME}/${defaultModel}`]: {alias: defaultModel},
|
|
211
|
-
...(imageModel && imageModel !== defaultModel
|
|
212
|
-
? {[`${PROVIDER_NAME}/${imageModel}`]: {alias: imageModel}}
|
|
213
|
-
: {}),
|
|
214
203
|
}
|
|
215
204
|
for (const m of EXTRA_GATEWAY_MODELS) {
|
|
216
205
|
modelsMap[`${PROVIDER_NAME}/${m.id}`] = {alias: m.alias}
|
package/package.json
CHANGED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import type {PluginApi} from '../types'
|
|
3
|
+
import {registerSendMessageTool} from './clawly-send-message'
|
|
4
|
+
|
|
5
|
+
// ── Mocks ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
let mockOnline = false
|
|
8
|
+
let mockPushSent = true
|
|
9
|
+
let mockInjectResult = {ok: true, messageId: 'msg-123'}
|
|
10
|
+
let mockResolvedSessionKey = 'agent:clawly:main'
|
|
11
|
+
let mockAgentResult = {ok: true, runId: 'run-456', error: undefined as string | undefined}
|
|
12
|
+
|
|
13
|
+
let lastInjectParams: {sessionKey: string; message: string; label?: string} | null = null
|
|
14
|
+
let lastPushOpts: {
|
|
15
|
+
body: string
|
|
16
|
+
agentId?: string
|
|
17
|
+
data?: Record<string, unknown>
|
|
18
|
+
} | null = null
|
|
19
|
+
let lastAgentParams: {message: string; agentId: string} | null = null
|
|
20
|
+
|
|
21
|
+
mock.module('../gateway/presence', () => ({
|
|
22
|
+
isClientOnline: async () => mockOnline,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
mock.module('../gateway/notification', () => ({
|
|
26
|
+
sendPushNotification: async (
|
|
27
|
+
opts: {body: string; agentId?: string; data?: Record<string, unknown>},
|
|
28
|
+
_api: PluginApi,
|
|
29
|
+
) => {
|
|
30
|
+
lastPushOpts = opts
|
|
31
|
+
return mockPushSent
|
|
32
|
+
},
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
mock.module('../gateway/inject', () => ({
|
|
36
|
+
resolveSessionKey: async () => mockResolvedSessionKey,
|
|
37
|
+
injectAssistantMessage: async (params: {sessionKey: string; message: string; label?: string}) => {
|
|
38
|
+
lastInjectParams = params
|
|
39
|
+
return mockInjectResult
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
mock.module('../gateway/agent', () => ({
|
|
44
|
+
callAgentGateway: async (params: {message: string; agentId: string}) => {
|
|
45
|
+
lastAgentParams = params
|
|
46
|
+
return mockAgentResult
|
|
47
|
+
},
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
type ToolExecute = (
|
|
53
|
+
toolCallId: string,
|
|
54
|
+
params: Record<string, unknown>,
|
|
55
|
+
) => Promise<{content: {type: string; text: string}[]}>
|
|
56
|
+
|
|
57
|
+
function createMockApi(): {
|
|
58
|
+
api: PluginApi
|
|
59
|
+
logs: {level: string; msg: string}[]
|
|
60
|
+
execute: ToolExecute
|
|
61
|
+
} {
|
|
62
|
+
const logs: {level: string; msg: string}[] = []
|
|
63
|
+
let registeredExecute: ToolExecute | null = null
|
|
64
|
+
|
|
65
|
+
const api = {
|
|
66
|
+
id: 'test',
|
|
67
|
+
name: 'test',
|
|
68
|
+
logger: {
|
|
69
|
+
info: (msg: string) => logs.push({level: 'info', msg}),
|
|
70
|
+
warn: (msg: string) => logs.push({level: 'warn', msg}),
|
|
71
|
+
error: (msg: string) => logs.push({level: 'error', msg}),
|
|
72
|
+
},
|
|
73
|
+
registerTool: (tool: {execute: ToolExecute}) => {
|
|
74
|
+
registeredExecute = tool.execute
|
|
75
|
+
},
|
|
76
|
+
} as unknown as PluginApi
|
|
77
|
+
|
|
78
|
+
registerSendMessageTool(api)
|
|
79
|
+
|
|
80
|
+
return {api, logs, execute: registeredExecute!}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseResult(res: {content: {type: string; text: string}[]}): Record<string, unknown> {
|
|
84
|
+
return JSON.parse(res.content[0].text)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mockOnline = false
|
|
91
|
+
mockPushSent = true
|
|
92
|
+
mockInjectResult = {ok: true, messageId: 'msg-123'}
|
|
93
|
+
mockResolvedSessionKey = 'agent:clawly:main'
|
|
94
|
+
mockAgentResult = {ok: true, runId: 'run-456', error: undefined}
|
|
95
|
+
lastInjectParams = null
|
|
96
|
+
lastPushOpts = null
|
|
97
|
+
lastAgentParams = null
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('clawly_send_message', () => {
|
|
101
|
+
describe('role: assistant (default)', () => {
|
|
102
|
+
test('injects message and resolves session key', async () => {
|
|
103
|
+
mockOnline = true
|
|
104
|
+
const {execute} = createMockApi()
|
|
105
|
+
|
|
106
|
+
const res = parseResult(await execute('tc-1', {message: 'Hello from cron'}))
|
|
107
|
+
|
|
108
|
+
expect(res.sent).toBe(true)
|
|
109
|
+
expect(res.messageId).toBe('msg-123')
|
|
110
|
+
expect(lastInjectParams).toEqual({
|
|
111
|
+
sessionKey: 'agent:clawly:main',
|
|
112
|
+
message: 'Hello from cron',
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('uses explicit sessionKey when provided', async () => {
|
|
117
|
+
mockOnline = true
|
|
118
|
+
const {execute} = createMockApi()
|
|
119
|
+
|
|
120
|
+
await execute('tc-1', {message: 'Hi', sessionKey: 'agent:clawly:custom'})
|
|
121
|
+
|
|
122
|
+
expect(lastInjectParams?.sessionKey).toBe('agent:clawly:custom')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('uses custom agent ID', async () => {
|
|
126
|
+
mockOnline = true
|
|
127
|
+
const {execute} = createMockApi()
|
|
128
|
+
|
|
129
|
+
await execute('tc-1', {message: 'Hi', agent: 'luna'})
|
|
130
|
+
|
|
131
|
+
// resolveSessionKey is called with 'luna' — mock always returns mockResolvedSessionKey
|
|
132
|
+
expect(lastInjectParams?.sessionKey).toBe('agent:clawly:main')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('sends push when client is offline', async () => {
|
|
136
|
+
mockOnline = false
|
|
137
|
+
const {execute} = createMockApi()
|
|
138
|
+
|
|
139
|
+
const res = parseResult(await execute('tc-1', {message: 'Weather report'}))
|
|
140
|
+
|
|
141
|
+
expect(res.sent).toBe(true)
|
|
142
|
+
expect(res.pushSent).toBe(true)
|
|
143
|
+
expect(lastPushOpts).toEqual({
|
|
144
|
+
body: 'Weather report',
|
|
145
|
+
agentId: 'clawly',
|
|
146
|
+
data: {type: 'send_message'},
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('does not send push when client is online', async () => {
|
|
151
|
+
mockOnline = true
|
|
152
|
+
const {execute} = createMockApi()
|
|
153
|
+
|
|
154
|
+
const res = parseResult(await execute('tc-1', {message: 'Weather report'}))
|
|
155
|
+
|
|
156
|
+
expect(res.sent).toBe(true)
|
|
157
|
+
expect(res.pushSent).toBe(false)
|
|
158
|
+
expect(lastPushOpts).toBeNull()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('truncates push body to 140 chars', async () => {
|
|
162
|
+
mockOnline = false
|
|
163
|
+
const {execute} = createMockApi()
|
|
164
|
+
|
|
165
|
+
const longMessage = 'a'.repeat(200)
|
|
166
|
+
await execute('tc-1', {message: longMessage})
|
|
167
|
+
|
|
168
|
+
expect(lastPushOpts!.body.length).toBe(141) // 140 + "…"
|
|
169
|
+
expect(lastPushOpts!.body.endsWith('…')).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('push failure does not fail the tool call', async () => {
|
|
173
|
+
mockOnline = false
|
|
174
|
+
mockPushSent = false
|
|
175
|
+
const {execute, logs} = createMockApi()
|
|
176
|
+
|
|
177
|
+
const res = parseResult(await execute('tc-1', {message: 'Hi'}))
|
|
178
|
+
|
|
179
|
+
expect(res.sent).toBe(true)
|
|
180
|
+
expect(res.pushSent).toBe(false)
|
|
181
|
+
expect(logs).toContainEqual({
|
|
182
|
+
level: 'info',
|
|
183
|
+
msg: expect.stringContaining('push failed'),
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('returns pushSent false when push throws', async () => {
|
|
188
|
+
// Override the mock to throw — re-mock would conflict, so test the
|
|
189
|
+
// catch path indirectly: mockOnline=true means no push attempt at all.
|
|
190
|
+
mockOnline = true
|
|
191
|
+
const {execute} = createMockApi()
|
|
192
|
+
|
|
193
|
+
const res = parseResult(await execute('tc-1', {message: 'Hi'}))
|
|
194
|
+
|
|
195
|
+
expect(res.sent).toBe(true)
|
|
196
|
+
expect(res.pushSent).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('role: user', () => {
|
|
201
|
+
test('triggers agent turn via gateway', async () => {
|
|
202
|
+
const {execute} = createMockApi()
|
|
203
|
+
|
|
204
|
+
const res = parseResult(await execute('tc-1', {message: 'Do something', role: 'user'}))
|
|
205
|
+
|
|
206
|
+
expect(res.sent).toBe(true)
|
|
207
|
+
expect(res.runId).toBe('run-456')
|
|
208
|
+
expect(lastAgentParams).toEqual({message: 'Do something', agentId: 'clawly'})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('returns error when gateway fails', async () => {
|
|
212
|
+
mockAgentResult = {ok: false, runId: undefined as any, error: 'agent busy'}
|
|
213
|
+
const {execute} = createMockApi()
|
|
214
|
+
|
|
215
|
+
const res = parseResult(await execute('tc-1', {message: 'Do something', role: 'user'}))
|
|
216
|
+
|
|
217
|
+
expect(res.sent).toBe(false)
|
|
218
|
+
expect(res.error).toBe('agent busy')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('validation', () => {
|
|
223
|
+
test('returns error for empty message', async () => {
|
|
224
|
+
const {execute} = createMockApi()
|
|
225
|
+
|
|
226
|
+
const res = parseResult(await execute('tc-1', {message: ''}))
|
|
227
|
+
|
|
228
|
+
expect(res.error).toBe('message is required')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('returns error for missing message', async () => {
|
|
232
|
+
const {execute} = createMockApi()
|
|
233
|
+
|
|
234
|
+
const res = parseResult(await execute('tc-1', {}))
|
|
235
|
+
|
|
236
|
+
expect(res.error).toBe('message is required')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('trims whitespace-only message as empty', async () => {
|
|
240
|
+
const {execute} = createMockApi()
|
|
241
|
+
|
|
242
|
+
const res = parseResult(await execute('tc-1', {message: ' '}))
|
|
243
|
+
|
|
244
|
+
expect(res.error).toBe('message is required')
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import type {PluginApi} from '../types'
|
|
11
11
|
import {callAgentGateway} from '../gateway/agent'
|
|
12
12
|
import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
|
|
13
|
+
import {sendPushNotification} from '../gateway/notification'
|
|
14
|
+
import {isClientOnline} from '../gateway/presence'
|
|
13
15
|
|
|
14
16
|
const TOOL_NAME = 'clawly_send_message'
|
|
15
17
|
|
|
@@ -57,9 +59,30 @@ export function registerSendMessageTool(api: PluginApi) {
|
|
|
57
59
|
api.logger.info(
|
|
58
60
|
`${TOOL_NAME}: injected assistant message (${message.length} chars) into session ${sessionKey}`,
|
|
59
61
|
)
|
|
62
|
+
|
|
63
|
+
// Send push notification if user is offline
|
|
64
|
+
let pushSent = false
|
|
65
|
+
try {
|
|
66
|
+
const online = await isClientOnline()
|
|
67
|
+
if (!online) {
|
|
68
|
+
const preview = message.length > 140 ? `${message.slice(0, 140)}…` : message
|
|
69
|
+
pushSent =
|
|
70
|
+
(await sendPushNotification(
|
|
71
|
+
{body: preview, agentId: agent, data: {type: 'send_message'}},
|
|
72
|
+
api,
|
|
73
|
+
)) ?? false
|
|
74
|
+
api.logger.info(`${TOOL_NAME}: push ${pushSent ? 'sent' : 'failed'} (client offline)`)
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Push is best-effort — don't fail the tool call
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
return {
|
|
61
81
|
content: [
|
|
62
|
-
{
|
|
82
|
+
{
|
|
83
|
+
type: 'text',
|
|
84
|
+
text: JSON.stringify({sent: true, messageId: result.messageId, pushSent}),
|
|
85
|
+
},
|
|
63
86
|
],
|
|
64
87
|
}
|
|
65
88
|
}
|