@avasapp/agent-bridge 0.2.0 → 0.3.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.
@@ -22,6 +22,7 @@ var runtime_exports = {};
22
22
  __export(runtime_exports, {
23
23
  RUNTIME_MARKER: () => RUNTIME_MARKER,
24
24
  cdpTransport: () => cdpTransport,
25
+ createGate: () => createGate,
25
26
  settle: () => settle,
26
27
  startAgentBridge: () => startAgentBridge,
27
28
  useAgentBridge: () => useAgentBridge
@@ -73,14 +74,15 @@ function logTools(logs) {
73
74
  // src/runtime/tools/restore.ts
74
75
  var SUFFIX = ".restore";
75
76
  var SELF = "bridge.restore";
77
+ var LAST = "scenario.restore";
76
78
  function restoreTools(getTools) {
77
79
  return {
78
80
  [SELF]: {
79
- description: "Undo what the agent changed: runs every *.restore tool in name order and returns each result or error.",
81
+ description: "Undo what the agent changed: runs every *.restore tool in name order, scenario.restore last, and returns each result or error.",
80
82
  run: async () => {
81
83
  const tools = getTools();
82
84
  const results = {};
83
- const names = Object.keys(tools).filter((name) => name.endsWith(SUFFIX) && name !== SELF).sort();
85
+ const names = Object.keys(tools).filter((name) => name.endsWith(SUFFIX) && name !== SELF).sort((a, b) => Number(a === LAST) - Number(b === LAST) || a.localeCompare(b));
84
86
  for (const name of names) {
85
87
  const definition = tools[name];
86
88
  const run = typeof definition === "function" ? definition : definition.run;
@@ -98,6 +100,159 @@ function restoreTools(getTools) {
98
100
  };
99
101
  }
100
102
 
103
+ // src/runtime/options-schema.ts
104
+ var import_json_schema = require("@cfworker/json-schema");
105
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
106
+ var CASCADE = /* @__PURE__ */ new Set([
107
+ "properties",
108
+ "patternProperties",
109
+ "additionalProperties",
110
+ "unevaluatedProperties",
111
+ "items",
112
+ "prefixItems",
113
+ "unevaluatedItems",
114
+ "allOf",
115
+ "if",
116
+ "then",
117
+ "else",
118
+ "dependentSchemas",
119
+ "$ref",
120
+ "false"
121
+ ]);
122
+ var path = (instanceLocation) => instanceLocation === "#" ? "options" : `options${instanceLocation.slice(1)}`;
123
+ function unknownKeys(schema, value, at, out) {
124
+ if (!isObject(schema)) return;
125
+ if (Array.isArray(value)) {
126
+ if (isObject(schema.items))
127
+ value.forEach((item, i) => unknownKeys(schema.items, item, `${at}/${i}`, out));
128
+ return;
129
+ }
130
+ if (!isObject(value) || !isObject(schema.properties)) return;
131
+ const props = schema.properties;
132
+ const open = schema.additionalProperties !== void 0 && schema.additionalProperties !== false || schema.patternProperties !== void 0 || // Composed schemas split their properties across branches; leave them to the validator.
133
+ ["allOf", "anyOf", "oneOf", "$ref"].some((k) => schema[k] !== void 0);
134
+ for (const [key, child] of Object.entries(value)) {
135
+ if (key in props) unknownKeys(props[key], child, `${at}/${key}`, out);
136
+ else if (!open)
137
+ out.push(`${path(at)}: unknown option "${key}". Known: ${Object.keys(props).join(", ") || "none"}`);
138
+ }
139
+ }
140
+ var validators = /* @__PURE__ */ new WeakMap();
141
+ function checkOptions(schema, options) {
142
+ let validator = validators.get(schema);
143
+ if (!validator) {
144
+ validator = new import_json_schema.Validator(schema, "2020-12", false);
145
+ validators.set(schema, validator);
146
+ }
147
+ const value = options === void 0 ? {} : options;
148
+ const problems = [];
149
+ unknownKeys(schema, value, "#", problems);
150
+ const result = validator.validate(value);
151
+ if (!result.valid) {
152
+ const leaves = result.errors.filter(
153
+ (e) => !CASCADE.has(e.keyword) && !/\/(anyOf|oneOf)\/\d/.test(e.keywordLocation)
154
+ );
155
+ const shown = leaves.length || problems.length ? leaves : result.errors;
156
+ for (const e of shown) problems.push(`${path(e.instanceLocation)}: ${e.error}`);
157
+ }
158
+ return [...new Set(problems)];
159
+ }
160
+
161
+ // src/runtime/tools/scenario.ts
162
+ var active = /* @__PURE__ */ new Map();
163
+ var message = (error) => error instanceof Error ? error.message : String(error);
164
+ async function runUndos(entry) {
165
+ const errors = [];
166
+ for (const undo2 of entry.undos.splice(0).reverse()) {
167
+ try {
168
+ await undo2();
169
+ } catch (error) {
170
+ errors.push(message(error));
171
+ }
172
+ }
173
+ return errors;
174
+ }
175
+ async function undo(name) {
176
+ const entry = active.get(name);
177
+ if (!entry) return [];
178
+ active.delete(name);
179
+ return (await runUndos(entry)).map((e) => `${name}: ${e}`);
180
+ }
181
+ function scenarioTools(getScenarios, getTools) {
182
+ const find = (name) => {
183
+ const scenarios = getScenarios();
184
+ const scenario = scenarios[name];
185
+ if (!scenario)
186
+ throw new Error(
187
+ `Unknown scenario "${name}". Known: ${Object.keys(scenarios).join(", ") || "none"}`
188
+ );
189
+ return scenario;
190
+ };
191
+ const call = async (tool, ...args) => {
192
+ const definition = getTools()[tool];
193
+ if (!definition) throw new Error(`Unknown tool "${tool}"`);
194
+ const run = typeof definition === "function" ? definition : definition.run;
195
+ return run(...args);
196
+ };
197
+ return {
198
+ "scenario.list": {
199
+ description: "Setups the app defines, e.g. a signed-in user: name, description, options (a JSON Schema), active.",
200
+ run: () => Object.entries(getScenarios()).map(([name, s]) => ({
201
+ name,
202
+ description: s.description,
203
+ options: s.options,
204
+ active: active.has(name)
205
+ })).sort((a, b) => a.name.localeCompare(b.name))
206
+ },
207
+ "scenario.apply": {
208
+ description: "Apply a scenario: [name, options?]. Options must fit its schema (scenario.list). Returns what it returns. Applying an active one undoes it first. bridge.restore undoes it.",
209
+ run: async (name, options) => {
210
+ const scenario = find(name);
211
+ if (scenario.options) {
212
+ const problems = checkOptions(scenario.options, options);
213
+ if (problems.length)
214
+ throw new Error(
215
+ `Scenario "${name}" got bad options, nothing was applied:
216
+ - ${problems.join("\n- ")}
217
+ scenario.list shows its options schema.`
218
+ );
219
+ }
220
+ const previous = await undo(name);
221
+ if (previous.length)
222
+ throw new Error(`Undoing the active "${name}" failed: ${previous.join("; ")}`);
223
+ const entry = { options, undos: [] };
224
+ active.set(name, entry);
225
+ const context = {
226
+ options,
227
+ call,
228
+ onUndo: (fn) => {
229
+ if (typeof fn !== "function")
230
+ throw new Error("onUndo takes a function");
231
+ entry.undos.push(fn);
232
+ }
233
+ };
234
+ try {
235
+ return await scenario.apply(context);
236
+ } catch (error) {
237
+ const errors = await undo(name);
238
+ const extra = errors.length ? ` (undo: ${errors.join("; ")})` : "";
239
+ throw new Error(`Scenario "${name}" failed: ${message(error)}${extra}`);
240
+ }
241
+ }
242
+ },
243
+ "scenario.restore": {
244
+ description: "Undo every active scenario, newest first. bridge.restore runs this after every other restorer.",
245
+ run: async () => {
246
+ const names = [...active.keys()].reverse();
247
+ const errors = [];
248
+ for (const name of names) errors.push(...await undo(name));
249
+ if (errors.length) throw new Error(errors.join("; "));
250
+ return names;
251
+ }
252
+ }
253
+ };
254
+ }
255
+
101
256
  // src/runtime/tools/screen.ts
102
257
  var import_react_native = require("react-native");
103
258
 
@@ -639,12 +794,14 @@ function screenTools() {
639
794
  }
640
795
 
641
796
  // src/runtime/builtin-tools.ts
642
- function builtinTools(listTools, getTools, logs) {
797
+ function builtinTools(listTools, getTools, logs, getScenarios) {
643
798
  return {
644
799
  ...bridgeTools(listTools),
645
800
  ...logTools(logs),
646
801
  ...restoreTools(getTools),
647
- ...screenTools()
802
+ ...screenTools(),
803
+ // Only when the app defines scenarios, so `tools` doesn't list empty ones.
804
+ ...getScenarios && scenarioTools(getScenarios, getTools)
648
805
  };
649
806
  }
650
807
 
@@ -673,6 +830,38 @@ function cdpTransport() {
673
830
  };
674
831
  }
675
832
 
833
+ // src/runtime/gate.ts
834
+ function createGate(name) {
835
+ let holds = 0;
836
+ const listeners = /* @__PURE__ */ new Set();
837
+ const notify = () => {
838
+ for (const fn of [...listeners]) fn(holds > 0);
839
+ };
840
+ return {
841
+ name,
842
+ get closed() {
843
+ return holds > 0;
844
+ },
845
+ close: () => {
846
+ let released = false;
847
+ holds += 1;
848
+ if (holds === 1) notify();
849
+ return () => {
850
+ if (released) return;
851
+ released = true;
852
+ holds -= 1;
853
+ if (holds === 0) notify();
854
+ };
855
+ },
856
+ subscribe: (fn) => {
857
+ listeners.add(fn);
858
+ return () => {
859
+ listeners.delete(fn);
860
+ };
861
+ }
862
+ };
863
+ }
864
+
676
865
  // src/runtime/logs.ts
677
866
  var CAPACITY = 200;
678
867
  var STACK_LINES = 10;
@@ -735,9 +924,9 @@ function formatArgs(args) {
735
924
  }
736
925
  parts.push(...rest.map(describe2));
737
926
  const error = args.find((a) => a instanceof Error);
738
- const message = parts.join(" ");
927
+ const message2 = parts.join(" ");
739
928
  return {
740
- message: message.length > MESSAGE_CHARS ? `${message.slice(0, MESSAGE_CHARS)}...` : message,
929
+ message: message2.length > MESSAGE_CHARS ? `${message2.slice(0, MESSAGE_CHARS)}...` : message2,
741
930
  ...error?.stack ? { stack: trimStack(error.stack) } : {}
742
931
  };
743
932
  }
@@ -756,9 +945,9 @@ function describe2(value) {
756
945
  var trimStack = (stack) => stack.split("\n").slice(0, STACK_LINES).join("\n");
757
946
  function installLogHooks(capture, env) {
758
947
  let quiet = 0;
759
- let active = true;
948
+ let active2 = true;
760
949
  const record = (level, args) => {
761
- if (!active || quiet) return;
950
+ if (!active2 || quiet) return;
762
951
  quiet++;
763
952
  try {
764
953
  capture.record(level, args);
@@ -801,7 +990,7 @@ function installLogHooks(capture, env) {
801
990
  env.addEventListener?.("unhandledrejection", onRejection);
802
991
  }
803
992
  return () => {
804
- active = false;
993
+ active2 = false;
805
994
  if (console.error === wrappers.error) console.error = originals.error;
806
995
  if (console.warn === wrappers.warn) console.warn = originals.warn;
807
996
  if (errorUtils && originalHandler && errorUtils.getGlobalHandler() === handler2)
@@ -877,8 +1066,8 @@ function createRegistry(getTools, logs) {
877
1066
  const value = await unwrap(definition).run(...call.args ?? []);
878
1067
  return { id: call.id, from, ok: true, value: toJson(value), ms: ms() };
879
1068
  } catch (error) {
880
- const message = error instanceof Error ? error.message : String(error);
881
- return { id: call.id, from, ok: false, error: message, ms: ms() };
1069
+ const message2 = error instanceof Error ? error.message : String(error);
1070
+ return { id: call.id, from, ok: false, error: message2, ms: ms() };
882
1071
  }
883
1072
  }
884
1073
  return { list, dispatch };
@@ -889,9 +1078,11 @@ var randomId = () => Math.random().toString(36).slice(2, 10);
889
1078
  function startAgentBridge(options = {}) {
890
1079
  const deviceId = randomId();
891
1080
  const userTools = () => typeof options.tools === "function" ? options.tools() : options.tools ?? {};
1081
+ const { scenarios } = options;
1082
+ const getScenarios = scenarios ? () => typeof scenarios === "function" ? scenarios() : scenarios : void 0;
892
1083
  const logs = startLogCapture();
893
1084
  const allTools = () => ({
894
- ...builtinTools(() => registry.list(), allTools, logs.capture),
1085
+ ...builtinTools(() => registry.list(), allTools, logs.capture, getScenarios),
895
1086
  ...userTools()
896
1087
  });
897
1088
  const registry = createRegistry(allTools, logs.capture);
@@ -927,7 +1118,12 @@ function useAgentBridge(options = {}) {
927
1118
  tools: () => {
928
1119
  const { tools } = latest.current;
929
1120
  return typeof tools === "function" ? tools() : tools ?? {};
930
- }
1121
+ },
1122
+ // Decided at mount: scenario.* tools are there or not for the bridge's life.
1123
+ scenarios: latest.current.scenarios ? () => {
1124
+ const { scenarios } = latest.current;
1125
+ return typeof scenarios === "function" ? scenarios() : scenarios ?? {};
1126
+ } : void 0
931
1127
  });
932
1128
  }, []);
933
1129
  }
@@ -1,5 +1,5 @@
1
- import { a as Transport, A as AgentBridgeOptions } from '../types-C6DUUHnB.cjs';
2
- export { C as CallMessage, D as DeviceInfo, L as LogEntry, R as RUNTIME_MARKER, b as ResultMessage, c as ToolDefinition, d as ToolFn, e as ToolInfo, T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { a as Transport, A as AgentBridgeOptions } from '../types-6kKQsLC1.cjs';
2
+ export { C as CallMessage, D as DeviceInfo, L as LogEntry, O as OptionsSchema, R as RUNTIME_MARKER, b as ResultMessage, S as Scenario, c as ScenarioContext, d as Scenarios, e as ToolDefinition, f as ToolFn, g as ToolInfo, T as Tools } from '../types-6kKQsLC1.cjs';
3
3
 
4
4
  /**
5
5
  * Works in any React Native app served by Metro. The client evaluates one fixed
@@ -8,6 +8,26 @@ export { C as CallMessage, D as DeviceInfo, L as LogEntry, R as RUNTIME_MARKER,
8
8
  */
9
9
  declare function cdpTransport(): Transport;
10
10
 
11
+ type Gate = {
12
+ name: string;
13
+ /** True while anything holds the gate closed. */
14
+ readonly closed: boolean;
15
+ /**
16
+ * Closes the gate until the returned function is called. Closes are
17
+ * counted, so the gate opens when the last one is released. Hand the
18
+ * result to a scenario's `onUndo`.
19
+ */
20
+ close: () => () => void;
21
+ /** Calls `fn(closed)` when the gate closes or opens. Returns an unsubscribe. */
22
+ subscribe: (fn: (closed: boolean) => void) => () => void;
23
+ };
24
+ /**
25
+ * A switch app code checks before a side effect a scenario must hold off,
26
+ * e.g. connecting a realtime client with a fake token. Always open in a
27
+ * release build.
28
+ */
29
+ declare function createGate(name: string): Gate;
30
+
11
31
  type SettleResult = {
12
32
  commits: number;
13
33
  ms: number;
@@ -66,4 +86,4 @@ declare function startAgentBridge(options?: AgentBridgeOptions): () => void;
66
86
  */
67
87
  declare function useAgentBridge(options?: AgentBridgeOptions): void;
68
88
 
69
- export { AgentBridgeOptions, type ScreenElement, type SettleResult, type Target, Transport, cdpTransport, settle, startAgentBridge, useAgentBridge };
89
+ export { AgentBridgeOptions, type Gate, type ScreenElement, type SettleResult, type Target, Transport, cdpTransport, createGate, settle, startAgentBridge, useAgentBridge };
@@ -48,6 +48,14 @@ type DeviceInfo = {
48
48
  tools: ToolInfo[];
49
49
  };
50
50
 
51
+ /**
52
+ * A JSON Schema (draft 2020-12) for a scenario's options. Typed loosely, so
53
+ * `as const` schemas and ones generated by other libraries fit.
54
+ */
55
+ type OptionsSchema = {
56
+ readonly [keyword: string]: unknown;
57
+ };
58
+
51
59
  type ToolFn = (...args: any[]) => unknown;
52
60
  type ToolDefinition = ToolFn | {
53
61
  description?: string;
@@ -64,13 +72,44 @@ type Transport = {
64
72
  /** Starts listening; returns a function that stops. */
65
73
  start: (context: TransportContext) => () => void;
66
74
  };
75
+ type ScenarioContext<O = unknown> = {
76
+ /** What the agent or flow passed to `scenario.apply`, or undefined. */
77
+ options: O | undefined;
78
+ /**
79
+ * Calls any bridge tool by name, e.g. `call('store.set', 'auth', {...})`.
80
+ * What a tool changes is undone by its own restorer in `bridge.restore`.
81
+ */
82
+ call: (tool: string, ...args: unknown[]) => Promise<unknown>;
83
+ /**
84
+ * Runs `fn` when the scenario is undone, after every other restorer. Use it
85
+ * for anything the tools' restorers don't cover: app mocks, gates, native
86
+ * state. Undo callbacks run newest first.
87
+ */
88
+ onUndo: (fn: () => unknown) => void;
89
+ };
90
+ /** A named setup the app defines and the agent applies, e.g. a signed-in user. */
91
+ type Scenario<O = unknown> = {
92
+ description?: string;
93
+ /**
94
+ * JSON Schema (2020-12) for `options`. `scenario.apply` rejects options
95
+ * that don't fit before `apply` runs, and `scenario.list` shows it. Keys an
96
+ * object schema doesn't list are rejected unless it sets
97
+ * `additionalProperties`. No options are checked as `{}`.
98
+ */
99
+ options?: OptionsSchema;
100
+ /** Sets the app up. Its return value goes back to the agent. */
101
+ apply: (context: ScenarioContext<O>) => unknown;
102
+ };
103
+ type Scenarios = Record<string, Scenario<any>>;
67
104
  type AgentBridgeOptions = {
68
105
  /** Your tools, or a function returning them (read on every call). */
69
106
  tools?: Tools | (() => Tools);
107
+ /** Named setups for `scenario.*`, or a function returning them (read on every call). */
108
+ scenarios?: Scenarios | (() => Scenarios);
70
109
  /** How this app shows up in `agent-bridge devices`. */
71
110
  name?: string;
72
111
  /** Defaults to CDP only. Add `expoTransport()` on Expo. */
73
112
  transports?: Transport[];
74
113
  };
75
114
 
76
- export { type AgentBridgeOptions as A, type CallMessage as C, type DeviceInfo as D, type LogEntry as L, RUNTIME_MARKER as R, type Tools as T, type Transport as a, type ResultMessage as b, type ToolDefinition as c, type ToolFn as d, type ToolInfo as e };
115
+ export { type AgentBridgeOptions as A, type CallMessage as C, type DeviceInfo as D, type LogEntry as L, type OptionsSchema as O, RUNTIME_MARKER as R, type Scenario as S, type Tools as T, type Transport as a, type ResultMessage as b, type ScenarioContext as c, type Scenarios as d, type ToolDefinition as e, type ToolFn as f, type ToolInfo as g };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avasapp/agent-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Let coding agents drive a running React Native app directly: seed state, flip flags, navigate and check the screen in about a millisecond per call, with no taps.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -118,6 +118,7 @@
118
118
  }
119
119
  },
120
120
  "dependencies": {
121
+ "@cfworker/json-schema": "^4.1.1",
121
122
  "ws": "^8.21.3"
122
123
  },
123
124
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agent-bridge
3
- description: "Drive a running React Native or Expo dev build directly through @avasapp/agent-bridge: see what's on screen, fill in forms, press buttons, seed query data, flip feature flags, set store state, navigate, mock the network, fake realtime messages and undo it all, in milliseconds per call instead of tapping through the app. Pairs with agent-device, which opens the app and covers native UI, real taps and screenshots. Use whenever you need the app in a particular state to test a change, or need to verify what a screen shows."
3
+ description: "Drive a running React Native or Expo dev build directly through @avasapp/agent-bridge: start from an app-defined scenario such as a locally signed-in user, see what's on screen, fill in forms, press buttons, seed query data, flip feature flags, set store state, navigate, mock the network, fake realtime messages and undo it all, in milliseconds per call instead of tapping through the app. Pairs with agent-device, which opens the app and covers native UI, real taps and screenshots. Use whenever you need the app in a particular state to test a change, or need to verify what a screen shows."
4
4
  ---
5
5
 
6
6
  # Driving an app with agent-bridge
@@ -37,6 +37,30 @@ minutes without calls and runs `bridge.restore` when it does.
37
37
  If `tools` fails with "agent-bridge isn't running", the hook isn't mounted in
38
38
  this build. Say so; don't fall back to tapping silently.
39
39
 
40
+ ## Start from a scenario
41
+
42
+ Apps can define named setups, such as a locally signed-in user. Use one
43
+ instead of signing in by hand or seeding auth state yourself:
44
+
45
+ ```sh
46
+ npx agent-bridge scenarios # what this app defines
47
+ npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
48
+ ```
49
+
50
+ `scenarios` shows each one's options as a JSON Schema. Bad options fail
51
+ before anything changes, and the error lists every problem
52
+ (`options: unknown option "usr". Known: user`): fix them and apply again.
53
+ `bridge.restore` (and `session stop`) undoes it after everything else. A local
54
+ scenario usually turns on strict network mode: any request no mock answers
55
+ fails with a 501, and the reply carries an error that names it
56
+ (`! error during …: agent-bridge strict network: no mock for GET https://…`).
57
+ Add a mock for it (`net.mock`), don't turn strict mode off.
58
+ `npx agent-bridge call net.strict` lists what it blocked. Strict mode sees JS
59
+ `fetch`/XHR only, not images, WebSockets or native SDKs.
60
+
61
+ If the app has no scenario for the state you need, say so. Don't sign in to a
62
+ real account instead.
63
+
40
64
  ## See and use the screen
41
65
 
42
66
  ```sh
@@ -95,6 +119,8 @@ last 200 errors and warnings.
95
119
  Write the steps into a flow file and run it:
96
120
 
97
121
  ```js
122
+ export const scenario = 'signedIn' // applied first, undone after, even on failure
123
+
98
124
  export default async ({ step }) => {
99
125
  await step('open form', 'screen.press', 'add-plant')
100
126
  await step('save empty', 'screen.press', 'save-plant')
@@ -105,6 +131,7 @@ export default async ({ step }) => {
105
131
 
106
132
  ```sh
107
133
  npx agent-bridge run flow.mjs --strict # --strict fails if the app logged an error
134
+ npx agent-bridge run flow.mjs --scenario signedIn # add a scenario the flow doesn't declare
108
135
  ```
109
136
 
110
137
  ## Clean up
@@ -114,8 +141,8 @@ npx agent-bridge session stop # runs bridge.restore; --keep leaves t
114
141
  ```
115
142
 
116
143
  `bridge.restore` unpins queries, puts back store and MMKV values, removes
117
- network mocks, unmutes realtime channels and ends a faked connection state, so
118
- the next run starts from the real state.
144
+ network mocks, unmutes realtime channels and ends a faked connection state,
145
+ then undoes active scenarios, so the next run starts from the real state.
119
146
 
120
147
  ## Traps
121
148