amalgm 0.1.144 → 0.1.145

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,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Action-result deltas — the evidence contract behind "every action reports
5
+ * its own result".
6
+ *
7
+ * Success must mean the intended browser state changed, not that a command
8
+ * was dispatched. So around each mutating verb the wrapper measures:
9
+ * before — the facts that decide whether the action CAN work (target
10
+ * enabled, current field value, keyboard-event baseline), using
11
+ * agent-browser's own target resolution (@ref/CSS), never a
12
+ * second resolver;
13
+ * after — what actually changed (URL/title, field value, key events,
14
+ * blocking dialog), one settle window later.
15
+ *
16
+ * The result is a typed delta object (buildable and testable without a
17
+ * browser) rendered to compact text. Every field is a measured fact or
18
+ * absent — no confidence scores, no interpretation, no model calls. Probe
19
+ * failures degrade to absent fields and never fail the action they decorate.
20
+ */
21
+
22
+ const attach = require('./attach');
23
+
24
+ /** One in-page round trip: url + title. Same probe engine.js batches. */
25
+ const STATE_PROBE = ['eval', 'JSON.stringify({url: location.href, title: document.title})'];
26
+
27
+ /** Idempotent capture-phase keydown counter; returns the current count.
28
+ * Navigation resets it — probes just degrade to absent. */
29
+ const KEY_COUNTER_PROBE = ['eval', `(() => {
30
+ if (!window.__amalgmKeyGuard) {
31
+ window.__amalgmKeyGuard = { count: 0 };
32
+ document.addEventListener('keydown', () => { window.__amalgmKeyGuard.count += 1; }, true);
33
+ }
34
+ return window.__amalgmKeyGuard.count;
35
+ })()`];
36
+
37
+ /** Brief settle so the page reacts (handlers run, sync navigations start)
38
+ * before we look. Not a load wait — deltas must stay cheap. */
39
+ const SETTLE_MS = 150;
40
+
41
+ const PROBE_TIMEOUT_MS = 5_000;
42
+
43
+ /** A press dispatching more events than this is not a keypress — re-check
44
+ * after a pause and call out a runaway input flood (a real stress-test P0:
45
+ * 34K+ synthetic keydowns/minute after one modifier combo). */
46
+ const KEY_FLOOD_THRESHOLD = 15;
47
+ const KEY_FLOOD_RECHECK_MS = 200;
48
+
49
+ /** session -> { url, title } as of the last probe or verb that knew it. */
50
+ const lastStates = new Map();
51
+
52
+ function parseProbe(result) {
53
+ // Batch entries wrap as {result: {origin, result}}, single runs as
54
+ // {data: {origin, result}} — .result must win, same as engine.parseState.
55
+ const data = result?.result ?? result?.data ?? result;
56
+ const raw = data && typeof data === 'object' && 'result' in data ? data.result : data;
57
+ try {
58
+ const { url = '', title = '' } = JSON.parse(String(raw));
59
+ return { url, title };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Unwrap a single value out of an agent-browser command result. */
66
+ function unwrap(entry, key) {
67
+ const data = entry?.result ?? entry?.data ?? entry;
68
+ if (data && typeof data === 'object') {
69
+ if (key !== undefined && key in data) return data[key];
70
+ if ('result' in data) return data.result;
71
+ }
72
+ return data;
73
+ }
74
+
75
+ /** Verbs that already learned the page state (open/snapshot) record it here
76
+ * so the next action's delta has a real "before". */
77
+ function remember(session, state) {
78
+ if (state && state.url) lastStates.set(session, { url: state.url, title: state.title || '' });
79
+ }
80
+
81
+ /** text= targets go through find, which agent-browser's get/is commands
82
+ * don't take — element probes are skipped for them, fields stay absent. */
83
+ function probeable(target) {
84
+ return typeof target === 'string' && target.length > 0 && !target.startsWith('text=');
85
+ }
86
+
87
+ async function tryRun(session, command, context) {
88
+ try {
89
+ return await attach.run(session, command, { timeoutMs: PROBE_TIMEOUT_MS, context });
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Measure the facts that decide whether the action can work. One extra
97
+ * round trip; every failure degrades to absent fields.
98
+ */
99
+ async function beforeAction(session, { action, target } = {}, context) {
100
+ const before = { page: lastStates.get(session) || null };
101
+ if ((action === 'click' || action === 'select') && probeable(target)) {
102
+ const enabled = await tryRun(session, ['is', 'enabled', target], context);
103
+ if (enabled) before.enabled = Boolean(unwrap(enabled, 'enabled'));
104
+ }
105
+ if (action === 'fill' && probeable(target)) {
106
+ const entries = await tryRun(session, ['get', 'value', target], context);
107
+ if (entries) {
108
+ before.value = String(unwrap(entries, 'value') ?? '');
109
+ const enabled = await tryRun(session, ['is', 'enabled', target], context);
110
+ if (enabled) before.enabled = Boolean(unwrap(enabled, 'enabled'));
111
+ }
112
+ }
113
+ if (action === 'press') {
114
+ const count = await tryRun(session, KEY_COUNTER_PROBE, context);
115
+ if (count) before.keyCount = Number(unwrap(count)) || 0;
116
+ }
117
+ return before;
118
+ }
119
+
120
+ /**
121
+ * Measure what changed and assemble the typed delta. Never throws.
122
+ * `expected` is the value a fill asked for, so a field that silently kept
123
+ * or mangled its content gets called out.
124
+ */
125
+ async function afterAction(session, { action, target, before = {}, expected } = {}, context) {
126
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
127
+ const delta = { action: action || null, target: target || null, page: {}, warnings: [] };
128
+ if (typeof before.enabled === 'boolean') delta.targetEnabled = before.enabled;
129
+ if (before.enabled === false) {
130
+ delta.warnings.push(`Target ${target} was disabled when ${action || 'the action'} ran — it likely had no effect.`);
131
+ }
132
+
133
+ // One batched round trip for the after-facts; a pending dialog fails the
134
+ // batch with a message that says so — that IS the delta then.
135
+ const commands = [];
136
+ if ((action === 'fill' || action === 'select') && probeable(target)) commands.push(['get', 'value', target]);
137
+ if (action === 'press') commands.push(KEY_COUNTER_PROBE);
138
+ commands.push(STATE_PROBE);
139
+
140
+ let entries = null;
141
+ try {
142
+ entries = await attach.runBatch(session, commands, { timeoutMs: PROBE_TIMEOUT_MS, context });
143
+ } catch (err) {
144
+ const message = String(err?.message || '');
145
+ if (/dialog/i.test(message)) {
146
+ delta.page.dialog = message;
147
+ return delta;
148
+ }
149
+ return delta; // probe failure: facts stay absent, action result stands
150
+ }
151
+
152
+ let index = 0;
153
+ if ((action === 'fill' || action === 'select') && probeable(target)) {
154
+ const after = String(unwrap(entries[index], 'value') ?? '');
155
+ index += 1;
156
+ delta.value = { after };
157
+ if (typeof before.value === 'string') {
158
+ delta.value.before = before.value;
159
+ delta.value.changed = before.value !== after;
160
+ }
161
+ if (typeof expected === 'string' && after !== expected) {
162
+ delta.warnings.push(`Field value is "${clip(after, 80)}", not the requested "${clip(expected, 80)}".`);
163
+ }
164
+ }
165
+ if (action === 'press') {
166
+ const count = Number(unwrap(entries[index])) || 0;
167
+ index += 1;
168
+ if (typeof before.keyCount === 'number') {
169
+ const dispatched = Math.max(0, count - before.keyCount);
170
+ delta.keys = { dispatched };
171
+ if (dispatched > KEY_FLOOD_THRESHOLD) {
172
+ await new Promise((resolve) => setTimeout(resolve, KEY_FLOOD_RECHECK_MS));
173
+ const again = await tryRun(session, KEY_COUNTER_PROBE, context);
174
+ const later = Number(unwrap(again)) || count;
175
+ if (later > count) {
176
+ delta.keys.flood = true;
177
+ delta.warnings.push(`Runaway keyboard input: ${later - before.keyCount} keydown events and still firing. Reload the page to stop it.`);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ const state = parseProbe(entries[index]);
184
+ if (state && state.url) {
185
+ delta.page.url = state.url;
186
+ delta.page.title = state.title || '';
187
+ const prior = before.page;
188
+ if (prior && prior.url) delta.page.urlChanged = prior.url !== state.url;
189
+ remember(session, state);
190
+ }
191
+ return delta;
192
+ }
193
+
194
+ function clip(value, max) {
195
+ const text = String(value ?? '');
196
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
197
+ }
198
+
199
+ /** Render the delta as compact lines: warnings first, then facts. */
200
+ function render(delta) {
201
+ if (!delta) return '';
202
+ const lines = delta.warnings.map((warning) => `⚠ ${warning}`);
203
+ if (delta.page.dialog) {
204
+ lines.push(`⚠ ${delta.page.dialog}`);
205
+ return lines.join('\n');
206
+ }
207
+ if (delta.value && delta.value.before !== undefined) {
208
+ lines.push(`→ Value: "${clip(delta.value.before, 80)}" → "${clip(delta.value.after, 80)}"`);
209
+ }
210
+ if (delta.keys && delta.keys.dispatched === 0) {
211
+ lines.push('→ No keydown events reached the page — the key may not have been delivered.');
212
+ }
213
+ if (delta.page.url) {
214
+ const label = delta.page.title ? `${delta.page.url} ("${delta.page.title}")` : delta.page.url;
215
+ if (delta.page.urlChanged) {
216
+ lines.push(`→ Navigated to ${label}`);
217
+ lines.push('Refs from the previous page are stale — re-run snapshot.');
218
+ } else {
219
+ lines.push(`→ Page: ${label}${delta.page.urlChanged === false ? ' (no navigation)' : ''}`);
220
+ }
221
+ }
222
+ return lines.join('\n');
223
+ }
224
+
225
+ /**
226
+ * Page-facts-only delta for verbs with no element target (dialog, tab, cua
227
+ * clicks). Returns rendered text; kept for those callers' simplicity.
228
+ */
229
+ async function describeAfter(session, context, action = null) {
230
+ const delta = await afterAction(session, { action, before: { page: lastStates.get(session) || null } }, context);
231
+ return render(delta);
232
+ }
233
+
234
+ /**
235
+ * Cap tool output at `max` chars with an explicit truncation marker — silent
236
+ * truncation reads as "that was everything" when it wasn't.
237
+ */
238
+ function clipOutput(text, max, hint = '') {
239
+ const value = String(text ?? '');
240
+ if (value.length <= max) return value;
241
+ const marker = `\n\n[Output truncated: showing ${max.toLocaleString()} of ${value.length.toLocaleString()} chars.${hint ? ` ${hint}` : ''}]`;
242
+ return value.slice(0, max) + marker;
243
+ }
244
+
245
+ module.exports = {
246
+ afterAction,
247
+ beforeAction,
248
+ clipOutput,
249
+ describeAfter,
250
+ lastStates,
251
+ parseProbe,
252
+ remember,
253
+ render,
254
+ };
@@ -3,13 +3,18 @@
3
3
  /**
4
4
  * Browser core actions — the thin surface.
5
5
  *
6
- * Eleven verbs built on the agent-browser CLI (vercel-labs/agent-browser).
6
+ * Verbs built on the agent-browser CLI (vercel-labs/agent-browser).
7
7
  * The loop is: open → snapshot (@refs) → click/fill/select (@ref) →
8
8
  * re-snapshot.
9
9
  * Everything else agent-browser can do (scroll, hover, cookies, network
10
10
  * mocking, traces, diffs, React inspection, ...) is reachable through `cli`
11
11
  * without widening this schema.
12
12
  *
13
+ * Every mutating verb reports its own result: after acting it probes the
14
+ * page once (delta.js) and answers in the same response what the agent
15
+ * would otherwise ask next — did we navigate, is a dialog blocking, where
16
+ * are we now. All large outputs carry explicit truncation markers.
17
+ *
13
18
  * Action names are short verbs — the toolbox layer prefixes them, so agents
14
19
  * see toolbox__browser_open, toolbox__browser_snapshot, and so on.
15
20
  *
@@ -20,6 +25,34 @@
20
25
 
21
26
  const { textResult, errorResult } = require('../lib/tool-result');
22
27
  const engine = require('./engine');
28
+ const delta = require('./delta');
29
+
30
+ const SNAPSHOT_MAX_CHARS = 12_000;
31
+ const OUTPUT_MAX_CHARS = 15_000;
32
+
33
+ /** Action text + the post-action delta, in one response. */
34
+ function actionResult(text, deltaText) {
35
+ return textResult([text, deltaText].filter(Boolean).join('\n'));
36
+ }
37
+
38
+ /** Rendered text for the agent + the typed delta object as metadata for
39
+ * anything programmatic (tests, UIs, validators). */
40
+ function evidenceResult(text, deltaObject) {
41
+ const result = actionResult(text, delta.render(deltaObject));
42
+ return { ...result, metadata: { browserDelta: deltaObject } };
43
+ }
44
+
45
+ /**
46
+ * The before → act → after evidence pipeline shared by the element verbs.
47
+ * `act` runs the engine verb; measurement never fails the action.
48
+ */
49
+ async function withEvidence({ action, args, ctx, expected, act, describe }) {
50
+ const session = engine.sessionFor(args, ctx);
51
+ const before = await delta.beforeAction(session, { action, target: args.target }, ctx);
52
+ const result = await act();
53
+ const deltaObject = await delta.afterAction(session, { action, target: args.target, before, expected }, ctx);
54
+ return evidenceResult(describe(result), deltaObject);
55
+ }
23
56
 
24
57
  const sessionProperty = {
25
58
  session: {
@@ -54,6 +87,14 @@ module.exports = [
54
87
  if (!args.url) return errorResult('url is required');
55
88
  try {
56
89
  const result = await engine.open(args, ctx);
90
+ delta.remember(result.session, result);
91
+ // The audit contract for open: requested URL, final URL, error page
92
+ // or not. A chrome-error page means the navigation failed and the
93
+ // previous page is gone — success text here would be a lie.
94
+ if (/^chrome-error:/.test(result.url || '')) {
95
+ return errorResult(`Open failed: the browser is on an error page (${result.url}) after requesting ${args.url}. `
96
+ + 'The previous page is gone — open a working URL to continue.');
97
+ }
57
98
  return textResult(line(result, `Opened: ${result.title || args.url}`)
58
99
  + '\n\nNext: snapshot lists interactive elements as @refs.');
59
100
  } catch (err) {
@@ -68,8 +109,11 @@ module.exports = [
68
109
  async handler(args, ctx) {
69
110
  try {
70
111
  const result = await engine.snapshot(args, ctx);
112
+ delta.remember(result.session, result);
71
113
  const header = `Page: ${result.title || ''}\nURL: ${result.url || ''}\n\n`;
72
- return textResult(header + (result.snapshotText || '(empty page)').slice(0, 12_000));
114
+ const body = delta.clipOutput(result.snapshotText || '(empty page)', SNAPSHOT_MAX_CHARS,
115
+ 'Elements past the cutoff have no refs here — scope with cli ["get","html","<css>"] or ["find","role","<role>","click","--name","<label>"].');
116
+ return textResult(header + body);
73
117
  } catch (err) {
74
118
  return errorResult(`Snapshot failed: ${err.message}`);
75
119
  }
@@ -110,8 +154,13 @@ module.exports = [
110
154
  async handler(args, ctx) {
111
155
  if (!args.target) return errorResult('target is required (e.g. "@e3", "#submit", "text=Sign in")');
112
156
  try {
113
- const result = await engine.click(args, ctx);
114
- return textResult(`Clicked ${args.target}\nSession: ${result.session}\n\nRe-run snapshot if the page changed.`);
157
+ return await withEvidence({
158
+ action: 'click',
159
+ args,
160
+ ctx,
161
+ act: () => engine.click(args, ctx),
162
+ describe: () => `Clicked ${args.target}`,
163
+ });
115
164
  } catch (err) {
116
165
  return errorResult(`Click failed: ${err.message}`);
117
166
  }
@@ -133,8 +182,16 @@ module.exports = [
133
182
  async handler(args, ctx) {
134
183
  if (!args.target || typeof args.text !== 'string') return errorResult('target and text are required');
135
184
  try {
136
- const result = await engine.fill(args, ctx);
137
- return textResult(`Filled ${args.target}${result.submitted ? ' and pressed Enter' : ''}\nSession: ${result.session}`);
185
+ return await withEvidence({
186
+ action: 'fill',
187
+ args,
188
+ ctx,
189
+ // A submit navigates away — comparing the next page's field against
190
+ // the requested text would be a false warning.
191
+ expected: args.submit ? undefined : args.text,
192
+ act: () => engine.fill(args, ctx),
193
+ describe: (result) => `Filled ${args.target}${result.submitted ? ' and pressed Enter' : ''}`,
194
+ });
138
195
  } catch (err) {
139
196
  return errorResult(`Fill failed: ${err.message}`);
140
197
  }
@@ -151,8 +208,13 @@ module.exports = [
151
208
  async handler(args, ctx) {
152
209
  if (!args.key) return errorResult('key is required');
153
210
  try {
154
- const result = await engine.press(args, ctx);
155
- return textResult(`Pressed ${args.key}\nSession: ${result.session}`);
211
+ return await withEvidence({
212
+ action: 'press',
213
+ args,
214
+ ctx,
215
+ act: () => engine.press(args, ctx),
216
+ describe: () => `Pressed ${args.key}`,
217
+ });
156
218
  } catch (err) {
157
219
  return errorResult(`Press failed: ${err.message}`);
158
220
  }
@@ -173,8 +235,13 @@ module.exports = [
173
235
  async handler(args, ctx) {
174
236
  if (!args.target || typeof args.value !== 'string') return errorResult('target and value are required');
175
237
  try {
176
- const result = await engine.select(args, ctx);
177
- return textResult(`Selected "${args.value}" in ${args.target}\nSession: ${result.session}`);
238
+ return await withEvidence({
239
+ action: 'select',
240
+ args,
241
+ ctx,
242
+ act: () => engine.select(args, ctx),
243
+ describe: () => `Selected "${args.value}" in ${args.target}`,
244
+ });
178
245
  } catch (err) {
179
246
  return errorResult(`Select failed: ${err.message}`);
180
247
  }
@@ -193,7 +260,8 @@ module.exports = [
193
260
  try {
194
261
  const { result } = await engine.evaluate(args, ctx);
195
262
  const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
196
- return textResult(output.slice(0, 15_000) || '(no result)');
263
+ return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS,
264
+ 'Return less from the script — slice/filter/count in-page instead.') || '(no result)');
197
265
  } catch (err) {
198
266
  return errorResult(`Eval failed: ${err.message}`);
199
267
  }
@@ -237,12 +305,98 @@ module.exports = [
237
305
  try {
238
306
  const { result } = await engine.passthrough(args, ctx);
239
307
  const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
240
- return textResult(output.slice(0, 15_000) || 'ok');
308
+ return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS) || 'ok');
241
309
  } catch (err) {
242
310
  return errorResult(`cli failed: ${err.message}`);
243
311
  }
244
312
  },
245
313
  },
314
+ {
315
+ name: 'dialog',
316
+ description: 'Resolve a JavaScript dialog (alert/confirm/prompt) that is blocking the page: accept or dismiss, with optional text for prompts. Action results warn when a dialog appears — resolve it here before any other action will work.',
317
+ inputSchema: {
318
+ type: 'object',
319
+ properties: {
320
+ action: { type: 'string', enum: ['accept', 'dismiss'], description: 'accept = OK, dismiss = Cancel' },
321
+ text: { type: 'string', description: 'Text to enter into a prompt() before accepting' },
322
+ ...sessionProperty,
323
+ },
324
+ required: ['action'],
325
+ },
326
+ async handler(args, ctx) {
327
+ if (args.action !== 'accept' && args.action !== 'dismiss') return errorResult('action must be "accept" or "dismiss"');
328
+ try {
329
+ const command = ['dialog', args.action];
330
+ if (typeof args.text === 'string' && args.action === 'accept') command.push(args.text);
331
+ const result = await engine.passthrough({ ...args, args: command }, ctx);
332
+ return actionResult(
333
+ `Dialog ${args.action === 'accept' ? 'accepted' : 'dismissed'}${args.text ? ` with text "${args.text}"` : ''}`,
334
+ await delta.describeAfter(result.session, ctx),
335
+ );
336
+ } catch (err) {
337
+ return errorResult(`dialog failed: ${err.message}`);
338
+ }
339
+ },
340
+ },
341
+ {
342
+ name: 'tab',
343
+ description: 'Manage the session\'s browser tabs: list them (stable ids like "t2"), switch to one, open a new one, or close one. Links with target=_blank and window.open popups become tabs automatically and are auto-focused.',
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {
347
+ action: { type: 'string', enum: ['list', 'switch', 'new', 'close'], description: 'What to do' },
348
+ id: { type: 'string', description: 'Tab id from list, e.g. "t2" — required for switch, optional for close (defaults to the active tab)' },
349
+ ...sessionProperty,
350
+ },
351
+ required: ['action'],
352
+ },
353
+ async handler(args, ctx) {
354
+ try {
355
+ let command;
356
+ // Bare `tab` lists — and stays allowed in the desktop app, where
357
+ // switching targets is refused (a session IS its surface there).
358
+ if (args.action === 'list') command = ['tab'];
359
+ else if (args.action === 'new') command = ['tab', 'new'];
360
+ else if (args.action === 'switch') {
361
+ if (!args.id) return errorResult('id is required for switch (run {action: "list"} first)');
362
+ command = ['tab', args.id];
363
+ } else if (args.action === 'close') command = ['tab', 'close', ...(args.id ? [args.id] : [])];
364
+ else return errorResult('action must be one of: list, switch, new, close');
365
+ const { result, session } = await engine.passthrough({ ...args, args: command }, ctx);
366
+ const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
367
+ if (args.action === 'list') return textResult(delta.clipOutput(output, OUTPUT_MAX_CHARS) || 'ok');
368
+ return actionResult(output || `tab ${args.action} ok`, await delta.describeAfter(session, ctx));
369
+ } catch (err) {
370
+ return errorResult(`tab failed: ${err.message}`);
371
+ }
372
+ },
373
+ },
374
+ {
375
+ name: 'console',
376
+ description: 'Read the page\'s console logs and uncaught errors captured for this session. Check this after actions that misbehave — a page that looks stuck is often throwing.',
377
+ inputSchema: {
378
+ type: 'object',
379
+ properties: {
380
+ clear: { type: 'boolean', description: 'Clear the captured logs and errors after reading' },
381
+ ...sessionProperty,
382
+ },
383
+ },
384
+ async handler(args, ctx) {
385
+ try {
386
+ const flags = args.clear ? ['--clear'] : [];
387
+ const logs = await engine.passthrough({ ...args, args: ['console', ...flags] }, ctx);
388
+ const errors = await engine.passthrough({ ...args, args: ['errors', ...flags] }, ctx);
389
+ const asText = (value) => (typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? ''));
390
+ const body = [
391
+ `Console:\n${delta.clipOutput(asText(logs.result), 8_000) || '(empty)'}`,
392
+ `Page errors:\n${delta.clipOutput(asText(errors.result), 6_000) || '(none)'}`,
393
+ ].join('\n\n');
394
+ return textResult(body);
395
+ } catch (err) {
396
+ return errorResult(`console failed: ${err.message}`);
397
+ }
398
+ },
399
+ },
246
400
  {
247
401
  name: 'close',
248
402
  description: 'End the browser session. Headless browsers shut down; the visible desktop surface stays open for the user. A fresh session starts on the next browser call.',
@@ -77,6 +77,13 @@ function buildActionDescriptor(toolName, args = {}) {
77
77
  return `Browser CLI${quoteIfPresent(Array.isArray(args.args) ? args.args.join(' ') : '')}`;
78
78
  case 'browser_close':
79
79
  return 'Close browser';
80
+ case 'cua':
81
+ case 'browser_cua': {
82
+ const action = String(args.action || '');
83
+ if (action === 'screenshot') return 'Take visible browser screenshot';
84
+ if (action === 'type') return args.text ? `Type ${clipText(args.text, 40)} in browser` : 'Type in browser';
85
+ return action ? `Browser ${action.replace(/_/g, ' ')} (coordinates)` : 'Browser computer-use action';
86
+ }
80
87
  case 'cua_click':
81
88
  return 'Click browser coordinates';
82
89
  case 'cua_double_click':
@@ -93,6 +100,12 @@ function buildActionDescriptor(toolName, args = {}) {
93
100
  return 'Drag in browser';
94
101
  case 'cua_screenshot':
95
102
  return 'Take visible browser screenshot';
103
+ case 'browser_dialog':
104
+ return args.action === 'dismiss' ? 'Dismiss browser dialog' : 'Accept browser dialog';
105
+ case 'browser_tab':
106
+ return `Browser tab ${clipText(String(args.action || 'list'), 20)}`;
107
+ case 'browser_console':
108
+ return 'Read browser console';
96
109
  case 'record_start':
97
110
  return `Start browser recording${quoteIfPresent(args.name)}`;
98
111
  case 'record_stop':
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-app-crash-loop-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, 'user');
11
+ process.env.AMALGM_HOME = path.join(tempRoot, 'home');
12
+ process.env.AMALGM_RUNTIME_LABEL = 'test';
13
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
14
+
15
+ const { getApp, saveApps } = require('../apps/store');
16
+ const { APP_RESTART_POLICY, startApp, stopApp } = require('../apps/supervisor');
17
+ const { APPS_DIR } = require('../config');
18
+
19
+ function waitFor(predicate, label, timeoutMs = 10_000) {
20
+ const deadline = Date.now() + timeoutMs;
21
+ return new Promise((resolve, reject) => {
22
+ const tick = async () => {
23
+ try {
24
+ if (await predicate()) return resolve();
25
+ } catch {}
26
+ if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for ${label}`));
27
+ setTimeout(tick, 50);
28
+ };
29
+ tick();
30
+ });
31
+ }
32
+
33
+ test('a crash-looping app is parked as degraded instead of restarting forever', async (t) => {
34
+ APP_RESTART_POLICY.initialDelayMs = 50;
35
+ APP_RESTART_POLICY.maxDelayMs = 100;
36
+ APP_RESTART_POLICY.maxCrashes = 3;
37
+
38
+ const appCwd = fs.mkdtempSync(path.join(tempRoot, 'crasher-'));
39
+ const script = path.join(appCwd, 'crash.js');
40
+ fs.writeFileSync(script, "console.log('boom before dying'); process.exit(1);\n");
41
+
42
+ saveApps({
43
+ version: 1,
44
+ apps: [{
45
+ id: 'app-crash-loop',
46
+ kind: 'app',
47
+ name: 'Crash Loop',
48
+ cwd: appCwd,
49
+ port: 0,
50
+ startCommand: `"${process.execPath}" "${script}"`,
51
+ appRef: 'crashloop',
52
+ publicUrl: 'https://crashloop.apps.amalgm.ai/',
53
+ dnsConnected: false,
54
+ autostart: true,
55
+ keepAlive: true,
56
+ desiredState: 'running',
57
+ status: 'registered',
58
+ pid: null,
59
+ }],
60
+ });
61
+
62
+ t.after(() => stopApp('app-crash-loop').catch(() => {}));
63
+
64
+ await startApp('app-crash-loop');
65
+ await waitFor(() => getApp('app-crash-loop')?.status === 'degraded', 'degraded status');
66
+
67
+ const app = getApp('app-crash-loop');
68
+ assert.equal(app.status, 'degraded');
69
+ assert.equal(app.pid, null);
70
+ assert.match(app.error, /automatic restarts paused/);
71
+
72
+ // Degraded means parked: no further restarts should happen on their own.
73
+ await new Promise((resolve) => setTimeout(resolve, 400));
74
+ assert.equal(getApp('app-crash-loop').status, 'degraded');
75
+
76
+ // App output landed in the app's own log file, not the runtime log.
77
+ const logFile = path.join(APPS_DIR, 'logs', 'app-crash-loop.log');
78
+ await waitFor(() => fs.existsSync(logFile), 'app log file');
79
+ assert.match(fs.readFileSync(logFile, 'utf8'), /boom before dying/);
80
+
81
+ // An explicit start resets the crash budget and tries again.
82
+ await startApp('app-crash-loop');
83
+ const restarted = getApp('app-crash-loop');
84
+ assert.equal(restarted.status, 'running');
85
+ });