@avasapp/agent-bridge 0.3.0 → 0.4.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.
@@ -0,0 +1,79 @@
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
+
3
+ /** What the adapter needs from a react-native-webview instance. */
4
+ type WebViewLike = {
5
+ injectJavaScript: (script: string) => void;
6
+ postMessage: (data: string) => void;
7
+ reload: () => void;
8
+ };
9
+ /**
10
+ * Keeps secrets out of the message log. Either dotted paths into JSON message
11
+ * bodies (`['token', 'user.phone']`), or a hook called for the whole body
12
+ * (`path` is `''`) and every nested value of a JSON body with its dotted path;
13
+ * what it returns is logged instead. Redaction is by key: a secret inside a
14
+ * string value is not found by a path list; scrub values in the hook. A body that isn't JSON (a script from
15
+ * `injectJavaScript`, plain text) can only be redacted by the hook.
16
+ */
17
+ type WebViewRedact = string[] | ((webview: string, path: string, value: unknown) => unknown);
18
+ type WebViewOptions = {
19
+ /** What agents call this WebView: `webview.press`'s `webview` option, `webview.snapshot <name>`. */
20
+ name: string;
21
+ /** Origins besides the page the WebView first loads that may be read and driven, e.g. `https://pay.example.com`. */
22
+ allowedOrigins?: string[];
23
+ redact?: WebViewRedact;
24
+ /** Your own `injectedJavaScriptBeforeContentLoaded`; it runs after the adapter's. */
25
+ injectedJavaScriptBeforeContentLoaded?: string;
26
+ /** The adapter listens to these; pass yours here so spreading `props` doesn't replace them. */
27
+ onLoadStart?: (event: NativeEvent) => void;
28
+ onLoadEnd?: (event: NativeEvent) => void;
29
+ onNavigationStateChange?: (event: NativeNavState) => void;
30
+ /** How long a call waits for the page to load and its script to check in (default 10 000 ms). */
31
+ firstLoadTimeoutMs?: number;
32
+ };
33
+ /** What react-native-webview tells the app, from native code, not from the page. */
34
+ type NativeEvent = {
35
+ nativeEvent?: {
36
+ url?: string;
37
+ isTopFrame?: boolean;
38
+ };
39
+ };
40
+ type NativeNavState = {
41
+ url?: string;
42
+ loading?: boolean;
43
+ };
44
+
45
+ /**
46
+ * Registers a react-native-webview so agents can read and drive it. Spread
47
+ * `props` on the WebView and give it `wrap(onMessage)` as its `onMessage`:
48
+ *
49
+ * ```tsx
50
+ * const ref = useRef<WebView>(null)
51
+ * const webview = useWebViewTools(ref, { name: 'checkout' })
52
+ * <WebView ref={ref} {...webview.props} onMessage={webview.wrap(onMessage)} />
53
+ * ```
54
+ *
55
+ * The bridge's own replies never reach `onMessage`. Add `webviewTools()` to the
56
+ * bridge's tools.
57
+ */
58
+ declare function useWebViewTools(ref: {
59
+ current: WebViewLike | null;
60
+ }, options: WebViewOptions): {
61
+ props: {
62
+ injectedJavaScriptBeforeContentLoaded: string;
63
+ injectedJavaScriptBeforeContentLoadedForMainFrameOnly: true;
64
+ onLoadStart: (event: NativeEvent) => void;
65
+ onLoadEnd: (event: NativeEvent) => void;
66
+ onNavigationStateChange: (state: NativeNavState) => void;
67
+ };
68
+ wrap: (onMessage?: (event: never) => unknown) => (event: {
69
+ nativeEvent?: {
70
+ data?: unknown;
71
+ url?: string;
72
+ isTopFrame?: boolean;
73
+ };
74
+ }) => unknown;
75
+ };
76
+ /** Read and drive the WebViews registered with `useWebViewTools`. */
77
+ declare function webviewTools(): Tools;
78
+
79
+ export { type WebViewLike, type WebViewOptions, type WebViewRedact, useWebViewTools, webviewTools };
@@ -218,7 +218,7 @@ function createRealtimeTap(options = {}) {
218
218
  }
