@frockbot/plugin-computer 0.3.15 → 0.3.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-computer",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,21 +29,21 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@cordisjs/client": "0.8.2",
32
- "@frockbot/client-core": "0.3.15",
33
- "@frockbot/client-ui": "0.3.15",
34
- "@frockbot/computer-core": "0.3.15",
35
- "@frockbot/computer-host-runtime": "0.3.15",
36
- "@frockbot/kernel-agent-loop": "0.3.15",
37
- "@frockbot/kernel-contracts": "0.3.15",
38
- "@frockbot/plugin-prompt": "0.3.15",
39
- "@frockbot/plugin-shell": "0.3.15",
40
- "@frockbot/plugin-tools": "0.3.15",
32
+ "@frockbot/client-core": "0.3.17",
33
+ "@frockbot/client-ui": "0.3.17",
34
+ "@frockbot/computer-core": "0.3.17",
35
+ "@frockbot/computer-host-runtime": "0.3.17",
36
+ "@frockbot/kernel-agent-loop": "0.3.17",
37
+ "@frockbot/kernel-contracts": "0.3.17",
38
+ "@frockbot/plugin-prompt": "0.3.17",
39
+ "@frockbot/plugin-shell": "0.3.17",
40
+ "@frockbot/plugin-tools": "0.3.17",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "vue": "3.5.41"
43
43
  },
