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,165 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+
6
+ const { ACTIONS } = require('../browser');
7
+ const delta = require('../browser/delta');
8
+
9
+ function names() {
10
+ return ACTIONS.map((action) => action.name);
11
+ }
12
+
13
+ function action(name) {
14
+ return ACTIONS.find((entry) => entry.name === name);
15
+ }
16
+
17
+ test('tool surface: promoted verbs present, cua merged, no duplicates', () => {
18
+ const all = names();
19
+ assert.equal(new Set(all).size, all.length, `duplicate action names: ${all.join(', ')}`);
20
+
21
+ for (const expected of ['open', 'snapshot', 'click', 'fill', 'press', 'select', 'eval', 'wait', 'cli', 'close',
22
+ 'dialog', 'tab', 'console', 'cua',
23
+ 'record_start', 'record_stop', 'record_list',
24
+ 'auth_list', 'auth_link_create', 'auth_save', 'auth_load']) {
25
+ assert.ok(all.includes(expected), `missing action: ${expected}`);
26
+ }
27
+ // The eight cua_* tools are merged into `cua`.
28
+ assert.equal(all.filter((name) => name.startsWith('cua_')).length, 0,
29
+ `cua_* actions should be merged into cua: ${all.filter((n) => n.startsWith('cua_')).join(', ')}`);
30
+ });
31
+
32
+ test('every action has a schema and handler; enums are well-formed', () => {
33
+ for (const entry of ACTIONS) {
34
+ assert.equal(typeof entry.handler, 'function', `${entry.name} has no handler`);
35
+ assert.equal(entry.inputSchema?.type, 'object', `${entry.name} has no object schema`);
36
+ assert.ok(entry.description, `${entry.name} has no description`);
37
+ }
38
+ assert.deepEqual(action('cua').inputSchema.properties.action.enum,
39
+ ['screenshot', 'click', 'double_click', 'move', 'scroll', 'type', 'keypress', 'drag']);
40
+ assert.deepEqual(action('dialog').inputSchema.properties.action.enum, ['accept', 'dismiss']);
41
+ assert.deepEqual(action('tab').inputSchema.properties.action.enum, ['list', 'switch', 'new', 'close']);
42
+ });
43
+
44
+ test('cua validates per-action arguments without touching a browser', async () => {
45
+ const bad = await action('cua').handler({ action: 'click', x: 'nope', y: 4 }, {});
46
+ assert.equal(bad.isError, true);
47
+ assert.match(bad.content[0].text, /x must be a finite number/);
48
+
49
+ const missingKeys = await action('cua').handler({ action: 'keypress' }, {});
50
+ assert.equal(missingKeys.isError, true);
51
+ assert.match(missingKeys.content[0].text, /keys is required/);
52
+
53
+ const shortPath = await action('cua').handler({ action: 'drag', path: [{ x: 1, y: 2 }] }, {});
54
+ assert.equal(shortPath.isError, true);
55
+ assert.match(shortPath.content[0].text, /at least two/);
56
+
57
+ const unknown = await action('cua').handler({ action: 'fly' }, {});
58
+ assert.equal(unknown.isError, true);
59
+ assert.match(unknown.content[0].text, /action must be one of/);
60
+ });
61
+
62
+ test('dialog and tab validate arguments without touching a browser', async () => {
63
+ const badDialog = await action('dialog').handler({ action: 'shrug' }, {});
64
+ assert.equal(badDialog.isError, true);
65
+
66
+ const badSwitch = await action('tab').handler({ action: 'switch' }, {});
67
+ assert.equal(badSwitch.isError, true);
68
+ assert.match(badSwitch.content[0].text, /id is required/);
69
+ });
70
+
71
+ test('clipOutput passes short text through and marks truncation explicitly', () => {
72
+ assert.equal(delta.clipOutput('hello', 100), 'hello');
73
+ const clipped = delta.clipOutput('x'.repeat(500), 100, 'Scope it.');
74
+ assert.ok(clipped.startsWith('x'.repeat(100)));
75
+ assert.match(clipped, /Output truncated: showing 100 of 500 chars\. Scope it\./);
76
+ });
77
+
78
+ test('delta remembers state and parses probe results', () => {
79
+ delta.remember('t-session', { url: 'https://a.example/', title: 'A' });
80
+ assert.deepEqual(delta.lastStates.get('t-session'), { url: 'https://a.example/', title: 'A' });
81
+ delta.remember('t-session', { url: '' }); // no url — must not overwrite
82
+ assert.equal(delta.lastStates.get('t-session').url, 'https://a.example/');
83
+ delta.lastStates.delete('t-session');
84
+
85
+ assert.deepEqual(
86
+ delta.parseProbe({ result: JSON.stringify({ url: 'https://b.example/', title: 'B' }) }),
87
+ { url: 'https://b.example/', title: 'B' },
88
+ );
89
+ assert.equal(delta.parseProbe({ result: 'not json' }), null);
90
+ });
91
+
92
+ test('delta render: warnings first, facts after, dialog short-circuits', () => {
93
+ const rendered = delta.render({
94
+ action: 'fill',
95
+ target: '#email',
96
+ targetEnabled: true,
97
+ value: { before: '', after: 'a@example.com', changed: true },
98
+ page: { url: 'https://x.example/form', title: 'Form', urlChanged: false },
99
+ warnings: [],
100
+ });
101
+ assert.match(rendered, /→ Value: "" → "a@example\.com"/);
102
+ assert.match(rendered, /→ Page: https:\/\/x\.example\/form \("Form"\) \(no navigation\)/);
103
+
104
+ const disabled = delta.render({
105
+ action: 'click',
106
+ target: '#save',
107
+ targetEnabled: false,
108
+ page: { url: 'https://x.example/', urlChanged: false },
109
+ warnings: ['Target #save was disabled when click ran — it likely had no effect.'],
110
+ });
111
+ assert.ok(disabled.startsWith('⚠ Target #save was disabled'), disabled);
112
+
113
+ const dialog = delta.render({
114
+ action: 'click',
115
+ target: '#confirm',
116
+ page: { dialog: 'A JavaScript confirm dialog is blocking the page.' },
117
+ warnings: [],
118
+ });
119
+ assert.match(dialog, /⚠ .*confirm dialog is blocking/);
120
+ assert.ok(!dialog.includes('→'), 'dialog delta should carry no page facts');
121
+
122
+ const navigated = delta.render({
123
+ action: 'click',
124
+ target: '@e3',
125
+ page: { url: 'https://x.example/next', title: 'Next', urlChanged: true },
126
+ warnings: [],
127
+ });
128
+ assert.match(navigated, /→ Navigated to https:\/\/x\.example\/next/);
129
+ assert.match(navigated, /stale — re-run snapshot/);
130
+
131
+ const deadKey = delta.render({
132
+ action: 'press',
133
+ keys: { dispatched: 0 },
134
+ page: { url: 'https://x.example/', urlChanged: false },
135
+ warnings: [],
136
+ });
137
+ assert.match(deadKey, /No keydown events reached the page/);
138
+
139
+ assert.equal(delta.render(null), '');
140
+ });
141
+
142
+ test('evidence verbs attach the typed delta object as metadata', () => {
143
+ // The contract: rendered text for the agent, machine-readable facts for
144
+ // everything else. Verified live in e2e; here we pin the result shape by
145
+ // checking the handler source wires evidenceResult for the element verbs.
146
+ const fs = require('node:fs');
147
+ const source = fs.readFileSync(require.resolve('../browser/tools.js'), 'utf8');
148
+ for (const verb of ["action: 'click'", "action: 'fill'", "action: 'press'", "action: 'select'"]) {
149
+ assert.ok(source.includes(verb), `${verb} not wired through withEvidence`);
150
+ }
151
+ });
152
+
153
+ test('auth_list summarizes by default and never dumps unbounded lists', async () => {
154
+ // The handler reads the real local store; whatever is in it, the summary
155
+ // contract must hold: totals + capped recent lists, full only on opt-in.
156
+ const result = await action('auth_list').handler({});
157
+ assert.ok(!result.isError, result.content?.[0]?.text);
158
+ const parsed = JSON.parse(result.content[0].text);
159
+ for (const key of ['profiles', 'bundles', 'loginSessions']) {
160
+ assert.equal(typeof parsed[key].total, 'number', `${key} missing total`);
161
+ assert.ok(Array.isArray(parsed[key].recent), `${key} missing recent`);
162
+ assert.ok(parsed[key].recent.length <= 15, `${key} recent not capped`);
163
+ }
164
+ assert.ok(result.content[0].text.length < 30_000, 'summary output should stay bounded');
165
+ });
@@ -36,15 +36,15 @@ test('core tool actions carry stable toolbox ids', () => {
36
36
  const browserOpen = CORE_TOOLS.find((tool) => tool.name === 'open');
37
37
  const notifyUser = CORE_TOOLS.find((tool) => tool.name === 'notify_user');
38
38
  const listApps = CORE_TOOLS.find((tool) => tool.name === 'list_apps');
39
- const cuaClick = CORE_TOOLS.find((tool) => tool.name === 'cua_click');
39
+ const cua = CORE_TOOLS.find((tool) => tool.name === 'cua');
40
40
  const recordStart = CORE_TOOLS.find((tool) => tool.name === 'record_start');
41
41
 
42
42
  assert.equal(browserOpen.toolboxToolId, 'browser');
43
43
  assert.equal(browserOpen.toolboxActionId, 'browser.open');
44
44
  assert.equal(listApps.toolboxToolId, 'computer-use');
45
45
  assert.equal(listApps.toolboxActionId, 'computer-use.list_apps');
46
- assert.equal(cuaClick.toolboxToolId, 'browser');
47
- assert.equal(cuaClick.toolboxActionId, 'browser.cua_click');
46
+ assert.equal(cua.toolboxToolId, 'browser');
47
+ assert.equal(cua.toolboxActionId, 'browser.cua');
48
48
  assert.equal(recordStart.toolboxActionId, 'browser.record_start');
49
49
  assert.equal(notifyUser.toolboxToolId, 'notifications');
50
50
  assert.equal(notifyUser.toolboxActionId, 'notifications.notify_user');
@@ -74,16 +74,12 @@ test('browser is a single toolbox entry holding every browser action', () => {
74
74
  'eval',
75
75
  'wait',
76
76
  'cli',
77
+ 'dialog',
78
+ 'tab',
79
+ 'console',
77
80
  'close',
78
- // computer use
79
- 'cua_screenshot',
80
- 'cua_click',
81
- 'cua_double_click',
82
- 'cua_move',
83
- 'cua_scroll',
84
- 'cua_type',
85
- 'cua_keypress',
86
- 'cua_drag',
81
+ // computer use — one action with an action field, not eight
82
+ 'cua',
87
83
  // recording
88
84
  'record_start',
89
85
  'record_stop',
@@ -123,8 +119,8 @@ test('browser actions carry capability tags so UIs can section one card', () =>
123
119
  .filter((tool) => tool.capability === capability)
124
120
  .map((tool) => tool.name);
125
121
 
126
- assert.equal(byCapability('core').length, 11);
127
- assert.equal(byCapability('computer-use').every((name) => name.startsWith('cua_')), true);
122
+ assert.equal(byCapability('core').length, 14);
123
+ assert.deepEqual(byCapability('computer-use'), ['cua']);
128
124
  assert.deepEqual(byCapability('recording'), ['record_start', 'record_stop', 'record_list']);
129
125
  assert.deepEqual(byCapability('auth'), ['auth_list', 'auth_link_create', 'auth_save', 'auth_load']);
130
126
  assert.equal(browser.tools.every((tool) => Boolean(tool.capability)), true);
@@ -133,12 +129,19 @@ test('browser actions carry capability tags so UIs can section one card', () =>
133
129
  test('loadouts from every browser era still grant the current actions', () => {
134
130
  const { loadoutContainsToolOrAction } = require('../toolbox/loadout-context');
135
131
 
136
- // Era-1 group id grants exactly that group's actions.
132
+ // Era-1 group id grants exactly that group's actions — including the
133
+ // era-4 merged `cua` action.
137
134
  const cuaLoadout = new Set(['browser_cua']);
135
+ assert.equal(loadoutContainsToolOrAction(cuaLoadout, 'browser', 'browser.cua'), true);
138
136
  assert.equal(loadoutContainsToolOrAction(cuaLoadout, 'browser', 'browser.cua_click'), true);
139
137
  assert.equal(loadoutContainsToolOrAction(cuaLoadout, 'browser', 'browser.record_start'), false);
140
138
  assert.equal(loadoutContainsToolOrAction(cuaLoadout, 'browser', 'browser.open'), false);
141
139
 
140
+ // Era-3 fine-grained cua_* grants cover the merged `cua` action.
141
+ const cuaActionLoadout = new Set(['browser.cua_click']);
142
+ assert.equal(loadoutContainsToolOrAction(cuaActionLoadout, 'browser', 'browser.cua'), true);
143
+ assert.equal(loadoutContainsToolOrAction(cuaActionLoadout, 'browser', 'browser.open'), false);
144
+
142
145
  // Era-1 fine-grained action ids map 1:1.
143
146
  const actionLoadout = new Set(['browser_recording.record_start']);
144
147
  assert.equal(loadoutContainsToolOrAction(actionLoadout, 'browser', 'browser.record_start'), true);
@@ -16,9 +16,14 @@ const FIRST_PARTY_TOOL_IDS = new Set([
16
16
  // granting the same capability. Frozen maps; these never grow.
17
17
  // era 1: four toolbox entries (browser + browser_cua/_recording/_auth)
18
18
  // era 2: one entry, prefixed action names (browser.browser_open)
19
- // era 3 (now): one entry, short verbs (browser.open); the three auth list
19
+ // era 3: one entry, short verbs (browser.open); the three auth list
20
20
  // actions merged into auth_list.
21
+ // era 4 (now): the eight cua_* actions merged into one `cua` action.
21
22
  const OLD_BROWSER_ACTION_NAMES = {
23
+ cua: [
24
+ 'cua_screenshot', 'cua_click', 'cua_double_click', 'cua_move',
25
+ 'cua_scroll', 'cua_type', 'cua_keypress', 'cua_drag',
26
+ ],
22
27
  open: ['browser_open'],
23
28
  snapshot: ['browser_snapshot'],
24
29
  screenshot: ['browser_screenshot'],
@@ -37,6 +42,7 @@ const OLD_BROWSER_ACTION_NAMES = {
37
42
 
38
43
  const LEGACY_BROWSER_GROUP_ACTIONS = {
39
44
  browser_cua: new Set([
45
+ 'cua',
40
46
  'cua_screenshot', 'cua_click', 'cua_double_click', 'cua_move',
41
47
  'cua_scroll', 'cua_type', 'cua_keypress', 'cua_drag',
42
48
  ]),
@@ -83,7 +83,7 @@ function toolKind(name = '', input = {}, metadata = {}) {
83
83
  function browserAction(name) {
84
84
  const match = String(name || '').match(/^(?:toolbox__browser_|browser_)(.+)$/);
85
85
  if (match) return match[1].replace(/^browser_/, '');
86
- if (/^(?:record_(?:start|stop|list)|cua_[a-z_]+)$/.test(name)) return name;
86
+ if (/^(?:record_(?:start|stop|list)|cua(?:_[a-z_]+)?)$/.test(name)) return name;
87
87
  return null;
88
88
  }
89
89
 
@@ -99,6 +99,15 @@ function titleFromMcp(tool, input = {}) {
99
99
  if (action === 'eval') return 'Running JavaScript in browser';
100
100
  if (action === 'wait') return 'Waiting for page';
101
101
  if (action === 'cli') return Array.isArray(input.args) ? `Browser: ${compactText(input.args.join(' '), 60)}` : 'Running browser command';
102
+ if (action === 'cua') {
103
+ const cuaAction = String(input.action || '');
104
+ if (cuaAction === 'screenshot') return 'Taking browser screenshot';
105
+ if (cuaAction === 'type') return input.text ? `Typing "${compactText(input.text, 60)}"` : 'Typing in browser';
106
+ return cuaAction ? `Browser ${cuaAction.replace(/_/g, ' ')} (coordinates)` : 'Browser computer-use action';
107
+ }
108
+ if (action === 'dialog') return input.action === 'dismiss' ? 'Dismissing browser dialog' : 'Accepting browser dialog';
109
+ if (action === 'tab') return `Browser tab ${compactText(String(input.action || 'list'), 20)}`;
110
+ if (action === 'console') return 'Reading browser console';
102
111
  if (action === 'close') return 'Closing browser session';
103
112
  if (action === 'record_start') return 'Starting browser recording';
104
113
  if (action === 'record_stop') return 'Saving browser recording';
@@ -121,18 +121,21 @@ One persistent browser per chat session. When the Amalgm desktop app is open, th
121
121
 
122
122
  Browser actions are MCP tools named `toolbox__browser_<action>` (your harness may add its own prefix, e.g. `mcp__amalgm__toolbox__browser_open`).
123
123
 
124
- The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`select`/`press` by @ref re-`snapshot` after the page changes.
124
+ The loop: `open` a URL → `snapshot` to see interactive elements as @refs → `click`/`fill`/`select`/`press` by @ref. Every acting verb reports its own result — current URL, whether it navigated, and whether a dialog is now blocking — so you usually don't need a follow-up call to see what happened.
125
125
 
126
126
  - `open` — go to a URL (the session persists across calls; navigate once, then interact)
127
127
  - `snapshot` — read the page: every interactive element gets a stable @ref
128
128
  - `click` / `fill` / `press` — act on @refs, CSS selectors, or `text=Label` targets
129
129
  - `select` — choose an option in a native dropdown (clicks and key presses cannot drive those)
130
+ - `dialog` — accept or dismiss a blocking alert/confirm/prompt (with optional prompt text)
131
+ - `tab` — list/switch/new/close tabs; `_blank` links and popups become tabs with stable ids like "t2"
132
+ - `console` — read the page's console logs and uncaught errors for this session
130
133
  - `eval` — run JavaScript in the page and get the result (bare expressions or `return` bodies both work)
131
134
  - `wait` — wait for a selector, URL, or delay
132
135
  - `screenshot` — PNG when you need pixels (snapshot is cheaper for driving); image coordinates match cua input 1:1
133
136
  - `cli` — the full agent-browser command surface: scroll, hover, back, cookies, storage, network, traces, find-by-role, and more
134
137
  - `close` — end the session (in the desktop app this also tidies away its split view)
135
- - `cua_screenshot` / `cua_click` / `cua_type` / … — coordinate-based control for vision loops. In the desktop app, text/key CUA actions are scoped inside the target page so they cannot type into Amalgm's own chat UI.
138
+ - `cua` — coordinate-based control for vision loops, one tool with an action field (screenshot/click/double_click/move/scroll/type/keypress/drag). In the desktop app, text/key actions are scoped inside the target page so they cannot type into Amalgm's own chat UI.
136
139
  - `record_start` / `record_stop` / `record_list` — capture the session to a local WebM video (QA evidence)
137
140
  - `auth_list` / `auth_link_create` / `auth_save` / `auth_load` — reuse logins: in the desktop app the login page opens directly in the split view for the user to sign in; from the web, share the minted link. Save/load encrypted auth bundles afterward
138
141