219
219
  },
220
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 }.",
221
+ description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }." + (options.emitUsage ? ` ${options.emitUsage}` : ""),
222
222
  run: (channel, ...values) => {
223
223
  const entries = channels.get(channel);
224
224
  if (!entries?.size)
@@ -254,6 +254,7 @@ function createRealtimeTap(options = {}) {
254
254
  run: (channel) => setMute(channel, null)
255
255
  },
256
256
  [`${ns}.restore`]: {
257
+ pending: () => mutes.size > 0 || faked !== null,
257
258
  description: "Undo the agent: unmute every channel and go back to the real connection state.",
258
259
  run: () => {
259
260
  const unmuted = [...mutes.keys()];
@@ -294,6 +295,7 @@ var socketIoTools = realtimeAdapter(
294
295
  return { data: values.length === 1 ? values[0] : values };
295
296
  },
296
297
  toMessage: (values) => values,
298
+ emitUsage: `socket.io: the channel is the event name and the rest are the listener's arguments, so ["chat", "a", "b"] calls listener("a", "b"). Do not repeat the event name in the arguments.`,
297
299
  // socket.io-client announces lifecycle events with emitReserved.
298
300
  connection: fakeableConnection(socket, {
299
301
  property: "connected",
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-6kKQsLC1.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  type Listener = (...args: any[]) => void;
4
4
  /** A socket.io client socket (`io(url)`), typed structurally. */
@@ -23,6 +23,151 @@ __export(tanstack_query_exports, {
23
23
  queryTools: () => queryTools
24
24
  });
25
25
  module.exports = __toCommonJS(tanstack_query_exports);
26
+ var import_query_core = require("@tanstack/query-core");
27
+
28
+ // src/shared/mock-signal.ts
29
+ var KEY = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/mock-signal");
30
+ var listeners = () => {
31
+ const g = globalThis;
32
+ return g[KEY] ?? (g[KEY] = /* @__PURE__ */ new Set());
33
+ };
34
+ function onMockSignal(listener) {
35
+ const all = listeners();
36
+ all.add(listener);
37
+ return () => {
38
+ all.delete(listener);
39
+ };
40
+ }
41
+
42
+ // src/adapters/query-mocked.ts
43
+ var KEY2 = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/query-mocked");
44
+ var trackers = globalThis[KEY2] ?? (globalThis[KEY2] = /* @__PURE__ */ new WeakMap());
45
+ var isInfinite = (query) => query.queryType === "infinite" || !!query.options.behavior;
46
+ function trackMockedQueries(queryClient, isPinned) {
47
+ const existing = trackers.get(queryClient);
48
+ if (existing) {
49
+ existing.isPinned = isPinned;
50
+ return existing;
51
+ }
52
+ const cache = queryClient.getQueryCache();
53
+ const mocked = /* @__PURE__ */ new Map();
54
+ const inflight = /* @__PURE__ */ new Map();
55
+ const instrumented = /* @__PURE__ */ new WeakSet();
56
+ const holder = { current: null };
57
+ let cleared = 0;
58
+ const newFetch = () => ({
59
+ own: /* @__PURE__ */ new Set(),
60
+ ids: /* @__PURE__ */ new Set(),
61
+ loose: /* @__PURE__ */ new Set(),
62
+ fetchMore: false,
63
+ gone: false
64
+ });
65
+ const reset = (hash) => {
66
+ const query = cache.get(hash);
67
+ if (!query || tracker.isPinned(hash)) return;
68
+ cleared += 1;
69
+ queryClient.resetQueries({ queryKey: query.queryKey, exact: true }).catch(() => {
70
+ });
71
+ };
72
+ const instrument = (query) => {
73
+ if (instrumented.has(query)) return;
74
+ instrumented.add(query);
75
+ const original = query.fetch.bind(query);
76
+ query.fetch = ((...args) => {
77
+ let f = inflight.get(query.queryHash);
78
+ if (!f) inflight.set(query.queryHash, f = newFetch());
79
+ const outer = holder.current;
80
+ holder.current = f;
81
+ try {
82
+ return original(...args);
83
+ } finally {
84
+ holder.current = outer;
85
+ }
86
+ });
87
+ };
88
+ for (const query of cache.getAll()) instrument(query);
89
+ const build = cache.build.bind(cache);
90
+ cache.build = ((...args) => {
91
+ const query = build(...args);
92
+ instrument(query);
93
+ return query;
94
+ });
95
+ cache.subscribe((event) => {
96
+ if (event.type !== "updated") return;
97
+ const { query, action } = event;
98
+ const hash = query.queryHash;
99
+ if (action.type === "fetch") {
100
+ let f = inflight.get(hash);
101
+ if (!f) inflight.set(hash, f = newFetch());
102
+ if (action.meta?.fetchMore) f.fetchMore = true;
103
+ } else if (action.type === "success") {
104
+ if (action.manual) {
105
+ if (!inflight.has(hash)) mocked.delete(hash);
106
+ return;
107
+ }
108
+ const f = inflight.get(hash);
109
+ inflight.delete(hash);
110
+ if (!f) return;
111
+ const used = answeredBy(f);
112
+ const before = mocked.get(hash);
113
+ if (used.size) {
114
+ if (f.gone) {
115
+ mocked.delete(hash);
116
+ reset(hash);
117
+ } else mocked.set(hash, /* @__PURE__ */ new Set([...before ?? [], ...used]));
118
+ } else if (before && !f.fetchMore && !isInfinite(query)) {
119
+ mocked.delete(hash);
120
+ }
121
+ } else if (query.state.fetchStatus === "idle") {
122
+ inflight.delete(hash);
123
+ }
124
+ });
125
+ const tracker = {
126
+ isPinned,
127
+ pending: () => mocked.size > 0,
128
+ takeCleared: () => {
129
+ const n = cleared;
130
+ cleared = 0;
131
+ return n;
132
+ },
133
+ clear: (ids) => {
134
+ const before = cleared;
135
+ for (const [hash, from] of [...mocked]) {
136
+ if (ids && !ids.some((id) => from.has(id))) continue;
137
+ mocked.delete(hash);
138
+ reset(hash);
139
+ }
140
+ return cleared - before;
141
+ }
142
+ };
143
+ trackers.set(queryClient, tracker);
144
+ listen(new WeakRef(tracker), inflight, holder);
145
+ return tracker;
146
+ }
147
+ var answeredBy = (f) => /* @__PURE__ */ new Set([...f.ids, ...f.loose]);
148
+ function listen(ref, inflight, holder) {
149
+ const stop = onMockSignal({
150
+ started: (requestId) => {
151
+ if (!ref.deref()) return stop();
152
+ holder.current?.own.add(requestId);
153
+ },
154
+ answered: (mockId, requestId) => {
155
+ if (!ref.deref()) return stop();
156
+ const owner = [...inflight.values()].find((f) => f.own.has(requestId));
157
+ if (owner) owner.ids.add(mockId);
158
+ else for (const f of inflight.values()) f.loose.add(mockId);
159
+ },
160
+ removed: (ids) => {
161
+ const tracker = ref.deref();
162
+ if (!tracker) return stop();
163
+ for (const f of inflight.values())
164
+ if (!ids || [...answeredBy(f)].some((id) => ids.includes(id))) f.gone = true;
165
+ tracker.clear(ids);
166
+ }
167
+ });
168
+ }
169
+
170
+ // src/adapters/tanstack-query.ts
26
171
  var pinStates = /* @__PURE__ */ new WeakMap();
27
172
  function pinStateFor(queryClient) {
28
173
  let state = pinStates.get(queryClient);
@@ -39,11 +184,56 @@ function pinStateFor(queryClient) {
39
184
  return state;
40
185
  }
41
186
  var rendered = () => new Promise((resolve) => setTimeout(resolve, 0));
187
+ var keyOf = (args) => args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
188
+ var isGetOptions = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0 && Object.keys(v).every((k) => k === "pages" || k === "path");
189
+ function slicePages(data, range) {
190
+ const infinite = data;
191
+ if (!infinite || !Array.isArray(infinite.pages))
192
+ throw new Error("pages only applies to an infinite query (data with a pages array)");
193
+ const [from, to] = Array.isArray(range) ? range : [range];
194
+ if (typeof from !== "number" || to !== void 0 && typeof to !== "number")
195
+ throw new Error(
196
+ `pages is [from, to] (to exclusive, like Array.slice) or a start index, got ${JSON.stringify(range)}`
197
+ );
198
+ return {
199
+ ...infinite,
200
+ pages: infinite.pages.slice(from, to),
201
+ ...Array.isArray(infinite.pageParams) && {
202
+ pageParams: infinite.pageParams.slice(from, to)
203
+ },
204
+ totalPages: infinite.pages.length
205
+ };
206
+ }
207
+ function atPath(value, path) {
208
+ const segments = typeof path === "string" ? path.split(".").filter(Boolean) : Array.isArray(path) && path.every((p) => typeof p === "string" || typeof p === "number") ? path : null;
209
+ if (!segments)
210
+ throw new Error(
211
+ `path is a dotted string or an array of segments, got ${JSON.stringify(path)}`
212
+ );
213
+ let current = value;
214
+ segments.forEach((segment, i) => {
215
+ if (current === null || typeof current !== "object" || !(String(segment) in current)) {
216
+ const where = segments.slice(0, i).join(".") || "the data";
217
+ const has = current && typeof current === "object" ? Array.isArray(current) ? `an array of ${current.length}` : `keys ${Object.keys(current).slice(0, 10).join(", ") || "(none)"}` : JSON.stringify(current) ?? "undefined";
218
+ throw new Error(`No "${segment}" in ${where}: it has ${has}`);
219
+ }
220
+ current = current[segment];
221
+ });
222
+ return current;
223
+ }
42
224
  function queryTools(queryClient) {
43
225
  const cache = queryClient.getQueryCache();
44
226
  const state = pinStateFor(queryClient);
45
227
  const { pins, seeded } = state;
46
228
  const hashOf = (key) => cache.build(queryClient, { queryKey: key }).queryHash;
229
+ const existingHash = (key) => cache.find({ queryKey: key, exact: true })?.queryHash ?? (0, import_query_core.hashKey)(key);
230
+ const arrayKey = (tool, key) => {
231
+ if (!Array.isArray(key))
232
+ throw new Error(
233
+ `${tool} takes the key as an array, got ${JSON.stringify(key)}. Wrap it: [[${JSON.stringify(key)}], <data>]`
234
+ );
235
+ return key;
236
+ };
47
237
  const apply = (key, data) => {
48
238
  state.applying = true;
49
239
  try {
@@ -63,8 +253,8 @@ function queryTools(queryClient) {
63
253
  apply(pin.key, pin.data);
64
254
  }));
65
255
  };
66
- const unpin = (key) => {
67
- const removed = pins.delete(hashOf(key));
256
+ const unpinKey = (key, hash = existingHash(key)) => {
257
+ const removed = pins.delete(hash);
68
258
  if (!pins.size) {
69
259
  state.unsubscribe?.();
70
260
  state.unsubscribe = null;
@@ -73,6 +263,7 @@ function queryTools(queryClient) {
73
263
  void queryClient.invalidateQueries({ queryKey: key, exact: true });
74
264
  return removed;
75
265
  };
266
+ const unpin = (...args) => unpinKey(keyOf(args));
76
267
  const refetchReal = (key) => {
77
268
  const query = cache.find({ queryKey: key, exact: true });
78
269
  if (!query) return false;
@@ -88,23 +279,46 @@ function queryTools(queryClient) {
88
279
  });
89
280
  return true;
90
281
  };
282
+ const mocked = trackMockedQueries(queryClient, (hash) => pins.has(hash));
91
283
  return {
92
284
  "query.list": {
93
- description: "Cached queries: key, status, observer count, pinned.",
94
- run: () => cache.getAll().map((q) => ({
285
+ description: 'Cached queries: key, status, fetchStatus, observer count, pinned, isStale, isInvalidated, dataUpdatedAt, errorUpdatedAt. Optional key prefix filter, e.g. ("todos") or (["todos", 1]).',
286
+ run: (...args) => cache.findAll(args.length ? { queryKey: keyOf(args) } : {}).map((q) => ({
95
287
  key: q.queryKey,
96
288
  status: q.state.status,
289
+ fetchStatus: q.state.fetchStatus,
97
290
  observers: q.getObserversCount(),
98
- pinned: pins.has(q.queryHash)
291
+ pinned: pins.has(q.queryHash),
292
+ isStale: q.isStale(),
293
+ isInvalidated: q.state.isInvalidated,
294
+ dataUpdatedAt: q.state.dataUpdatedAt,
295
+ errorUpdatedAt: q.state.errorUpdatedAt
99
296
  }))
100
297
  },
101
298
  "query.get": {
102
- description: "Cached data for a query key.",
103
- run: (key) => queryClient.getQueryData(key)
299
+ description: 'Cached data for a query key, e.g. ("todos", 1) or (["todos", 1]). Errors when no such query is cached, listing keys that start the same. For a big one, a last { pages: [from, to] } returns only those pages of an infinite query (to exclusive, like Array.slice; adds totalPages), and { path: "pages.0.items" } returns only the value there (after pages, if both).',
300
+ run: (...args) => {
301
+ const last = args[args.length - 1];
302
+ const options = args.length > 1 && isGetOptions(last) && !cache.find({ queryKey: keyOf(args), exact: true }) ? last : void 0;
303
+ const key = keyOf(options ? args.slice(0, -1) : args);
304
+ if (!key.length) throw new Error("query.get needs a query key");
305
+ if (!cache.find({ queryKey: key, exact: true })) {
306
+ const near = cache.findAll({ queryKey: key.slice(0, 1) }).slice(0, 5).map((q) => JSON.stringify(q.queryKey));
307
+ throw new Error(
308
+ `No cached query with key ${JSON.stringify(key)}.${near.length ? ` Similar: ${near.join(", ")}` : " See query.list."}`
309
+ );
310
+ }
311
+ let data = queryClient.getQueryData(key);
312
+ if (options?.pages !== void 0) data = slicePages(data, options.pages);
313
+ if (options?.path !== void 0) data = atPath(data, options.path);
314
+ return data;
315
+ }
104
316
  },
105
317
  "query.set": {
318
+ maxArgs: 2,
106
319
  description: "Replace cached data once. A refetch will overwrite it; use query.pin to keep it.",
107
- run: async (key, data) => {
320
+ run: async (given, data) => {
321
+ const key = arrayKey("query.set", given);
108
322
  const hash = hashOf(key);
109
323
  if (!state.changed.has(hash)) state.changed.set(hash, key);
110
324
  queryClient.setQueryData(key, data);
@@ -113,8 +327,10 @@ function queryTools(queryClient) {
113
327
  }
114
328
  },
115
329
  "query.pin": {
330
+ maxArgs: 2,
116
331
  description: "Seed data for a query key and keep it through refetches until unpinned.",
117
- run: async (key, data) => {
332
+ run: async (given, data) => {
333
+ const key = arrayKey("query.pin", given);
118
334
  pins.set(hashOf(key), { key, data });
119
335
  watch();
120
336
  apply(key, data);
@@ -127,19 +343,40 @@ function queryTools(queryClient) {
127
343
  run: unpin
128
344
  },
129
345
  "query.unpinAll": {
346
+ maxArgs: 0,
130
347
  description: "Stop every pin and refetch real data.",
131
- run: () => [...pins.values()].map((p) => p.key).filter((key) => unpin(key)).length
348
+ run: () => [...pins].filter(([hash, { key }]) => unpinKey(key, hash)).length
132
349
  },
133
350
  "query.refetch": {
134
- description: "Refetch a query and return its data.",
135
- run: async (key) => {
351
+ description: 'Refetch the queries matching a key prefix, e.g. ("todos", 1) or (["todos", 1]). Returns { matched, refetched, data } (data of the exact key); errors when none match or none can be refetched (disabled, or no queryFn).',
352
+ run: async (...args) => {
353
+ const key = keyOf(args);
354
+ const found = cache.findAll({ queryKey: key });
355
+ if (!found.length)
356
+ throw new Error(
357
+ `No cached query matches key ${JSON.stringify(key)}. See query.list.`
358
+ );
359
+ const defaultFn = queryClient.getDefaultOptions().queries?.queryFn;
360
+ const refetched = found.filter(
361
+ (q) => !q.isDisabled() && (q.options.queryFn ?? defaultFn)
362
+ ).length;
363
+ if (!refetched)
364
+ throw new Error(
365
+ `${found.length} cached ${found.length === 1 ? "query matches" : "queries match"} key ${JSON.stringify(key)}, but none can be refetched (disabled, or no queryFn).`
366
+ );
136
367
  await queryClient.refetchQueries({ queryKey: key });
137
368
  await rendered();
138
- return queryClient.getQueryData(key);
369
+ return {
370
+ matched: found.length,
371
+ refetched,
372
+ data: queryClient.getQueryData(key)
373
+ };
139
374
  }
140
375
  },
141
376
  "query.restore": {
142
- description: "Undo the agent: unpin everything and refetch real data for keys changed with query.set.",
377
+ maxArgs: 0,
378
+ pending: () => pins.size > 0 || state.changed.size > 0 || mocked.pending(),
379
+ description: "Undo the agent: unpin everything, refetch real data for keys changed with query.set, and reset queries that fetched while a net.mock answered (net.unmock and net.restore do this too). Returns { unpinned, refetched, mockedCleared }.",
143
380
  run: async () => {
144
381
  const unpinned = [...pins.values()].map((p) => p.key);
145
382
  const changed = [...state.changed].filter(([hash]) => !pins.has(hash)).map(([, key]) => key);
@@ -149,13 +386,15 @@ function queryTools(queryClient) {
149
386
  state.unsubscribe = null;
150
387
  for (const key of unpinned) refetchReal(key);
151
388
  const refetched = changed.filter((key) => refetchReal(key)).length;
389
+ mocked.clear();
390
+ const mockedCleared = mocked.takeCleared();
152
391
  await rendered();
153
- return { unpinned: unpinned.length, refetched };
392
+ return { unpinned: unpinned.length, refetched, mockedCleared };
154
393
  }
155
394
  },
156
395
  "query.invalidate": {
157
- description: "Invalidate queries matching a key prefix.",
158
- run: (key) => queryClient.invalidateQueries({ queryKey: key })
396
+ description: 'Invalidate queries matching a key prefix, e.g. ("todos") or (["todos"]).',
397
+ run: (...args) => queryClient.invalidateQueries({ queryKey: keyOf(args) })
159
398
  }
160
399
  };
161
400
  }
@@ -1,5 +1,5 @@
1
1
  import { QueryClient } from '@tanstack/query-core';
2
- import { T as Tools } from '../types-6kKQsLC1.cjs';
2
+ import { T as Tools } from '../types-BmpykP1P.cjs';
3
3
 
4
4
  /**
5
5
  * Read and seed the TanStack Query cache. `query.pin` keeps a seed in place: