@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 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" (agent uses clawly_send_message tool)
6
- * Appends: delivery instructions to payload.message
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" — agent reports via tools explicitly
34
- patched.delivery = {mode: 'none'}
35
-
36
- // Append delivery instructions to payload.message
37
- if (isRecord(job.payload) && typeof job.payload.message === 'string') {
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 delivery enforcement')
63
+ api.logger.info('hook: registered before_tool_call for cron add (none + agentTurn enforcement)')
77
64
  }
@@ -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 extraModels = EXTRA_GATEWAY_MODELS.map(({id, name, input}) => ({id, name, input}))
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 === imageModel || !imageModel
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.filter((l) => l.msg.includes('skipped'))).toHaveLength(0)
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 () => {
@@ -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) return
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)
@@ -6,8 +6,8 @@ describe('isOnlineEntry', () => {
6
6
  expect(isOnlineEntry({host: 'openclaw-ios', reason: 'foreground'})).toBe(true)
7
7
  })
8
8
 
9
- test('returns true for reason "connect" (backward compat)', () => {
10
- expect(isOnlineEntry({host: 'openclaw-ios', reason: 'connect'})).toBe(true)
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"', () => {
@@ -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 connected. */
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' || entry.reason === 'connect'
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
  */
@@ -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` / `agents.defaults.imageModel`
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 IDs from agents.defaults
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2en/clawly-plugins",
3
- "version": "1.24.7-beta.0",
3
+ "version": "1.24.7-beta.2",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
- {type: 'text', text: JSON.stringify({sent: true, messageId: result.messageId})},
82
+ {
83
+ type: 'text',
84
+ text: JSON.stringify({sent: true, messageId: result.messageId, pushSent}),
85
+ },
63
86
  ],
64
87
  }
65
88
  }