helmpilot 0.4.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/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # helmpilot-channel
2
+
3
+ The Helmpilot Desktop Channel plugin for OpenClaw — registers Helmpilot as a full-featured channel
4
+ in the OpenClaw gateway, providing interactive tools, outbound messaging, and relay-based
5
+ cross-network tunneling.
6
+
7
+ **Channel ID**: `helmpilot-openclaw`
8
+ **Config Section**: `channels.helmpilot`
9
+
10
+ ## Architecture
11
+
12
+ Helmpilot is a desktop client that communicates with OpenClaw via WebSocket RPC.
13
+ This plugin makes Helmpilot a **first-class channel** in the OpenClaw ecosystem,
14
+ following the same plugin pattern as Slack, Telegram, Discord, and other channels.
15
+
16
+ ### Relay Tunnel
17
+
18
+ When configured with a relay URL, the plugin establishes a transparent tunnel:
19
+
20
+ ```
21
+ Helmpilot Desktop ──WS(deviceKey)──→ Helmpilot Relay ←──WS(channelKey)── helmpilot-channel plugin
22
+ (transparent) │
23
+ bridge WS
24
+
25
+ ws://127.0.0.1:<port>
26
+ (local gateway)
27
+ ```
28
+
29
+ The relay forwards raw WebSocket messages without parsing. The plugin acts as a bridge:
30
+ it connects to the relay as the "gateway side", then opens a local WebSocket to the
31
+ OpenClaw gateway. Messages are transparently forwarded between these two connections.
32
+
33
+ ### Interactive Tool Flow
34
+
35
+ ```
36
+ Agent calls hp_ask_user({ questions: [...] })
37
+
38
+ OpenClaw emits tool.start event → Helmpilot client renders QuestionCard UI
39
+
40
+ Plugin execute() blocks on a Promise (keyed by toolCallId)
41
+
42
+ User fills answers → Helmpilot client calls helmpilot.respond via WS RPC
43
+
44
+ Gateway method resolves the Promise → returns answers as tool result
45
+
46
+ Agent sees actual user answers, continues reasoning
47
+ ```
48
+
49
+ ## Plugin Structure
50
+
51
+ ```
52
+ plugins/helmpilot-channel/
53
+ index.ts ← Plugin entry (definePluginEntry) — tools, RPC, channel registration
54
+ channel-plugin.ts ← ChannelPlugin definition (meta/config/gateway/startAccount)
55
+ tools.ts ← Tool schemas, PendingMap (Symbol.for key)
56
+ bridge.ts ← LocalBridge — WS tunnel to local gateway with ping/pong heartbeat
57
+ outbound.ts ← Outbound adapter (sendText/sendMedia via relay)
58
+ relay-client.ts ← RelayClient with Ed25519 auth + auto-reconnect + keepalive
59
+ relay-registry.ts ← Module-level Map<accountId, RelayClient> singleton
60
+ adapters.ts ← Messaging/Setup/Status/Pairing/Security adapters
61
+ types.ts ← Question/Answer TypeBox schemas
62
+ preload.cjs ← CJS→ESM bridge + SDK symlink
63
+ openclaw.plugin.json ← Manifest (declares channel: "helmpilot-openclaw")
64
+ package.json ← Package metadata and OpenClaw compatibility
65
+ __tests__/ ← Outbound adapter + config adapter tests
66
+ ```
67
+
68
+ ## Channel Adapters
69
+
70
+ The plugin implements the full ChannelPlugin interface:
71
+
72
+ | Adapter | Source | Purpose |
73
+ |---------|--------|---------|
74
+ | **outbound** | `outbound.ts` | Send text/media to Helmpilot client via relay (`deliveryMode: 'direct'`, `textChunkLimit: 4000`) |
75
+ | **messaging** | `adapters.ts` | Target normalization and resolution |
76
+ | **setup** | `adapters.ts` | Account config validation and application |
77
+ | **status** | `adapters.ts` | Channel summary, account snapshot, probe |
78
+ | **pairing** | `adapters.ts` | Device key pairing (`idLabel: 'Device Key'`) |
79
+ | **security** | `adapters.ts` | Security policy adapter |
80
+ | **config** | Inline in plugin | Account CRUD, enable/disable, account resolution |
81
+
82
+ ### Outbound Adapter
83
+
84
+ Sends messages as OpenClaw event frames through the relay:
85
+
86
+ ```typescript
87
+ // Format sent to relay
88
+ {
89
+ type: 'event',
90
+ event: 'helmpilot.outbound',
91
+ payload: {
92
+ kind: 'text' | 'media',
93
+ text?: string,
94
+ mediaUrl?: string,
95
+ messageId: string, // Format: msg_<uuid>
96
+ replyToId?: string,
97
+ identity?: { name?, avatar?, emoji? }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ## Tools
103
+
104
+ ### `hp_ask_user`
105
+
106
+ Present structured questions to the user in the Helmpilot desktop client.
107
+ Blocks until the user submits answers. No fixed timeout — waits indefinitely
108
+ until the user responds. Cleanup relies on connection/session lifecycle boundaries.
109
+
110
+ ```typescript
111
+ {
112
+ questions: [
113
+ {
114
+ header: "Language", // Short unique ID (max 50 chars)
115
+ question: "Which language?", // Display text (max 500 chars)
116
+ options: [ // Optional predefined choices
117
+ { label: "Python", recommended: true },
118
+ { label: "TypeScript" },
119
+ { label: "Rust", description: "Systems programming" }
120
+ ],
121
+ multiSelect: false, // Allow multiple selections
122
+ allowFreeformInput: true // Allow typing a custom answer
123
+ }
124
+ ]
125
+ }
126
+ ```
127
+
128
+ ### `hp_send_msg`
129
+
130
+ Send a notification or structured message to the Helmpilot client.
131
+ Non-blocking — delivers the message for display as a distinct notification card,
132
+ separate from inline chat stream. Supports Markdown in the message body.
133
+
134
+ ```typescript
135
+ {
136
+ message: "Deployment **completed** successfully!", // Markdown content
137
+ type: "success", // "info" (default) | "success" | "warning" | "error"
138
+ title: "Deploy Status" // Optional notification title
139
+ }
140
+ ```
141
+
142
+ ### `hp_send_file`
143
+
144
+ Send a file to the Helmpilot client for local display/writing.
145
+ Non-blocking — the tool returns immediately after relaying the content.
146
+
147
+ ## Gateway RPC Methods
148
+
149
+ ### `helmpilot.respond`
150
+
151
+ Called by the Helmpilot client to deliver user answers to a pending `hp_ask_user` call.
152
+
153
+ ```json
154
+ { "toolCallId": "...", "answers": "User's formatted answer string" }
155
+ ```
156
+
157
+ ## Configuration
158
+
159
+ ### CLI Quick Setup
160
+
161
+ The fastest way to configure helmpilot-channel is via the OpenClaw CLI:
162
+
163
+ ```bash
164
+ # Full non-interactive setup (all three fields at once)
165
+ openclaw channels add --channel helmpilot-openclaw \
166
+ --url wss://relay.example.com \
167
+ --code helmpilot-abc123 \
168
+ --token ck_xyz789
169
+
170
+ # Interactive wizard mode (guided step-by-step)
171
+ openclaw channels add --channel helmpilot-openclaw
172
+
173
+ # Partial update (e.g. rotate channel key only)
174
+ openclaw channels add --channel helmpilot-openclaw --token ck_new_key
175
+ ```
176
+
177
+ | CLI Flag | Config Field | Validation | Example |
178
+ |----------|-------------|------------|---------|
179
+ | `--url` | `channels.helmpilot.relayUrl` | Must be `ws://`, `wss://`, `http://`, or `https://` | `wss://relay.example.com` |
180
+ | `--code` | `channels.helmpilot.channelId` | Must start with `helmpilot-` | `helmpilot-abc123` |
181
+ | `--token` | `channels.helmpilot.channelKey` | Must start with `ck_` | `ck_xyz789` |
182
+
183
+ > **Tip**: The `channelId` and `channelKey` are generated by the Relay server during
184
+ > provisioning (triggered from the Helmpilot desktop client). Copy them from the Helmpilot
185
+ > setup screen into the CLI command above.
186
+
187
+ ### Manual Configuration
188
+
189
+ Add to your OpenClaw config (`openclaw.json`):
190
+
191
+ ### Single account (simple)
192
+
193
+ ```json
194
+ {
195
+ "channels": {
196
+ "helmpilot": {
197
+ "enabled": true,
198
+ "relayUrl": "wss://relay.example.com",
199
+ "channelKey": "ck_xxxxx..."
200
+ }
201
+ }
202
+ }
203
+ ```
204
+
205
+ ### Multi-account
206
+
207
+ ```json
208
+ {
209
+ "channels": {
210
+ "helmpilot": {
211
+ "accounts": {
212
+ "work": { "relayUrl": "wss://relay.example.com", "channelKey": "ck_aaa..." },
213
+ "home": { "relayUrl": "wss://relay.example.com", "channelKey": "ck_bbb..." }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ ```
219
+
220
+ ### Secret storage for channelKey
221
+
222
+ To avoid storing channelKey in plaintext, use any of these formats:
223
+
224
+ ```json
225
+ // Env template — resolved from process.env at startup
226
+ "channelKey": "${HELMPILOT_CHANNEL_KEY}"
227
+
228
+ // SecretRef (env source)
229
+ "channelKey": { "source": "env", "id": "HELMPILOT_CHANNEL_KEY" }
230
+
231
+ // SecretRef (file source)
232
+ "channelKey": { "source": "file", "id": "/run/secrets/helmpilot-channel-key" }
233
+ ```
234
+
235
+ | Field | Type | Required | Purpose |
236
+ |-------|------|----------|---------|
237
+ | `enabled` | boolean | No (default: true) | Enable/disable the channel |
238
+ | `relayUrl` | string | Yes (for relay mode) | Relay service WebSocket URL |
239
+ | `channelId` | string | Auto-populated | Channel ID from relay registration |
240
+ | `channelKey` | string \| SecretRef | Yes (for relay mode) | Channel key — supports env templates and SecretRef |
241
+
242
+ ## Installation
243
+
244
+ ```bash
245
+ openclaw plugins install helmpilot-channel
246
+ ```
247
+
248
+ Or add to your workspace's plugin load paths:
249
+
250
+ ```bash
251
+ # Local development (symlink)
252
+ ln -s /path/to/Helmpilot/plugins/helmpilot-channel ~/.openclaw/extensions/helmpilot-channel
253
+ ```
254
+
255
+ ## Relay Client
256
+
257
+ `RelayClient` (`relay-client.ts`) manages the gateway-side WebSocket connection to the relay:
258
+
259
+ - **Ed25519 auth**: Auto-generates key pair, handles challenge-response with relay
260
+ - **Legacy fallback**: Falls back to `?key=<channelKey>` URL param if Ed25519 keys not registered
261
+ - **Auto-reconnect**: Exponential backoff from 1s to 30s
262
+ - **Keepalive**: Sends `{"__relay":true,"type":"ping"}` every 25 seconds
263
+ - **Flush handling**: Recognizes `flush_start`/`flush_end` relay markers for buffered message batches
264
+ - **States**: `'disconnected' | 'connecting' | 'authenticating' | 'connected' | 'error'`
265
+
266
+ ## Local Bridge
267
+
268
+ `LocalBridge` (`bridge.ts`) manages the WS connection to the local OpenClaw gateway:
269
+
270
+ - **Transparent tunnel**: Forwards raw WS frames between relay and local gateway
271
+ - **Ping/pong heartbeat**: 20s interval + 10s timeout for dead connection detection
272
+ - **Lifecycle**: Opened per `startAccount()`, closed on disconnect
273
+
274
+ ## Process Architecture
275
+
276
+ The gateway loads plugin registration twice:
277
+ - **Connection-level**: `registerGatewayMethod()` handlers (client-specific)
278
+ - **Per-agent**: `registerTool()` handlers (agent-scoped)
279
+
280
+ These scopes don't share closure variables. We use `globalThis[Symbol.for('helmpilot.pendingMap')]`
281
+ as a process-level shared mailbox between `helmpilot.respond` (connection) and
282
+ `hp_ask_user` execute (agent).
283
+
284
+ ## Multi-Account Isolation
285
+
286
+ Each account gets its own `RelayClient` + `LocalBridge` pair, registered in `relay-registry.ts`.
287
+ Outbound messages are routed to the correct relay via `getRelay(accountId)` — the null-fallback
288
+ has been removed to prevent cross-account message leakage.
289
+
290
+ The `helmpilot.respond` RPC handler requires an explicit `toolCallId` to prevent resolving
291
+ a pending request from a different account's agent.
292
+
293
+ ## Testing
294
+
295
+ ```bash
296
+ # All plugin tests
297
+ npx vitest run plugins/helmpilot-channel/__tests__/
298
+
299
+ # Individual test files
300
+ npx vitest run plugins/helmpilot-channel/__tests__/outbound.test.ts # Outbound adapter (10 cases)
301
+ npx vitest run plugins/helmpilot-channel/__tests__/config-adapter.test.ts # Config/multi-account (varies)
302
+ npx vitest run plugins/helmpilot-channel/__tests__/setup-adapter.test.ts # CLI setup adapter (16 cases)
303
+ ```
304
+
305
+ ## License
306
+
307
+ MIT
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for the helmpilot-channel plugin config adapter (multi-account support).
3
+ *
4
+ * We import the plugin definition and exercise the config.* methods
5
+ * to verify multi-account and legacy single-account config parsing.
6
+ */
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ // Mock all external dependencies that the plugin imports
10
+ vi.mock('../relay-registry.js', () => ({
11
+ getRelay: vi.fn(),
12
+ getFirstRelay: vi.fn(),
13
+ registerRelay: vi.fn(),
14
+ unregisterRelay: vi.fn(),
15
+ }));
16
+ vi.mock('node:crypto', () => ({ randomUUID: () => 'test-uuid' }));
17
+
18
+ vi.mock('ws', () => ({
19
+ WebSocket: class MockWebSocket {},
20
+ }));
21
+
22
+ vi.mock('../relay-client.js', () => ({
23
+ RelayClient: class MockRelayClient {
24
+ constructor() {}
25
+ connect() {}
26
+ close() {}
27
+ getState() { return 'disconnected'; }
28
+ },
29
+ }));
30
+
31
+ vi.mock('../outbound.js', () => ({
32
+ createOutboundAdapter: () => ({ sendText: vi.fn(), sendMedia: vi.fn() }),
33
+ }));
34
+
35
+ vi.mock('../adapters.js', () => ({
36
+ createMessagingAdapter: () => ({}),
37
+ createSetupAdapter: () => ({}),
38
+ createStatusAdapter: () => ({}),
39
+ createPairingAdapter: () => ({}),
40
+ createSecurityAdapter: () => ({}),
41
+ }));
42
+
43
+ // The plugin file imports from OpenClaw SDK — mock these
44
+ vi.mock('openclaw/plugin-sdk/plugin-entry', () => ({
45
+ definePluginEntry: (fn: any) => fn,
46
+ }));
47
+
48
+ vi.mock('openclaw/plugin-sdk/core', () => ({
49
+ ChannelPlugin: class {},
50
+ }));
51
+
52
+ vi.mock('openclaw/plugin-sdk/device-bootstrap', () => ({
53
+ listDevicePairing: vi.fn().mockResolvedValue({ pending: [], paired: [] }),
54
+ approveDevicePairing: vi.fn().mockResolvedValue(null),
55
+ }));
56
+
57
+ // Import after mocks are set up
58
+ const pluginEntry = (await import('../index.js')).default;
59
+
60
+ // The plugin registers the channel via api.registerChannel({ plugin: helmpilotPlugin })
61
+ // We capture helmpilotPlugin by calling register() with a spy
62
+ let helmpilotPlugin: any;
63
+ const fakeApi = {
64
+ registerChannel: (opts: any) => { helmpilotPlugin = opts.plugin; },
65
+ registerGatewayMethod: () => {},
66
+ registerTool: () => {},
67
+ on: () => {},
68
+ logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
69
+ };
70
+ pluginEntry.register(fakeApi);
71
+
72
+ const cfg = helmpilotPlugin.config;
73
+
74
+ // ── Helpers ──
75
+
76
+ /** Build a full OpenClaw config shape with the given helmpilot section */
77
+ function makeConfig(helmpilot: Record<string, unknown> = {}) {
78
+ return { channels: { helmpilot } };
79
+ }
80
+
81
+ describe('config adapter', () => {
82
+ describe('listAccountIds', () => {
83
+ it('returns ["default"] for empty config', () => {
84
+ const ids = cfg.listAccountIds({});
85
+ expect(ids).toEqual(['default']);
86
+ });
87
+
88
+ it('returns ["default"] for legacy single-account config', () => {
89
+ const ids = cfg.listAccountIds(
90
+ makeConfig({ relayUrl: 'https://relay.example.com', channelKey: 'ck_abc' }),
91
+ );
92
+ expect(ids).toEqual(['default']);
93
+ });
94
+
95
+ it('returns account keys from accounts object', () => {
96
+ const ids = cfg.listAccountIds(
97
+ makeConfig({
98
+ accounts: {
99
+ home: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
100
+ work: { relayUrl: 'https://r2.com', channelKey: 'ck_2' },
101
+ },
102
+ }),
103
+ );
104
+ expect(ids).toEqual(['home', 'work']);
105
+ });
106
+
107
+ it('prepends "default" when legacy top-level config coexists with accounts', () => {
108
+ const ids = cfg.listAccountIds(
109
+ makeConfig({
110
+ relayUrl: 'https://legacy.com',
111
+ channelKey: 'ck_legacy',
112
+ accounts: {
113
+ mobile: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
114
+ },
115
+ }),
116
+ );
117
+ expect(ids).toEqual(['default', 'mobile']);
118
+ });
119
+
120
+ it('does not duplicate default if accounts already has "default" key', () => {
121
+ const ids = cfg.listAccountIds(
122
+ makeConfig({
123
+ relayUrl: 'https://legacy.com',
124
+ channelKey: 'ck_legacy',
125
+ accounts: {
126
+ default: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
127
+ },
128
+ }),
129
+ );
130
+ expect(ids).toEqual(['default']);
131
+ });
132
+ });
133
+
134
+ describe('resolveAccount', () => {
135
+ it('resolves legacy single-account with top-level config', () => {
136
+ const account = cfg.resolveAccount(
137
+ makeConfig({ relayUrl: 'https://relay.example.com', channelKey: 'ck_abc' }),
138
+ 'default',
139
+ );
140
+ expect(account.accountId).toBe('default');
141
+ expect(account.config.relayUrl).toBe('https://relay.example.com');
142
+ expect(account.config.channelKey).toBe('ck_abc');
143
+ expect(account.config.enabled).toBe(true);
144
+ });
145
+
146
+ it('resolves account from multi-account config', () => {
147
+ const account = cfg.resolveAccount(
148
+ makeConfig({
149
+ accounts: {
150
+ home: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
151
+ work: { relayUrl: 'https://r2.com', channelKey: 'ck_2' },
152
+ },
153
+ }),
154
+ 'work',
155
+ );
156
+ expect(account.accountId).toBe('work');
157
+ expect(account.config.relayUrl).toBe('https://r2.com');
158
+ expect(account.config.channelKey).toBe('ck_2');
159
+ });
160
+
161
+ it('falls back to legacy config for unknown account ID', () => {
162
+ const account = cfg.resolveAccount(
163
+ makeConfig({ relayUrl: 'https://legacy.com', channelKey: 'ck_legacy' }),
164
+ 'nonexistent',
165
+ );
166
+ expect(account.accountId).toBe('nonexistent');
167
+ expect(account.config.relayUrl).toBe('https://legacy.com');
168
+ });
169
+
170
+ it('defaults accountId to "default" when null', () => {
171
+ const account = cfg.resolveAccount(
172
+ makeConfig({ relayUrl: 'https://r.com', channelKey: 'ck_x' }),
173
+ null as any,
174
+ );
175
+ expect(account.accountId).toBe('default');
176
+ });
177
+
178
+ it('respects per-account enabled=false', () => {
179
+ const account = cfg.resolveAccount(
180
+ makeConfig({
181
+ accounts: {
182
+ off: { enabled: false, relayUrl: 'https://r.com', channelKey: 'ck_x' },
183
+ },
184
+ }),
185
+ 'off',
186
+ );
187
+ expect(account.config.enabled).toBe(false);
188
+ });
189
+
190
+ it('respects top-level enabled=false for multi-account', () => {
191
+ const account = cfg.resolveAccount(
192
+ makeConfig({
193
+ enabled: false,
194
+ accounts: {
195
+ home: { relayUrl: 'https://r.com', channelId: 'helmpilot-home', channelKey: 'ck_x' },
196
+ },
197
+ }),
198
+ 'home',
199
+ );
200
+ expect(account.config.enabled).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe('isConfigured', () => {
205
+ it('returns true for relay config with url, channelId, and key', () => {
206
+ const account = cfg.resolveAccount(
207
+ makeConfig({ relayUrl: 'https://r.com', channelId: 'helmpilot-abc', channelKey: 'ck_x' }),
208
+ 'default',
209
+ );
210
+ expect(cfg.isConfigured(account)).toBe(true);
211
+ });
212
+
213
+ it('returns false when only relayUrl is set', () => {
214
+ const account = cfg.resolveAccount(
215
+ makeConfig({ relayUrl: 'https://r.com' }),
216
+ 'default',
217
+ );
218
+ expect(cfg.isConfigured(account)).toBe(false);
219
+ });
220
+
221
+ it('returns false when channelId is missing', () => {
222
+ const account = cfg.resolveAccount(
223
+ makeConfig({ relayUrl: 'https://r.com', channelKey: 'ck_x' }),
224
+ 'default',
225
+ );
226
+ expect(cfg.isConfigured(account)).toBe(false);
227
+ });
228
+
229
+ it('returns true for local mode (no relay config)', () => {
230
+ const account = cfg.resolveAccount(makeConfig({}), 'default');
231
+ expect(cfg.isConfigured(account)).toBe(true);
232
+ });
233
+ });
234
+
235
+ describe('isEnabled', () => {
236
+ it('returns true by default', () => {
237
+ const account = cfg.resolveAccount(makeConfig({}), 'default');
238
+ expect(cfg.isEnabled(account)).toBe(true);
239
+ });
240
+
241
+ it('returns false when enabled is explicitly false', () => {
242
+ const account = cfg.resolveAccount(
243
+ makeConfig({ enabled: false }),
244
+ 'default',
245
+ );
246
+ expect(cfg.isEnabled(account)).toBe(false);
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+
3
+ // Use vi.hoisted so mock variables are available during vi.mock hoisting
4
+ const { mockGetRelay, uuidState } = vi.hoisted(() => ({
5
+ mockGetRelay: vi.fn(),
6
+ uuidState: { counter: 0 },
7
+ }));
8
+
9
+ vi.mock('../relay-registry.js', () => ({
10
+ getRelay: mockGetRelay,
11
+ }));
12
+
13
+ vi.mock('node:crypto', () => ({
14
+ randomUUID: () => `00000000-0000-0000-0000-${String(++uuidState.counter).padStart(12, '0')}`,
15
+ }));
16
+
17
+ import { createOutboundAdapter } from '../outbound';
18
+
19
+ describe('outbound adapter', () => {
20
+ const mockRelay = {
21
+ getState: vi.fn(),
22
+ sendRaw: vi.fn(),
23
+ };
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ uuidState.counter = 0;
28
+ mockGetRelay.mockReturnValue(mockRelay);
29
+ mockRelay.getState.mockReturnValue('connected');
30
+ mockRelay.sendRaw.mockReturnValue(true);
31
+ });
32
+
33
+ describe('sendText', () => {
34
+ it('sends text as OpenClaw event frame format', async () => {
35
+ const adapter = createOutboundAdapter();
36
+ const result = await adapter.sendText({
37
+ to: 'user1',
38
+ text: 'Hello from agent',
39
+ });
40
+
41
+ expect(result.channel).toBe('helmpilot');
42
+ expect(result.messageId).toMatch(/^msg_/);
43
+ expect(result.error).toBeUndefined();
44
+
45
+ const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
46
+ expect(sent.type).toBe('event');
47
+ expect(sent.event).toBe('helmpilot.outbound');
48
+ expect(sent.payload.kind).toBe('text');
49
+ expect(sent.payload.text).toBe('Hello from agent');
50
+ expect(sent.payload.messageId).toMatch(/^msg_/);
51
+ });
52
+
53
+ it('includes identity and replyToId when provided', async () => {
54
+ const adapter = createOutboundAdapter();
55
+ await adapter.sendText({
56
+ to: 'user1',
57
+ text: 'Reply',
58
+ replyToId: 'orig_123',
59
+ identity: { name: 'Bot', emoji: '🤖' },
60
+ });
61
+
62
+ const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
63
+ expect(sent.payload.replyToId).toBe('orig_123');
64
+ expect(sent.payload.identity).toEqual({ name: 'Bot', emoji: '🤖' });
65
+ });
66
+
67
+ it('returns error when relay is not connected', async () => {
68
+ mockRelay.getState.mockReturnValue('connecting');
69
+ const adapter = createOutboundAdapter();
70
+ const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
71
+
72
+ expect(result.error).toBe('Relay not connected');
73
+ expect(result.messageId).toBe('');
74
+ expect(mockRelay.sendRaw).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('returns error when relay is null', async () => {
78
+ mockGetRelay.mockReturnValue(null);
79
+ const adapter = createOutboundAdapter();
80
+ const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
81
+
82
+ expect(result.error).toBe('Relay not connected');
83
+ });
84
+
85
+ it('returns error when sendRaw fails', async () => {
86
+ mockRelay.sendRaw.mockReturnValue(false);
87
+ const adapter = createOutboundAdapter();
88
+ const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
89
+
90
+ expect(result.error).toBe('Failed to send via relay');
91
+ });
92
+ });
93
+
94
+ describe('sendMedia', () => {
95
+ it('sends media as OpenClaw event frame format', async () => {
96
+ const adapter = createOutboundAdapter();
97
+ const result = await adapter.sendMedia({
98
+ to: 'user1',
99
+ mediaUrl: 'https://example.com/image.png',
100
+ text: 'Check this out',
101
+ });
102
+
103
+ expect(result.channel).toBe('helmpilot');
104
+ expect(result.error).toBeUndefined();
105
+
106
+ const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
107
+ expect(sent.type).toBe('event');
108
+ expect(sent.event).toBe('helmpilot.outbound');
109
+ expect(sent.payload.kind).toBe('media');
110
+ expect(sent.payload.mediaUrl).toBe('https://example.com/image.png');
111
+ expect(sent.payload.text).toBe('Check this out');
112
+ });
113
+
114
+ it('sends media without text', async () => {
115
+ const adapter = createOutboundAdapter();
116
+ await adapter.sendMedia({
117
+ to: 'user1',
118
+ mediaUrl: 'https://example.com/file.pdf',
119
+ });
120
+
121
+ const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
122
+ expect(sent.payload.text).toBeUndefined();
123
+ expect(sent.payload.mediaUrl).toBe('https://example.com/file.pdf');
124
+ });
125
+
126
+ it('returns error when relay is not connected', async () => {
127
+ mockGetRelay.mockReturnValue(null);
128
+ const adapter = createOutboundAdapter();
129
+ const result = await adapter.sendMedia({
130
+ to: 'user1',
131
+ mediaUrl: 'https://example.com/image.png',
132
+ });
133
+
134
+ expect(result.error).toBe('Relay not connected');
135
+ });
136
+ });
137
+
138
+ it('deliveryMode is direct', () => {
139
+ const adapter = createOutboundAdapter();
140
+ expect(adapter.deliveryMode).toBe('direct');
141
+ });
142
+
143
+ it('textChunkLimit is 4000', () => {
144
+ const adapter = createOutboundAdapter();
145
+ expect(adapter.textChunkLimit).toBe(4000);
146
+ });
147
+ });