44
44
  "devDependencies": {
45
- "@frockbot/plugin-models": "0.3.15",
46
- "@frockbot/plugin-testkit": "0.3.15",
45
+ "@frockbot/plugin-models": "0.3.17",
46
+ "@frockbot/plugin-testkit": "0.3.17",
47
47
  "@types/bun": "1.4.0",
48
48
  "@types/node": "26.2.0",
49
49
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -108,6 +108,78 @@ describe("computer agent contribution", () => {
108
108
  await harness.dispose();
109
109
  });
110
110
 
111
+ test("computer_browser says which field a click is missing and takes label as name", async () => {
112
+ const calls: string[] = [];
113
+ const provider: ComputerProvider = {
114
+ id: "fixture",
115
+ open: async (identity, tenant, assignment) => ({
116
+ assignment,
117
+ identity,
118
+ tenant,
119
+ exec: {
120
+ execute: async () => ({
121
+ exitCode: 0,
122
+ stdout: new Uint8Array(),
123
+ stderr: new Uint8Array(),
124
+ outputTruncated: false,
125
+ }),
126
+ },
127
+ browser: {
128
+ perform: async (action) => {
129
+ calls.push(JSON.stringify(action));
130
+ return { accessibilitySnapshot: 'checkbox "Mark done"' };
131
+ },
132
+ },
133
+ close: () => Promise.resolve(),
134
+ }),
135
+ };
136
+ const harness = await createPluginHarness([
137
+ ComputerRegistry,
138
+ ToolRegistry,
139
+ SystemPromptRegistry,
140
+ SessionStore,
141
+ ]);
142
+ harness.root.computers.register(provider);
143
+ await harness.mount(
144
+ createComputerAgentPlugin({
145
+ userId: "user-1",
146
+ defaultProviderId: "fixture",
147
+ }),
148
+ );
149
+
150
+ // Bob's first three attempts on production, in order.
151
+ const onlyName = await execute(harness, "computer_browser", {
152
+ action: "click",
153
+ name: "Add",
154
+ });
155
+ expect(onlyName.isError).toBe(true);
156
+ expect(onlyName.content).toContain("role and name are both required");
157
+ expect(onlyName.content).toContain('"role":"button"');
158
+
159
+ const onlyLabel = await execute(harness, "computer_browser", {
160
+ action: "click",
161
+ label: "Add",
162
+ });
163
+ expect(onlyLabel.isError).toBe(true);
164
+
165
+ const labelWithRole = await execute(harness, "computer_browser", {
166
+ action: "click",
167
+ label: "Mark done",
168
+ role: "checkbox",
169
+ });
170
+ expect(labelWithRole.isError).toBe(false);
171
+ expect(calls).toEqual([
172
+ JSON.stringify({ type: "click", role: "checkbox", name: "Mark done" }),
173
+ ]);
174
+
175
+ const unknown = await execute(harness, "computer_browser", {
176
+ action: "hover",
177
+ });
178
+ expect(unknown.isError).toBe(true);
179
+ expect(unknown.content).toContain('"action" must be one of');
180
+ await harness.dispose();
181
+ });
182
+
111
183
  test("computer_exec during an update returns an actionable tool failure", async () => {
112
184
  const provider: ComputerProvider = {
113
185
  id: "fixture",
package/src/agent.ts CHANGED
@@ -256,6 +256,46 @@ function decodeProcessId(input: unknown): string | undefined {
256
256
  return isComputerProcessIdV1(value) ? value : undefined;
257
257
  }
258
258
 
259
+ /**
260
+ * What each `computer_browser` action needs, in the words the refusal uses.
261
+ *
262
+ * Bob on production (2026-09-04) clicked a button with `{name}`, then
263
+ * `{label}`, then `{label, role}` before landing on `{role, name}` — three
264
+ * wasted steps per click, because the loop's generic "Invalid input for tool"
265
+ * names no field. The snapshot lists elements as `button "Add"` and
266
+ * `checkbox "Mark done"`, so a click takes that role and that accessible name;
267
+ * `label` is accepted as a synonym for `name` (and `name` for `label` on
268
+ * `fill`) since the model reaches for both.
269
+ */
270
+ export const BROWSER_ACTION_SHAPES_V1: Readonly<Record<string, string>> = {
271
+ snapshot: '{"action":"snapshot"}',
272
+ navigate: '{"action":"navigate","url":"http://127.0.0.1:8944/"}',
273
+ click:
274
+ '{"action":"click","role":"button","name":"Add"} — role and name are both required; take them from the snapshot line (button "Add" → role "button", name "Add"; a checkbox line → role "checkbox")',
275
+ fill: '{"action":"fill","label":"New todo","text":"Buy milk"} — label is the field\'s accessible label from the snapshot',
276
+ press: '{"action":"press","key":"Enter"}',
277
+ wait: '{"action":"wait","milliseconds":500} (0 to 30000)',
278
+ };
279
+
280
+ /** Why a `computer_browser` input could not be used, and what to send. */
281
+ export function browserInputRefusalV1(input: unknown): string {
282
+ const value = record(input);
283
+ const action = typeof value?.action === "string" ? value.action : undefined;
284
+ const shape = action ? BROWSER_ACTION_SHAPES_V1[action] : undefined;
285
+ if (!shape) {
286
+ return `computer_browser input is invalid: "action" must be one of ${Object.keys(
287
+ BROWSER_ACTION_SHAPES_V1,
288
+ )
289
+ .map((name) => `"${name}"`)
290
+ .join(", ")}. For example ${BROWSER_ACTION_SHAPES_V1.click}.`;
291
+ }
292
+ return `computer_browser input is invalid for "${action}". Expected ${shape}.`;
293
+ }
294
+
295
+ function optionalString(value: unknown): string | undefined {
296
+ return typeof value === "string" && value.length > 0 ? value : undefined;
297
+ }
298
+
259
299
  function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
260
300
  const value = record(input);
261
301
  switch (value?.action) {
@@ -265,24 +305,28 @@ function decodeBrowser(input: unknown): ComputerBrowserAction | undefined {
265
305
  return typeof value.url === "string" && value.url
266
306
  ? { type: "navigate", url: value.url }
267
307
  : undefined;
268
- case "click":
269
- return typeof value.role === "string" && typeof value.name === "string"
308
+ case "click": {
309
+ const name = optionalString(value.name) ?? optionalString(value.label);
310
+ return typeof value.role === "string" && name !== undefined
270
311
  ? {
271
312
  type: "click",
272
313
  role: value.role,
273
- name: value.name,
314
+ name,
274
315
  exact: typeof value.exact === "boolean" ? value.exact : undefined,
275
316
  }
276
317
  : undefined;
277
- case "fill":
278
- return typeof value.label === "string" && typeof value.text === "string"
318
+ }
319
+ case "fill": {
320
+ const label = optionalString(value.label) ?? optionalString(value.name);
321
+ return label !== undefined && typeof value.text === "string"
279
322
  ? {
280
323
  type: "fill",
281
- label: value.label,
324
+ label,
282
325
  text: value.text,
283
326
  exact: typeof value.exact === "boolean" ? value.exact : undefined,
284
327
  }
285
328
  : undefined;
329
+ }
286
330
  case "press":
287
331
  return typeof value.key === "string"
288
332
  ? { type: "press", key: value.key }
@@ -394,9 +438,11 @@ export async function syncWorkspaceRootNowV1(request: {
394
438
  sessionId: string;
395
439
  turn: number;
396
440
  root: WorkspaceRootV1;
441
+ requiredPaths?: readonly string[];
397
442
  signal?: AbortSignal;
398
443
  }): Promise<ComputerSyncSummaryV1> {
399
- const { computer, sessions, sessionId, turn, root, signal } = request;
444
+ const { computer, sessions, sessionId, turn, root, requiredPaths, signal } =
445
+ request;
400
446
  const sync = computer.sync;
401
447
  let summary: ComputerSyncSummaryV1;
402
448
  if (!sync?.reconcileRoot) {
@@ -409,7 +455,12 @@ export async function syncWorkspaceRootNowV1(request: {
409
455
  summary = await sync.reconcileRoot(
410
456
  root,
411
457
  "publish",
412
- signal ? { signal } : undefined,
458
+ signal || requiredPaths
459
+ ? {
460
+ ...(signal ? { signal } : {}),
461
+ ...(requiredPaths ? { requiredPaths } : {}),
462
+ }
463
+ : undefined,
413
464
  );
414
465
  } catch (error) {
415
466
  summary = computerSyncSummaryV1(
@@ -1470,7 +1521,7 @@ export function createComputerAgentPlugin(
1470
1521
  },
1471
1522
  idempotent: config.idempotentEffects === true,
1472
1523
  description:
1473
- "Control the browser in the Bot's selected Computer and return an accessibility snapshot.",
1524
+ 'Control the browser in the Bot\'s selected Computer and return an accessibility snapshot. Shapes: {"action":"snapshot"}; {"action":"navigate","url":...}; {"action":"click","role":"button","name":"Add"} (role AND name, both from the snapshot line, e.g. checkbox "Mark done"); {"action":"fill","label":"New todo","text":...}; {"action":"press","key":"Enter"}; {"action":"wait","milliseconds":500}.',
1474
1525
  inputSchema: {
1475
1526
  type: "object",
1476
1527
  properties: {
@@ -1479,9 +1530,20 @@ export function createComputerAgentPlugin(
1479
1530
  enum: ["snapshot", "navigate", "click", "fill", "press", "wait"],
1480
1531
  },
1481
1532
  url: { type: "string" },
1482
- role: { type: "string" },
1483
- name: { type: "string" },
1484
- label: { type: "string" },
1533
+ role: {
1534
+ type: "string",
1535
+ description:
1536
+ "click: the element's role from the snapshot (button, checkbox, link, textbox…)",
1537
+ },
1538
+ name: {
1539
+ type: "string",
1540
+ description:
1541
+ "click: the element's accessible name from the snapshot",
1542
+ },
1543
+ label: {
1544
+ type: "string",
1545
+ description: "fill: the field's accessible label from the snapshot",
1546
+ },
1485
1547
  text: { type: "string" },
1486
1548
  key: { type: "string" },
1487
1549
  exact: { type: "boolean" },
@@ -1490,11 +1552,15 @@ export function createComputerAgentPlugin(
1490
1552
  required: ["action"],
1491
1553
  additionalProperties: false,
1492
1554
  },
1493
- validate: (input) => decodeBrowser(input) !== undefined,
1555
+ // Deliberately permissive: a wrong shape reaches `execute`, which says
1556
+ // which field is missing and shows the shape. A bare `false` here becomes
1557
+ // the loop's generic "Invalid input for tool", which cost Bob three
1558
+ // steps per click. Same reasoning as `skill_load`.
1559
+ validate: (input) => !!record(input),
1494
1560
  execute: async (input, context) => {
1495
1561
  const action = decodeBrowser(input);
1496
1562
  if (!action)
1497
- return { content: "Invalid browser action", isError: true };
1563
+ return { content: browserInputRefusalV1(input), isError: true };
1498
1564
  try {
1499
1565
  return await useComputer(
1500
1566
  await open(context.botId, context.sessionId, context.signal),
package/src/sync.test.ts CHANGED
@@ -254,6 +254,34 @@ describe("the Computer Package as the sync's caller", () => {
254
254
  ]);
255
255
  expect(syncEvents(events)[0]?.detail).toContain("paused");
256
256
  });
257
+
258
+ test("records every incomplete sync operation in one Turn", async () => {
259
+ const { provider, signal } = fixture(() => ({
260
+ ...computerSyncSummaryV1(
261
+ "degraded",
262
+ "Excluded 1 reproducible Workspace item from sync.",
263
+ ),
264
+ ignored: 1,
265
+ }));
266
+ const model = modelRunning(["first", "second"], (step) => {
267
+ if (step === 2) signal.value = "signal-2";
268
+ });
269
+
270
+ const events = await runTurn(provider, model);
271
+
272
+ expect(
273
+ syncEvents(events).map((event) => ({
274
+ reason: event.reason,
275
+ status: event.status,
276
+ ignored: event.ignored,
277
+ omitted: event.omitted,
278
+ })),
279
+ ).toEqual([
280
+ { reason: "open", status: "degraded", ignored: 1, omitted: 0 },
281
+ { reason: "signal", status: "degraded", ignored: 1, omitted: 0 },
282
+ { reason: "turn-end", status: "degraded", ignored: 1, omitted: 0 },
283
+ ]);
284
+ });
257
285
  });
258
286
 
259
287
  /**
@@ -298,8 +326,9 @@ describe("syncWorkspaceRootNowV1", () => {
298
326
  calls.push("reconcile");
299
327
  return Promise.resolve(computerSyncSummaryV1("ok"));
300
328
  },
301
- reconcileRoot: (asked, reason) => {
329
+ reconcileRoot: (asked, reason, options) => {
302
330
  calls.push(`reconcileRoot:${reason}:${asked.kind}`);
331
+ calls.push(`required:${options?.requiredPaths?.join(",") ?? ""}`);
303
332
  return Promise.resolve({ ...computerSyncSummaryV1("ok"), pushed: 1 });
304
333
  },
305
334
  signal: () => Promise.resolve(undefined),
@@ -312,12 +341,16 @@ describe("syncWorkspaceRootNowV1", () => {
312
341
  sessionId: "session-1",
313
342
  turn: 3,
314
343
  root: appletsRoot,
344
+ requiredPaths: ["todo/dist/server.js", "todo/dist/ui.html"],
315
345
  });
316
346
 
317
347
  expect(summary.status).toBe("ok");
318
348
  // One root, never the whole Workspace: the Turn's own policy still owns
319
349
  // `open`, `signal`, and `turn-end`, and this borrows none of them.
320
- expect(calls).toEqual(["reconcileRoot:publish:package-declared"]);
350
+ expect(calls).toEqual([
351
+ "reconcileRoot:publish:package-declared",
352
+ "required:todo/dist/server.js,todo/dist/ui.html",
353
+ ]);
321
354
  const recorded = syncEvents([...harness.session.events]);
322
355
  expect(recorded).toHaveLength(1);
323
356
  expect(recorded[0]).toMatchObject({