@avasapp/agent-bridge 0.1.0 → 0.2.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/README.md +96 -8
- package/dist/adapters/ably.cjs +344 -0
- package/dist/adapters/ably.d.cts +37 -0
- package/dist/adapters/socket-io.cjs +317 -0
- package/dist/adapters/socket-io.d.cts +21 -0
- package/dist/{chunk-UEWFQWCY.js → chunk-LBYZ5JHF.js} +5 -2
- package/dist/cli.js +1 -1
- package/dist/client/index.cjs +5 -2
- package/dist/client/index.js +1 -1
- package/dist/noop/ably.cjs +26 -0
- package/dist/noop/realtime.cjs +40 -0
- package/dist/noop/socket-io.cjs +26 -0
- package/dist/realtime/index.cjs +279 -0
- package/dist/realtime/index.d.cts +138 -0
- package/entries/ably.cjs +9 -0
- package/entries/realtime.cjs +9 -0
- package/entries/socket.io.cjs +9 -0
- package/package.json +20 -4
- package/skills/agent-bridge/SKILL.md +41 -5
package/README.md
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
# @avasapp/agent-bridge
|
|
2
2
|
|
|
3
|
-
Let coding agents drive a running React Native app directly. Seed data, flip flags, navigate and check the screen in **milliseconds**, without tapping through the app.
|
|
3
|
+
Let coding agents drive a running React Native app directly. Seed data, flip flags, fake realtime messages, navigate and check the screen in **milliseconds**, without tapping through the app. It works alongside [agent-device](https://github.com/callstack/agent-device): agent-device opens the app and handles native UI, and agent-bridge handles everything inside the app.
|
|
4
4
|
|
|
5
5
|
## Demo
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The same checks on [the example app](example) in Expo Go, both at real speed, all on one Mac:
|
|
10
10
|
|
|
11
|
-
[`
|
|
11
|
+
- **agent-device alone** ([`demo-agent-device.mjs`](example/flows/demo-agent-device.mjs)) taps and types through the UI, edits the fake backend and reloads to change the flag and the data, and asks the realtime server to push a message.
|
|
12
|
+
- **agent-device + agent-bridge** ([`demo.mjs`](example/flows/demo.mjs)) makes each change with one call, then undoes them all with `bridge.restore`.
|
|
13
|
+
|
|
14
|
+
agent-device 0.21.15 won't press or fill most of this screen by selector on the iOS 27 simulator ([callstack/agent-device#2996](https://github.com/callstack/agent-device/issues/2996)), so the agent-device side looks up each element's frame and taps its centre. That adds one lookup per tap or fill.
|
|
12
15
|
|
|
13
16
|
| Call | Round trip |
|
|
14
17
|
| --- | --- |
|
|
15
18
|
| `bridge.ping` | 1–4 ms |
|
|
16
19
|
| `screen.findText`, `screen.waitFor` (already there) | 3–9 ms |
|
|
17
|
-
| `screen.fill`, `screen.press` | 20–
|
|
18
|
-
| `query.pin`, `store.call`, `router.navigate` | 11–
|
|
19
|
-
|
|
|
20
|
+
| `screen.fill`, `screen.press` | 20–120 ms, render included |
|
|
21
|
+
| `query.pin`, `store.call`, `router.navigate` | 11–55 ms, render included |
|
|
22
|
+
| 19 steps, no pauses | 1.3–1.5 s wall, 0.9 s of it waiting on the fake backend |
|
|
20
23
|
|
|
21
24
|
```
|
|
22
|
-
|
|
25
|
+
19 steps with agent-bridge: 1.4 s wall
|
|
26
|
+
same checks, agent-device alone: 22.4 s wall
|
|
23
27
|
same flow from another machine: ~57 ms per call (network)
|
|
24
28
|
same screen check via a11y tree: 450–970 ms
|
|
25
29
|
```
|
|
@@ -32,6 +36,21 @@ Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev
|
|
|
32
36
|
bun add -d @avasapp/agent-bridge # or npm / yarn / pnpm
|
|
33
37
|
```
|
|
34
38
|
|
|
39
|
+
### Teach your agent
|
|
40
|
+
|
|
41
|
+
The package ships an agent skill that tells your agent when and how to drive the app. Add it to Claude Code, Cursor, Codex and other agents:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx skills add avas-app/agent-bridge
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
In Claude Code you can install it as a plugin instead:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
/plugin marketplace add avas-app/agent-bridge
|
|
51
|
+
/plugin install agent-bridge@agent-bridge
|
|
52
|
+
```
|
|
53
|
+
|
|
35
54
|
## In the app
|
|
36
55
|
|
|
37
56
|
Mount the hook in a file that only runs in development. You choose every tool; adapters for common libraries are one import away.
|
|
@@ -123,9 +142,77 @@ No device tool is needed. Pair one (such as agent-device) with the bridge for wh
|
|
|
123
142
|
| `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
|
|
124
143
|
| `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
|
|
125
144
|
| `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
|
|
145
|
+
| `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
|
|
146
|
+
| `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
|
|
147
|
+
| `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
|
|
126
148
|
|
|
127
149
|
A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route; apps can fake a whole backend with `mockRequests` from the same import.
|
|
128
150
|
|
|
151
|
+
## Realtime
|
|
152
|
+
|
|
153
|
+
The realtime tools let an agent see realtime messages, send the app a fake one, and stop real ones from overwriting a state it set up. They work with any library.
|
|
154
|
+
|
|
155
|
+
With Ably or socket.io, pass the client where you create it, **before the app subscribes**. Listeners added earlier stay invisible.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { ablyTools } from '@avasapp/agent-bridge/ably'
|
|
159
|
+
import { socketIoTools } from '@avasapp/agent-bridge/socket.io'
|
|
160
|
+
|
|
161
|
+
export const ably = new Ably.Realtime(options)
|
|
162
|
+
export const realtimeDevTools = ablyTools(ably) // or socketIoTools(socket)
|
|
163
|
+
|
|
164
|
+
// in AgentBridge: tools: { ...realtimeDevTools, ... }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
With any other library, or your own subscribe layer, wrap each listener:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { createRealtimeTap } from '@avasapp/agent-bridge/realtime'
|
|
171
|
+
|
|
172
|
+
export const realtimeTap = createRealtimeTap() // tools: { ...realtimeTap.tools }
|
|
173
|
+
|
|
174
|
+
export function subscribe(channel: string, onMessage: (message: Message) => void) {
|
|
175
|
+
const { listener, unsubscribe } = realtimeTap.wrap(channel, onMessage)
|
|
176
|
+
const off = client.subscribe(channel, listener)
|
|
177
|
+
return () => { off(); unsubscribe() }
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```sh
|
|
182
|
+
npx agent-bridge call realtime.channels
|
|
183
|
+
npx agent-bridge call realtime.mute '"order-42"' # the real feed goes quiet
|
|
184
|
+
npx agent-bridge call realtime.emit '["order-42", {"name": "status", "data": {"status": "arrived"}}]'
|
|
185
|
+
npx agent-bridge call realtime.log '{"channel": "order-42"}'
|
|
186
|
+
npx agent-bridge call realtime.connection '"disconnected"' # null goes back
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- `realtime.emit` runs the app's own handlers. Ably gets an Ably message (`{ id, name, data, timestamp }` plus what you pass), socket.io gets the arguments after the event name (`["chat", "a", "b"]` calls `listener("a", "b")`), and `createRealtimeTap` gets the value as is, or what its `toMessage` option builds.
|
|
190
|
+
- `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
|
|
191
|
+
- `realtime.connection` fakes a state through the client's own events (Ably's `connection.on` and `connection.state`, socket.io's `connect` / `disconnect`) and drops real messages until the state is `connected` again. Ably channel states don't follow. `socket.connected` keeps its real value: socket.io reads it itself, and faking it would hold real traffic back until the next reconnect.
|
|
192
|
+
- `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
|
|
193
|
+
- `createRealtimeTap` takes `describe` (what to log), `toMessage` (what `emit` delivers), `channelInfo` (extras for `realtime.channels`), `connection` (to add `realtime.connection`) and `namespace` (for a second tap).
|
|
194
|
+
- socket.io `onAny` listeners don't get injected events.
|
|
195
|
+
|
|
196
|
+
### Writing an adapter
|
|
197
|
+
|
|
198
|
+
To patch a client instead of wrapping each listener, name its add and remove methods and say which channel a call is for. `tapListeners` handles the bookkeeping and every remove form (all, by event, by listener). `realtimeAdapter` patches each client once. A Pusher-style `channel.bind(event, fn)` is this much:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { createRealtimeTap, realtimeAdapter, tapListeners } from '@avasapp/agent-bridge/realtime'
|
|
202
|
+
|
|
203
|
+
export const pusherTools = realtimeAdapter((channel: Channel, options: {}) => {
|
|
204
|
+
const tap = createRealtimeTap()
|
|
205
|
+
tapListeners(tap, channel, {
|
|
206
|
+
add: 'bind',
|
|
207
|
+
remove: 'unbind',
|
|
208
|
+
channel: ([event]) => `${channel.name}:${event}`, // undefined leaves a call alone
|
|
209
|
+
})
|
|
210
|
+
return tap
|
|
211
|
+
})
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Pass `accepts` when a listener filters by event, and `message: 'args'` when the message is every argument (as in socket.io). `fakeableConnection(client, { property, events, states, announce })` adds `realtime.connection`: it fakes the state property and silences the client's own change events while a fake is on. Pass `fakeProperty: false` if the client reads that property itself to decide whether to deliver or send. [`src/adapters/`](src/adapters) has the Ably and socket.io adapters built this way.
|
|
215
|
+
|
|
129
216
|
## Transports
|
|
130
217
|
|
|
131
218
|
| | Expo dev-tools socket | CDP |
|
|
@@ -149,6 +236,7 @@ npx agent-bridge assert-absent path/to/main.jsbundle
|
|
|
149
236
|
- **Run the agent on the machine with the simulator.** Every call pays the network otherwise.
|
|
150
237
|
- **Hidden tabs stay mounted.** `screen.findText` skips anything under an inactive `RNSScreen`.
|
|
151
238
|
- **Expo checks the debugger's Origin** against the host Metro advertises and drops mismatches silently. The client reads it from the manifest.
|
|
239
|
+
- **Expo Go on Android has no CDP `Runtime.evaluate`.** Use the Expo socket there (the default); CDP works in dev builds and in Expo Go on iOS.
|
|
152
240
|
- **Expo's socket broadcasts to every app.** Calls are addressed to one device; pick it with `--device` when several are connected.
|
|
153
241
|
- **Screen checks through the accessibility tree are slow** (hundreds of ms each). Check in-app, and keep one real UI check per flow.
|
|
154
242
|
- **`screen.fill` skips the keyboard.** It runs the input's handlers, so validation and state are real, but autocorrect, native `maxLength` and uncontrolled inputs' native text are not.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/adapters/ably.ts
|
|
21
|
+
var ably_exports = {};
|
|
22
|
+
__export(ably_exports, {
|
|
23
|
+
ablyTools: () => ablyTools
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(ably_exports);
|
|
26
|
+
|
|
27
|
+
// src/realtime/patch.ts
|
|
28
|
+
var list = (names) => Array.isArray(names) ? names : [names];
|
|
29
|
+
var sameKey = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
30
|
+
function split(args) {
|
|
31
|
+
const last = args[args.length - 1];
|
|
32
|
+
return typeof last === "function" ? { key: args.slice(0, -1), listener: last } : { key: args };
|
|
33
|
+
}
|
|
34
|
+
function tapListeners(tap, target, options) {
|
|
35
|
+
const methods = target;
|
|
36
|
+
const tracked = [];
|
|
37
|
+
const spread = options.message === "args";
|
|
38
|
+
for (const name of list(options.add)) {
|
|
39
|
+
const add = methods[name];
|
|
40
|
+
if (typeof add !== "function") continue;
|
|
41
|
+
methods[name] = function(...args) {
|
|
42
|
+
const { key, listener: original } = split(args);
|
|
43
|
+
const channel = original && options.channel(key);
|
|
44
|
+
if (!original || channel === void 0) return add.apply(this, args);
|
|
45
|
+
const accepts2 = options.accepts;
|
|
46
|
+
const { listener, unsubscribe } = tap.wrap(
|
|
47
|
+
channel,
|
|
48
|
+
(message, ...rest) => spread ? original.apply(target, message) : original.call(target, message, ...rest),
|
|
49
|
+
accepts2 ? { accepts: (m) => accepts2(key, m) } : {}
|
|
50
|
+
);
|
|
51
|
+
const wrapped = spread ? (...received) => listener(received) : (message, ...rest) => listener(message, ...rest);
|
|
52
|
+
tracked.push({ key, original, wrapped, unsubscribe });
|
|
53
|
+
return add.apply(this, [...key, wrapped]);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
for (const name of list(options.remove)) {
|
|
57
|
+
const remove = methods[name];
|
|
58
|
+
if (typeof remove !== "function") continue;
|
|
59
|
+
methods[name] = function(...args) {
|
|
60
|
+
const { key, listener } = split(args);
|
|
61
|
+
const hits = tracked.filter(
|
|
62
|
+
(t) => (!listener || t.original === listener || t.original.fn === listener) && (!key.length || sameKey(t.key, key))
|
|
63
|
+
);
|
|
64
|
+
for (const hit of hits) {
|
|
65
|
+
tracked.splice(tracked.indexOf(hit), 1);
|
|
66
|
+
hit.unsubscribe();
|
|
67
|
+
}
|
|
68
|
+
if (!listener || !hits.length) return remove.apply(this, args);
|
|
69
|
+
let result;
|
|
70
|
+
for (const hit of hits) result = remove.apply(this, [...hit.key, hit.wrapped]);
|
|
71
|
+
return result;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function fakeableConnection(target, options) {
|
|
76
|
+
const methods = target;
|
|
77
|
+
const events = methods?.[options.events];
|
|
78
|
+
if (!methods || typeof events !== "function") return void 0;
|
|
79
|
+
const read = options.read ?? String;
|
|
80
|
+
const write = options.write ?? ((state) => state);
|
|
81
|
+
const values = methods;
|
|
82
|
+
let fake = null;
|
|
83
|
+
let real = values[options.property];
|
|
84
|
+
const readReal = () => read(options.fakeProperty === false ? values[options.property] : real);
|
|
85
|
+
if (options.fakeProperty !== false)
|
|
86
|
+
Object.defineProperty(target, options.property, {
|
|
87
|
+
configurable: true,
|
|
88
|
+
enumerable: true,
|
|
89
|
+
get: () => fake === null ? real : write(fake),
|
|
90
|
+
set: (value) => {
|
|
91
|
+
real = value;
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
methods[options.events] = function(...args) {
|
|
95
|
+
return fake === null ? events.apply(this, args) : void 0;
|
|
96
|
+
};
|
|
97
|
+
const emit = (...args) => void events.apply(target, args);
|
|
98
|
+
return {
|
|
99
|
+
state: readReal,
|
|
100
|
+
states: options.states,
|
|
101
|
+
live: options.live,
|
|
102
|
+
fake: (state, previous) => {
|
|
103
|
+
fake = state;
|
|
104
|
+
const current = state ?? readReal();
|
|
105
|
+
if (current !== previous)
|
|
106
|
+
options.announce(emit, { current, previous, faked: state !== null });
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function realtimeAdapter(install) {
|
|
111
|
+
const taps = /* @__PURE__ */ new WeakMap();
|
|
112
|
+
return (client, options = {}) => {
|
|
113
|
+
let tools = taps.get(client);
|
|
114
|
+
if (!tools) taps.set(client, tools = install(client, options).tools);
|
|
115
|
+
return tools;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/realtime/tap.ts
|
|
120
|
+
var LOG_SIZE = 50;
|
|
121
|
+
var ALL = "*";
|
|
122
|
+
var defaultDescribe = (message) => {
|
|
123
|
+
const id = message?.id;
|
|
124
|
+
return typeof id === "string" ? { data: message, id } : { data: message };
|
|
125
|
+
};
|
|
126
|
+
var sameMessage = (a, b) => a === b || Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]);
|
|
127
|
+
function createRealtimeTap(options = {}) {
|
|
128
|
+
const ns = options.namespace ?? "realtime";
|
|
129
|
+
const describe = options.describe ?? defaultDescribe;
|
|
130
|
+
const toMessage2 = options.toMessage ?? ((values) => values[0]);
|
|
131
|
+
const connection = options.connection;
|
|
132
|
+
const live = connection?.live ?? "connected";
|
|
133
|
+
const channels = /* @__PURE__ */ new Map();
|
|
134
|
+
const mutes = /* @__PURE__ */ new Map();
|
|
135
|
+
const log = [];
|
|
136
|
+
const last = /* @__PURE__ */ new Map();
|
|
137
|
+
let injectedCount = 0;
|
|
138
|
+
let faked = null;
|
|
139
|
+
const muteFor = (channel) => mutes.get(channel) ?? mutes.get(ALL);
|
|
140
|
+
const liveChannels = () => [...channels.keys()].join(", ") || "none";
|
|
141
|
+
function record(channel, message, injected, dropped) {
|
|
142
|
+
log.push({
|
|
143
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
144
|
+
channel,
|
|
145
|
+
...describe(message),
|
|
146
|
+
injected,
|
|
147
|
+
...dropped && { dropped }
|
|
148
|
+
});
|
|
149
|
+
if (log.length > LOG_SIZE) log.splice(0, log.length - LOG_SIZE);
|
|
150
|
+
}
|
|
151
|
+
function receive(channel, entry, message, rest) {
|
|
152
|
+
const previous = last.get(channel);
|
|
153
|
+
const dropped = muteFor(channel) ? "muted" : faked !== null && faked !== live ? "connection" : void 0;
|
|
154
|
+
if (previous && !previous.seen.has(entry) && sameMessage(previous.message, message)) {
|
|
155
|
+
previous.seen.add(entry);
|
|
156
|
+
} else {
|
|
157
|
+
last.set(channel, { message, seen: /* @__PURE__ */ new Set([entry]) });
|
|
158
|
+
record(channel, message, false, dropped);
|
|
159
|
+
}
|
|
160
|
+
if (!dropped) entry.deliver(message, ...rest);
|
|
161
|
+
}
|
|
162
|
+
const wrap = (channel, listener, wrapOptions = {}) => {
|
|
163
|
+
const entry = {
|
|
164
|
+
deliver: listener,
|
|
165
|
+
accepts: wrapOptions.accepts
|
|
166
|
+
};
|
|
167
|
+
let entries = channels.get(channel);
|
|
168
|
+
if (!entries) channels.set(channel, entries = /* @__PURE__ */ new Set());
|
|
169
|
+
entries.add(entry);
|
|
170
|
+
return {
|
|
171
|
+
listener: (message, ...rest) => receive(channel, entry, message, rest),
|
|
172
|
+
unsubscribe: () => {
|
|
173
|
+
const current = channels.get(channel);
|
|
174
|
+
if (!current?.delete(entry) || current.size) return;
|
|
175
|
+
channels.delete(channel);
|
|
176
|
+
last.delete(channel);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
function setMute(channel, mute) {
|
|
181
|
+
if (typeof channel !== "string" || !channel)
|
|
182
|
+
throw new Error(`Pass a channel name, or "${ALL}" for every channel`);
|
|
183
|
+
if (mute) mutes.set(channel, mute);
|
|
184
|
+
else if (channel === ALL) mutes.clear();
|
|
185
|
+
else mutes.delete(channel);
|
|
186
|
+
return { muted: [...mutes.keys()] };
|
|
187
|
+
}
|
|
188
|
+
function setConnection(conn, state) {
|
|
189
|
+
const shown = faked ?? conn.state();
|
|
190
|
+
if (state !== null && typeof state !== "string")
|
|
191
|
+
throw new Error('Pass a state such as "disconnected", or null for the real one');
|
|
192
|
+
if (state !== null && conn.states && !conn.states.includes(state))
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Unknown state "${state}". Known: ${conn.states.join(", ")}`
|
|
195
|
+
);
|
|
196
|
+
if (state === faked) return;
|
|
197
|
+
faked = state;
|
|
198
|
+
conn.fake(state, shown);
|
|
199
|
+
}
|
|
200
|
+
const tools = {
|
|
201
|
+
[`${ns}.channels`]: {
|
|
202
|
+
description: "Channels the app listens on: listener count, muted, and what the library says about each.",
|
|
203
|
+
run: () => [...channels].map(([name, entries]) => ({
|
|
204
|
+
name,
|
|
205
|
+
listeners: entries.size,
|
|
206
|
+
muted: Boolean(muteFor(name)),
|
|
207
|
+
...options.channelInfo?.(name)
|
|
208
|
+
}))
|
|
209
|
+
},
|
|
210
|
+
[`${ns}.log`]: {
|
|
211
|
+
description: `Recent messages, real and injected, newest first (last ${LOG_SIZE}). Filter: { channel?, event?, limit? }; clear: true empties the log after reading.`,
|
|
212
|
+
run: (filter = {}) => {
|
|
213
|
+
const entries = log.filter(
|
|
214
|
+
(e) => (!filter.channel || e.channel === filter.channel) && (!filter.event || e.event === filter.event)
|
|
215
|
+
).reverse().slice(0, filter.limit ?? 20);
|
|
216
|
+
if (filter.clear) log.length = 0;
|
|
217
|
+
return entries;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[`${ns}.emit`]: {
|
|
221
|
+
description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }.",
|
|
222
|
+
run: (channel, ...values) => {
|
|
223
|
+
const entries = channels.get(channel);
|
|
224
|
+
if (!entries?.size)
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Nothing listens on "${channel}". Live channels: ${liveChannels()}`
|
|
227
|
+
);
|
|
228
|
+
injectedCount += 1;
|
|
229
|
+
const message = toMessage2(values, {
|
|
230
|
+
channel,
|
|
231
|
+
id: `agent-bridge-${injectedCount}`
|
|
232
|
+
});
|
|
233
|
+
const id = describe(message).id;
|
|
234
|
+
if (muteFor(channel)?.dropInjected) {
|
|
235
|
+
record(channel, message, true, "muted");
|
|
236
|
+
return { id, delivered: 0 };
|
|
237
|
+
}
|
|
238
|
+
record(channel, message, true);
|
|
239
|
+
let delivered = 0;
|
|
240
|
+
for (const entry of [...entries]) {
|
|
241
|
+
if (entry.accepts && !entry.accepts(message)) continue;
|
|
242
|
+
entry.deliver(message);
|
|
243
|
+
delivered += 1;
|
|
244
|
+
}
|
|
245
|
+
return { id, delivered };
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
[`${ns}.mute`]: {
|
|
249
|
+
description: `Drop real messages on a channel ("${ALL}" for all) until unmuted. Injected messages still get through unless { dropInjected: true }.`,
|
|
250
|
+
run: (channel, muteOptions = {}) => setMute(channel, { dropInjected: Boolean(muteOptions.dropInjected) })
|
|
251
|
+
},
|
|
252
|
+
[`${ns}.unmute`]: {
|
|
253
|
+
description: `Stop dropping messages on a channel ("${ALL}" for all).`,
|
|
254
|
+
run: (channel) => setMute(channel, null)
|
|
255
|
+
},
|
|
256
|
+
[`${ns}.restore`]: {
|
|
257
|
+
description: "Undo the agent: unmute every channel and go back to the real connection state.",
|
|
258
|
+
run: () => {
|
|
259
|
+
const unmuted = [...mutes.keys()];
|
|
260
|
+
mutes.clear();
|
|
261
|
+
const connectionFaked = faked !== null;
|
|
262
|
+
if (connection && connectionFaked) setConnection(connection, null);
|
|
263
|
+
return { unmuted, connectionFaked };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
if (connection)
|
|
268
|
+
tools[`${ns}.connection`] = {
|
|
269
|
+
description: `The connection state, or fake one: [state] shows the app that state${connection.states ? ` (${connection.states.join(", ")})` : ""} and drops real messages unless it is "${live}"; [null] goes back to the real one.`,
|
|
270
|
+
run: (...args) => {
|
|
271
|
+
if (args.length) setConnection(connection, args[0] ?? null);
|
|
272
|
+
return { state: faked ?? connection.state(), real: connection.state(), faked: faked !== null };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
return { wrap, tools };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/adapters/ably.ts
|
|
279
|
+
var CONNECTION_STATES = [
|
|
280
|
+
"initialized",
|
|
281
|
+
"connecting",
|
|
282
|
+
"connected",
|
|
283
|
+
"disconnected",
|
|
284
|
+
"suspended",
|
|
285
|
+
"closing",
|
|
286
|
+
"closed",
|
|
287
|
+
"failed"
|
|
288
|
+
];
|
|
289
|
+
function accepts(filter, message) {
|
|
290
|
+
if (typeof filter === "string") return message.name === filter;
|
|
291
|
+
if (Array.isArray(filter)) return filter.includes(message.name);
|
|
292
|
+
if (filter && typeof filter === "object") {
|
|
293
|
+
const { name, clientId } = filter;
|
|
294
|
+
return (name === void 0 || name === message.name) && (clientId === void 0 || clientId === message.clientId);
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
function toMessage(values, { id }) {
|
|
299
|
+
const [input] = values;
|
|
300
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
301
|
+
throw new Error('Pass the message as { name, data }, e.g. ["chat", { "name": "typing", "data": { "user": "sam" } }]');
|
|
302
|
+
return { id, timestamp: Date.now(), ...input };
|
|
303
|
+
}
|
|
304
|
+
var ablyTools = realtimeAdapter(
|
|
305
|
+
(client, options) => {
|
|
306
|
+
const channels = /* @__PURE__ */ new Map();
|
|
307
|
+
const tap = createRealtimeTap({
|
|
308
|
+
namespace: options.namespace,
|
|
309
|
+
describe: (m) => ({ event: m.name, data: m.data, id: m.id }),
|
|
310
|
+
toMessage,
|
|
311
|
+
channelInfo: (name) => ({ state: channels.get(name)?.state }),
|
|
312
|
+
// ably-js's connection is an event emitter; `emit` isn't in its public types.
|
|
313
|
+
connection: fakeableConnection(client.connection, {
|
|
314
|
+
property: "state",
|
|
315
|
+
events: "emit",
|
|
316
|
+
states: CONNECTION_STATES,
|
|
317
|
+
announce: (emit, { current, previous, faked }) => emit(current, {
|
|
318
|
+
previous,
|
|
319
|
+
current,
|
|
320
|
+
event: current,
|
|
321
|
+
...faked && { reason: { message: "Faked by agent-bridge" } }
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
});
|
|
325
|
+
const patch = (channel) => {
|
|
326
|
+
if (!channel || channels.get(channel.name) === channel) return channel;
|
|
327
|
+
channels.set(channel.name, channel);
|
|
328
|
+
tapListeners(tap, channel, {
|
|
329
|
+
add: "subscribe",
|
|
330
|
+
remove: "unsubscribe",
|
|
331
|
+
channel: () => channel.name,
|
|
332
|
+
accepts: ([filter], message) => accepts(filter, message)
|
|
333
|
+
});
|
|
334
|
+
return channel;
|
|
335
|
+
};
|
|
336
|
+
const registry = client.channels;
|
|
337
|
+
const get = registry.get;
|
|
338
|
+
registry.get = function(...args) {
|
|
339
|
+
return patch(get.apply(this, args));
|
|
340
|
+
};
|
|
341
|
+
for (const channel of Object.values(registry.all ?? {})) patch(channel);
|
|
342
|
+
return tap;
|
|
343
|
+
}
|
|
344
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { T as Tools } from '../types-C6DUUHnB.cjs';
|
|
2
|
+
|
|
3
|
+
/** The fields of an Ably message this adapter reads. */
|
|
4
|
+
type AblyMessageLike = {
|
|
5
|
+
id?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
data?: unknown;
|
|
8
|
+
clientId?: string;
|
|
9
|
+
};
|
|
10
|
+
/** The Ably realtime channel surface this adapter uses. */
|
|
11
|
+
type AblyChannelLike = {
|
|
12
|
+
name: string;
|
|
13
|
+
state?: string;
|
|
14
|
+
subscribe(...args: never[]): unknown;
|
|
15
|
+
unsubscribe(...args: never[]): unknown;
|
|
16
|
+
};
|
|
17
|
+
/** An Ably realtime client (`new Ably.Realtime(...)`), typed structurally. */
|
|
18
|
+
type AblyRealtimeLike = {
|
|
19
|
+
channels: {
|
|
20
|
+
get(name: string, ...rest: never[]): AblyChannelLike;
|
|
21
|
+
};
|
|
22
|
+
connection?: {
|
|
23
|
+
state: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
type AblyToolsOptions = {
|
|
27
|
+
/** Tool name prefix. Defaults to "realtime". */
|
|
28
|
+
namespace?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* See, fake and drop Ably messages: `realtime.*`. Patches the client, so call
|
|
32
|
+
* it where you create the client, before the app subscribes; listeners added
|
|
33
|
+
* earlier stay invisible. Calling it again with the same client is free.
|
|
34
|
+
*/
|
|
35
|
+
declare const ablyTools: (client: AblyRealtimeLike, options?: AblyToolsOptions | undefined) => Tools;
|
|
36
|
+
|
|
37
|
+
export { type AblyChannelLike, type AblyMessageLike, type AblyRealtimeLike, type AblyToolsOptions, ablyTools };
|