@moxxy/plugin-computer-control 0.41.2 → 0.42.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.
- package/bin/win32-x64/moxxy-computer.exe +0 -0
- package/bin/win32-x64/moxxy-computer.exe.json +1 -1
- package/dist/backend/access.d.ts +129 -0
- package/dist/backend/access.d.ts.map +1 -0
- package/dist/backend/access.js +158 -0
- package/dist/backend/access.js.map +1 -0
- package/dist/backend/app-hints.d.ts +14 -0
- package/dist/backend/app-hints.d.ts.map +1 -0
- package/dist/backend/app-hints.js +30 -0
- package/dist/backend/app-hints.js.map +1 -0
- package/dist/backend/backend.d.ts +70 -0
- package/dist/backend/backend.d.ts.map +1 -0
- package/dist/backend/backend.js +466 -0
- package/dist/backend/backend.js.map +1 -0
- package/dist/backend/rpc.d.ts +1438 -0
- package/dist/backend/rpc.d.ts.map +1 -0
- package/dist/backend/rpc.js +75 -0
- package/dist/backend/rpc.js.map +1 -0
- package/dist/backend/turn-controls.d.ts +20 -0
- package/dist/backend/turn-controls.d.ts.map +1 -0
- package/dist/backend/turn-controls.js +115 -0
- package/dist/backend/turn-controls.js.map +1 -0
- package/dist/contract/guidance.d.ts +12 -0
- package/dist/contract/guidance.d.ts.map +1 -0
- package/dist/contract/guidance.js +42 -0
- package/dist/contract/guidance.js.map +1 -0
- package/dist/contract/image.d.ts +29 -0
- package/dist/contract/image.d.ts.map +1 -0
- package/dist/contract/image.js +46 -0
- package/dist/contract/image.js.map +1 -0
- package/dist/contract/keys.d.ts +15 -0
- package/dist/contract/keys.d.ts.map +1 -0
- package/dist/contract/keys.js +118 -0
- package/dist/contract/keys.js.map +1 -0
- package/dist/contract/outcome.d.ts +61 -0
- package/dist/contract/outcome.d.ts.map +1 -0
- package/dist/contract/outcome.js +63 -0
- package/dist/contract/outcome.js.map +1 -0
- package/dist/contract/progress.d.ts +27 -0
- package/dist/contract/progress.d.ts.map +1 -0
- package/dist/contract/progress.js +36 -0
- package/dist/contract/progress.js.map +1 -0
- package/dist/contract/tools.d.ts +338 -0
- package/dist/contract/tools.d.ts.map +1 -0
- package/dist/contract/tools.js +195 -0
- package/dist/contract/tools.js.map +1 -0
- package/dist/contract/untrusted.d.ts +3 -0
- package/dist/contract/untrusted.d.ts.map +1 -0
- package/dist/contract/untrusted.js +8 -0
- package/dist/contract/untrusted.js.map +1 -0
- package/dist/helper/artifact.d.ts +41 -0
- package/dist/helper/artifact.d.ts.map +1 -0
- package/dist/helper/artifact.js +189 -0
- package/dist/helper/artifact.js.map +1 -0
- package/dist/helper/protocol.d.ts +80 -0
- package/dist/helper/protocol.d.ts.map +1 -0
- package/dist/helper/protocol.js +49 -0
- package/dist/helper/protocol.js.map +1 -0
- package/dist/helper/transport.d.ts +43 -0
- package/dist/helper/transport.d.ts.map +1 -0
- package/dist/{windows → helper}/transport.js +45 -19
- package/dist/helper/transport.js.map +1 -0
- package/dist/index.d.ts +9 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -42
- package/dist/index.js.map +1 -1
- package/dist/jev/ladder.d.ts +32 -0
- package/dist/jev/ladder.d.ts.map +1 -0
- package/dist/jev/ladder.js +66 -0
- package/dist/jev/ladder.js.map +1 -0
- package/dist/jev/memory.d.ts +11 -0
- package/dist/jev/memory.d.ts.map +1 -0
- package/dist/jev/memory.js +16 -0
- package/dist/jev/memory.js.map +1 -0
- package/dist/jev/run.d.ts +93 -0
- package/dist/jev/run.d.ts.map +1 -0
- package/dist/jev/run.js +403 -0
- package/dist/jev/run.js.map +1 -0
- package/dist/jev/train.d.ts +97 -0
- package/dist/jev/train.d.ts.map +1 -0
- package/dist/jev/train.js +28 -0
- package/dist/jev/train.js.map +1 -0
- package/dist/linux/profile.d.ts +6 -0
- package/dist/linux/profile.d.ts.map +1 -0
- package/dist/linux/profile.js +21 -0
- package/dist/linux/profile.js.map +1 -0
- package/dist/macos/profile.d.ts +5 -0
- package/dist/macos/profile.d.ts.map +1 -0
- package/dist/macos/profile.js +17 -0
- package/dist/macos/profile.js.map +1 -0
- package/dist/preview/controller.d.ts +127 -0
- package/dist/preview/controller.d.ts.map +1 -0
- package/dist/preview/controller.js +203 -0
- package/dist/preview/controller.js.map +1 -0
- package/dist/preview/surface.d.ts +9 -0
- package/dist/preview/surface.d.ts.map +1 -0
- package/dist/preview/surface.js +50 -0
- package/dist/preview/surface.js.map +1 -0
- package/dist/windows/maintenance.d.ts +1 -1
- package/dist/windows/maintenance.d.ts.map +1 -1
- package/dist/windows/maintenance.js +6 -5
- package/dist/windows/maintenance.js.map +1 -1
- package/dist/windows/profile.d.ts +5 -0
- package/dist/windows/profile.d.ts.map +1 -0
- package/dist/windows/profile.js +17 -0
- package/dist/windows/profile.js.map +1 -0
- package/learned/README.md +26 -0
- package/learned/cases/com.apple.calculator.json +395 -0
- package/learned/cases/com.apple.finder.json +115 -0
- package/learned/cases/com.apple.safari.json +112 -0
- package/learned/cases/com.apple.systempreferences.json +255 -0
- package/learned/com.apple.calculator-eace95fc.json +334 -0
- package/learned/com.apple.finder-27cf6ce8.json +260 -0
- package/learned/com.apple.safari-7cd9df4f.json +143 -0
- package/learned/com.apple.systempreferences-02cf0b8b.json +535 -0
- package/package.json +13 -7
- package/scripts/promote-learned.mjs +8 -0
- package/scripts/summarize-trial.mjs +68 -0
- package/scripts/train-learned.mjs +64 -0
- package/skills/computer-apps/blender.md +29 -0
- package/skills/computer-apps/browsers.md +50 -0
- package/skills/computer-apps/design-tools.md +37 -0
- package/skills/computer-apps/finder.md +23 -0
- package/skills/computer-apps/office.md +46 -0
- package/skills/computer-apps/video-editors.md +44 -0
- package/skills/computer-control.md +154 -191
- package/src/backend/access.test.ts +158 -0
- package/src/backend/access.ts +168 -0
- package/src/backend/app-hints.test.ts +59 -0
- package/src/backend/app-hints.ts +39 -0
- package/src/backend/backend.test.ts +725 -0
- package/src/backend/backend.ts +482 -0
- package/src/backend/contract-helper.fixture.mjs +138 -0
- package/src/backend/helper.fixture.ts +38 -0
- package/src/backend/rpc.ts +90 -0
- package/src/backend/turn-controls.test.ts +178 -0
- package/src/backend/turn-controls.ts +120 -0
- package/src/contract/guidance.test.ts +75 -0
- package/src/contract/guidance.ts +48 -0
- package/src/contract/image.test.ts +74 -0
- package/src/contract/image.ts +54 -0
- package/src/contract/keys.test.ts +103 -0
- package/src/contract/keys.ts +117 -0
- package/src/contract/outcome.test.ts +71 -0
- package/src/contract/outcome.ts +72 -0
- package/src/contract/progress.test.ts +56 -0
- package/src/contract/progress.ts +43 -0
- package/src/contract/tools.test.ts +210 -0
- package/src/contract/tools.ts +212 -0
- package/src/contract/untrusted.test.ts +20 -0
- package/src/contract/untrusted.ts +8 -0
- package/src/helper/artifact.test.ts +131 -0
- package/src/helper/artifact.ts +182 -0
- package/src/helper/protocol.test.ts +29 -0
- package/src/helper/protocol.ts +50 -0
- package/src/{windows → helper}/transport.test.ts +76 -19
- package/src/{windows → helper}/transport.ts +54 -16
- package/src/index.test.ts +112 -0
- package/src/index.ts +36 -53
- package/src/jev/ladder.test.ts +113 -0
- package/src/jev/ladder.ts +88 -0
- package/src/jev/memory.ts +20 -0
- package/src/jev/run.test.ts +700 -0
- package/src/jev/run.ts +443 -0
- package/src/jev/train.test.ts +40 -0
- package/src/jev/train.ts +41 -0
- package/src/linux/helper.test.ts +481 -0
- package/src/linux/profile.ts +25 -0
- package/src/macos/helper.test.ts +988 -0
- package/src/macos/profile.ts +19 -0
- package/src/preview/controller.test.ts +308 -0
- package/src/preview/controller.ts +262 -0
- package/src/preview/surface.test.ts +68 -0
- package/src/preview/surface.ts +45 -0
- package/src/skill.test.ts +34 -0
- package/src/windows/maintenance.ts +8 -7
- package/src/windows/profile.ts +19 -0
- package/dist/shell.d.ts +0 -56
- package/dist/shell.d.ts.map +0 -1
- package/dist/shell.js +0 -189
- package/dist/shell.js.map +0 -1
- package/dist/temporary-files.d.ts +0 -2
- package/dist/temporary-files.d.ts.map +0 -1
- package/dist/temporary-files.js +0 -10
- package/dist/temporary-files.js.map +0 -1
- package/dist/tools/applescript.d.ts +0 -2
- package/dist/tools/applescript.d.ts.map +0 -1
- package/dist/tools/applescript.js +0 -50
- package/dist/tools/applescript.js.map +0 -1
- package/dist/tools/click.d.ts +0 -2
- package/dist/tools/click.d.ts.map +0 -1
- package/dist/tools/click.js +0 -58
- package/dist/tools/click.js.map +0 -1
- package/dist/tools/clipboard.d.ts +0 -2
- package/dist/tools/clipboard.d.ts.map +0 -1
- package/dist/tools/clipboard.js +0 -71
- package/dist/tools/clipboard.js.map +0 -1
- package/dist/tools/key.d.ts +0 -11
- package/dist/tools/key.d.ts.map +0 -1
- package/dist/tools/key.js +0 -143
- package/dist/tools/key.js.map +0 -1
- package/dist/tools/open.d.ts +0 -2
- package/dist/tools/open.d.ts.map +0 -1
- package/dist/tools/open.js +0 -91
- package/dist/tools/open.js.map +0 -1
- package/dist/tools/screenshot.d.ts +0 -2
- package/dist/tools/screenshot.d.ts.map +0 -1
- package/dist/tools/screenshot.js +0 -162
- package/dist/tools/screenshot.js.map +0 -1
- package/dist/tools/type.d.ts +0 -8
- package/dist/tools/type.d.ts.map +0 -1
- package/dist/tools/type.js +0 -65
- package/dist/tools/type.js.map +0 -1
- package/dist/windows/artifact.d.ts +0 -3
- package/dist/windows/artifact.d.ts.map +0 -1
- package/dist/windows/artifact.js +0 -57
- package/dist/windows/artifact.js.map +0 -1
- package/dist/windows/backend.d.ts +0 -11
- package/dist/windows/backend.d.ts.map +0 -1
- package/dist/windows/backend.js +0 -122
- package/dist/windows/backend.js.map +0 -1
- package/dist/windows/contracts.d.ts +0 -1627
- package/dist/windows/contracts.d.ts.map +0 -1
- package/dist/windows/contracts.js +0 -137
- package/dist/windows/contracts.js.map +0 -1
- package/dist/windows/control-service.d.ts +0 -12
- package/dist/windows/control-service.d.ts.map +0 -1
- package/dist/windows/control-service.js +0 -65
- package/dist/windows/control-service.js.map +0 -1
- package/dist/windows/guidance.d.ts +0 -3
- package/dist/windows/guidance.d.ts.map +0 -1
- package/dist/windows/guidance.js +0 -20
- package/dist/windows/guidance.js.map +0 -1
- package/dist/windows/protocol.d.ts +0 -9
- package/dist/windows/protocol.d.ts.map +0 -1
- package/dist/windows/protocol.js +0 -32
- package/dist/windows/protocol.js.map +0 -1
- package/dist/windows/transport.d.ts +0 -23
- package/dist/windows/transport.d.ts.map +0 -1
- package/dist/windows/transport.js.map +0 -1
- package/src/shell.test.ts +0 -186
- package/src/shell.ts +0 -213
- package/src/temporary-files.test.ts +0 -18
- package/src/temporary-files.ts +0 -9
- package/src/tools/applescript-serialize.test.ts +0 -74
- package/src/tools/applescript.ts +0 -53
- package/src/tools/click.ts +0 -60
- package/src/tools/clipboard.ts +0 -72
- package/src/tools/key.ts +0 -155
- package/src/tools/open.ts +0 -96
- package/src/tools/screenshot.test.ts +0 -137
- package/src/tools/screenshot.ts +0 -180
- package/src/tools/type.ts +0 -68
- package/src/tools.test.ts +0 -94
- package/src/windows/action-contracts.test.ts +0 -13
- package/src/windows/artifact.test.ts +0 -16
- package/src/windows/artifact.ts +0 -57
- package/src/windows/backend.test.ts +0 -41
- package/src/windows/backend.ts +0 -122
- package/src/windows/contracts.test.ts +0 -81
- package/src/windows/contracts.ts +0 -143
- package/src/windows/control-service.test.ts +0 -58
- package/src/windows/control-service.ts +0 -68
- package/src/windows/guidance.test.ts +0 -29
- package/src/windows/guidance.ts +0 -21
- package/src/windows/model-contract.test.ts +0 -37
- package/src/windows/protocol.ts +0 -27
- package/src/windows/text-contracts.test.ts +0 -14
- package/src/windows/window-typing.test.ts +0 -14
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { AUTO_APPROVE_PLUGIN_ID, AUTO_APPROVE_SUBTYPE, type AppContext, type ComputerControlService, type MoxxyEvent, type ToolDef, type ToolImageResult } from '@moxxy/sdk';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { REQUEST_ACCESS_TOOL } from './access.js';
|
|
7
|
+
import type { AppHint } from './app-hints.js';
|
|
8
|
+
import { ComputerBackend, type PlatformProfile } from './backend.js';
|
|
9
|
+
import { contractHelperScript, helperRequests, memoryLog, toolContext } from './helper.fixture.js';
|
|
10
|
+
import type { PreviewMessage } from '../preview/controller.js';
|
|
11
|
+
import { CONTRACT_PROTOCOL_VERSION } from './rpc.js';
|
|
12
|
+
import type { AskJev, JevAnswers } from '@moxxy/jev';
|
|
13
|
+
import { RunMemory } from '../jev/memory.js';
|
|
14
|
+
import { removeDirSync } from '@moxxy/vitest-preset/fs';
|
|
15
|
+
|
|
16
|
+
let directory: string;
|
|
17
|
+
let requestsFile: string;
|
|
18
|
+
let events: MoxxyEvent[];
|
|
19
|
+
let backends: ComputerBackend[];
|
|
20
|
+
|
|
21
|
+
const profile = (extra: Partial<PlatformProfile> = {}): PlatformProfile => ({
|
|
22
|
+
...extra,
|
|
23
|
+
platform: 'darwin',
|
|
24
|
+
protocolVersion: CONTRACT_PROTOCOL_VERSION,
|
|
25
|
+
helperPath: process.execPath,
|
|
26
|
+
helperArgs: [contractHelperScript, '--log', requestsFile],
|
|
27
|
+
// Artifact verification has its own tests; the fixture is a script, not a signed binary.
|
|
28
|
+
verifyHelper: async () => undefined,
|
|
29
|
+
unavailableMessage: 'Computer Use helper is missing.',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function backend(hints: AppHint[] = [], extra: Partial<PlatformProfile> = {}, jev?: (apiKey: string) => AskJev): { instance: ComputerBackend; tools: Map<string, ToolDef> } {
|
|
33
|
+
const instance = new ComputerBackend(profile(extra), hints, jev, new RunMemory(join(directory, 'learned'), Date.now, join(directory, 'shipped')));
|
|
34
|
+
backends.push(instance);
|
|
35
|
+
return { instance, tools: new Map(instance.tools().map((tool) => [tool.name, tool])) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let seq = 0;
|
|
39
|
+
const base = (turnId: string) => ({ id: `e${seq}`, seq: seq++, ts: 0, sessionId: 'session', turnId, source: 'system' });
|
|
40
|
+
|
|
41
|
+
async function run(tools: Map<string, ToolDef>, name: string, input: unknown, turnId = 'turn', secrets: Record<string, string> = {}): Promise<unknown> {
|
|
42
|
+
const tool = tools.get(name);
|
|
43
|
+
if (!tool) throw new Error(`no tool ${name}`);
|
|
44
|
+
return tool.handler(tool.inputSchema.parse(input), toolContext(memoryLog(events), { turnId, getSecret: async (secret) => secrets[secret] ?? null }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Record a request the way dispatch does: requested → approved → result. */
|
|
48
|
+
async function requestAccess(tools: Map<string, ToolDef>, input: Record<string, unknown>, turnId = 'turn'): Promise<unknown> {
|
|
49
|
+
const callId = `call-${seq}`;
|
|
50
|
+
events.push({ ...base(turnId), type: 'tool_call_requested', callId, name: REQUEST_ACCESS_TOOL, input } as MoxxyEvent);
|
|
51
|
+
events.push({ ...base(turnId), type: 'tool_call_approved', callId, decidedBy: 'resolver', mode: 'allow' } as MoxxyEvent);
|
|
52
|
+
const output = await run(tools, REQUEST_ACCESS_TOOL, input, turnId);
|
|
53
|
+
events.push({ ...base(turnId), type: 'tool_result', callId, ok: true, output } as MoxxyEvent);
|
|
54
|
+
return output;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const methods = () => helperRequests(requestsFile).map((request) => request.method);
|
|
58
|
+
const forModel = (output: unknown) => (output as ToolImageResult).forModel ?? String(output);
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
directory = mkdtempSync(join(tmpdir(), 'moxxy-computer-backend-'));
|
|
62
|
+
requestsFile = join(directory, 'requests.jsonl');
|
|
63
|
+
events = [];
|
|
64
|
+
backends = [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
await Promise.all(backends.map((instance) => instance.release('session')));
|
|
69
|
+
removeDirSync(directory);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('computer_request_access', () => {
|
|
73
|
+
it('grants resolved apps at their category level and reports the rest', async () => {
|
|
74
|
+
const { tools } = backend();
|
|
75
|
+
const output = await requestAccess(tools, { apps: ['TextEdit', 'Safari', 'Terminal', 'Notes', 'Mail'], reason: 'Edit a note' });
|
|
76
|
+
expect(output).toEqual({
|
|
77
|
+
kind: 'computer_access',
|
|
78
|
+
granted: [
|
|
79
|
+
{ id: 'com.apple.TextEdit', name: 'TextEdit', tier: 'full' },
|
|
80
|
+
{ id: 'com.apple.Safari', name: 'Safari', tier: 'read' },
|
|
81
|
+
{ id: 'com.apple.Terminal', name: 'Terminal', tier: 'click' },
|
|
82
|
+
],
|
|
83
|
+
unresolved: [
|
|
84
|
+
{ app: 'Notes', reason: 'ambiguous', candidates: [{ id: 'com.example.notes.one', name: 'Notes' }, { id: 'com.example.notes.two', name: 'Notes' }] },
|
|
85
|
+
{ app: 'Mail', reason: 'not_found' },
|
|
86
|
+
],
|
|
87
|
+
clipboard_read: false, clipboard_write: false, system_key_combos: false,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('lifts a restricted default only for apps the user approved as full_access', async () => {
|
|
92
|
+
const { tools } = backend();
|
|
93
|
+
const output = await requestAccess(tools, { apps: ['Safari'], reason: 'Fill a form', full_access: ['Safari'] }) as { granted: unknown[] };
|
|
94
|
+
expect(output.granted).toEqual([{ id: 'com.apple.Safari', name: 'Safari', tier: 'full' }]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('an app asked for under another name', () => {
|
|
99
|
+
// The system's name for an app can differ from the one the model used ("System Settings" is "Ustawienia systemowe").
|
|
100
|
+
it('is known by the name it was asked for, not only by the name it was granted under', async () => {
|
|
101
|
+
const { tools } = backend();
|
|
102
|
+
const output = await requestAccess(tools, { apps: ['Text Editor'], reason: 'Edit a note' }) as { granted: unknown[] };
|
|
103
|
+
expect(output.granted).toEqual([{ id: 'com.apple.TextEdit', name: 'TextEdit', tier: 'full' }]);
|
|
104
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'Text Editor' }))).toContain('App: TextEdit');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('stays refused when it was never granted', async () => {
|
|
108
|
+
const { tools } = backend();
|
|
109
|
+
await requestAccess(tools, { apps: ['Text Editor'], reason: 'Edit a note' });
|
|
110
|
+
await expect(run(tools, 'computer_get_app_state', { app: 'Safari' })).rejects.toThrow(/not granted/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('observation', () => {
|
|
115
|
+
it('marks the tools that only look as live state, so the loop guard lets them repeat between actions', () => {
|
|
116
|
+
const { tools } = backend();
|
|
117
|
+
const live = [...tools.values()].filter((tool) => tool.liveState).map((tool) => tool.name).sort();
|
|
118
|
+
expect(live).toEqual(['computer_get_app_state', 'computer_list_apps', 'computer_status', 'computer_zoom']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the two tools a task starts with loaded, so a long tool list costs no round to load them', () => {
|
|
122
|
+
const { tools } = backend();
|
|
123
|
+
const loaded = [...tools.values()].filter((tool) => tool.alwaysLoaded).map((tool) => tool.name).sort();
|
|
124
|
+
expect(loaded).toEqual(['computer_request_access', 'computer_run']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('refuses an app that was not granted without asking the helper', async () => {
|
|
128
|
+
const { tools } = backend();
|
|
129
|
+
await expect(run(tools, 'computer_get_app_state', { app: 'TextEdit' })).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
130
|
+
expect(methods()).not.toContain('get_app_state');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns the indexed tree as untrusted content with the screenshot, then only what changed', async () => {
|
|
134
|
+
const { tools } = backend();
|
|
135
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
136
|
+
const first = await run(tools, 'computer_get_app_state', { app: 'textedit' }) as ToolImageResult;
|
|
137
|
+
expect(first).toMatchObject({ mediaType: 'image/png', base64: 'iVBORw0KGgo=' });
|
|
138
|
+
expect(first.forModel).toContain('<app_content app="TextEdit" trust="untrusted">');
|
|
139
|
+
expect(first.forModel).toContain('[1] text area value="hello" focused');
|
|
140
|
+
expect(first.forModel).toMatch(/Screenshot 800x600/);
|
|
141
|
+
expect(helperRequests(requestsFile).at(-1)).toEqual({ method: 'get_app_state', params: { app: 'com.apple.TextEdit', screenshot: true } });
|
|
142
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).toMatch(/No changes/);
|
|
143
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit', disable_diff: true }))).toContain('[2] button "Save"');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('asks a browser for its page content and says when the page is not readable yet', async () => {
|
|
147
|
+
const { tools } = backend();
|
|
148
|
+
await requestAccess(tools, { apps: ['Safari', 'TextEdit'], reason: 'Read' });
|
|
149
|
+
const page = forModel(await run(tools, 'computer_get_app_state', { app: 'Safari' }));
|
|
150
|
+
const editor = forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }));
|
|
151
|
+
const asked = helperRequests(requestsFile).filter((request) => request.method === 'get_app_state').map((request) => request.params);
|
|
152
|
+
expect(asked).toEqual([{ app: 'com.apple.Safari', screenshot: true, web: true }, { app: 'com.apple.TextEdit', screenshot: true }]);
|
|
153
|
+
expect(page).toMatch(/page content is not readable yet.*look again/i);
|
|
154
|
+
expect(editor).not.toMatch(/not readable yet/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('shows every surface where the agent cursor is and which window it works in', async () => {
|
|
158
|
+
const { instance, tools } = backend();
|
|
159
|
+
const services = new Map<string, unknown>();
|
|
160
|
+
await instance.hooks.onInit?.({ sessionId: 'session', services: { register: (name: string, impl: unknown) => services.set(name, impl) } } as unknown as AppContext);
|
|
161
|
+
const control = services.get('computerControl') as ComputerControlService;
|
|
162
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
163
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
164
|
+
expect((await control.snapshot())[0]).toMatchObject({
|
|
165
|
+
cursor: { phase: 'idle', x: 0.5, y: 0.5 }, target: { app: 'TextEdit', window: 'Untitled' },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('turns a coded helper refusal into a Computer Use error with its hint', async () => {
|
|
170
|
+
const { tools } = backend();
|
|
171
|
+
await requestAccess(tools, { apps: ['Terminal'], reason: 'Read output' });
|
|
172
|
+
await expect(run(tools, 'computer_get_app_state', { app: 'Terminal' }))
|
|
173
|
+
.rejects.toMatchObject({ code: 'permissions_not_granted', message: expect.stringMatching(/System Settings/) });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('marks granted apps in the app list', async () => {
|
|
177
|
+
const { tools } = backend();
|
|
178
|
+
await requestAccess(tools, { apps: ['Safari'], reason: 'Read' });
|
|
179
|
+
const listed = await run(tools, 'computer_list_apps', { query: 'a' }) as { apps: Array<{ id: string; access?: string }> };
|
|
180
|
+
expect(listed.apps.find((app) => app.id === 'com.apple.Safari')?.access).toBe('read');
|
|
181
|
+
expect(listed.apps.find((app) => app.id === 'com.apple.Terminal')?.access).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('actions', () => {
|
|
186
|
+
it('acts on the granted app and returns the outcome with the fresh state', async () => {
|
|
187
|
+
const { tools } = backend();
|
|
188
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
189
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
190
|
+
const output = forModel(await run(tools, 'computer_type_text', { app: 'TextEdit', text: ' world' }));
|
|
191
|
+
expect(output).toMatch(/Action delivered/);
|
|
192
|
+
expect(output).toContain('~ [1] text area value="hello world" focused');
|
|
193
|
+
expect(helperRequests(requestsFile).at(-1)).toEqual({
|
|
194
|
+
method: 'act',
|
|
195
|
+
params: { app: 'com.apple.TextEdit', action: { action: 'type_text', text: ' world' }, allowed: ['com.apple.TextEdit'] },
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('notices an action that leaves the app unchanged and refuses a third try of it', async () => {
|
|
200
|
+
const { tools } = backend();
|
|
201
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
202
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
203
|
+
// Save changes nothing in the scripted app.
|
|
204
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }))).toMatch(/Nothing visible changed/);
|
|
205
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }))).toMatch(/Action ineffective \(no_progress\)/);
|
|
206
|
+
const sent = methods().filter((method) => method === 'act').length;
|
|
207
|
+
await expect(run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 })).rejects.toMatchObject({ code: 'no_progress' });
|
|
208
|
+
expect(methods().filter((method) => method === 'act')).toHaveLength(sent);
|
|
209
|
+
// Typing changes the document: progress, and the count starts over.
|
|
210
|
+
expect(forModel(await run(tools, 'computer_type_text', { app: 'TextEdit', text: '!' }))).not.toMatch(/Nothing visible changed/);
|
|
211
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }))).toMatch(/Nothing visible changed/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not send again an action the helper itself found ineffective', async () => {
|
|
215
|
+
const { tools } = backend();
|
|
216
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
217
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
218
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 4 }))).toMatch(/Nothing visible changed/);
|
|
219
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 4 }))).toMatch(/Action ineffective.*A real click changed nothing either/s);
|
|
220
|
+
const sent = methods().filter((method) => method === 'act').length;
|
|
221
|
+
await expect(run(tools, 'computer_click', { app: 'TextEdit', element_index: 4 })).rejects.toMatchObject({ code: 'no_progress' });
|
|
222
|
+
expect(methods().filter((method) => method === 'act')).toHaveLength(sent);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('names the field of a helper result that breaks the contract', async () => {
|
|
226
|
+
const { tools } = backend();
|
|
227
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
228
|
+
await expect(run(tools, 'computer_type_text', { app: 'TextEdit', text: '<twins>' })).rejects.toMatchObject({
|
|
229
|
+
code: 'helper_failed', message: expect.stringMatching(/invalid act result \(state\.tree\.elements\.4\.key: Duplicate element key/),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('refuses an action above the granted level before it reaches the helper', async () => {
|
|
234
|
+
const { tools } = backend();
|
|
235
|
+
await requestAccess(tools, { apps: ['Safari'], reason: 'Read' });
|
|
236
|
+
await expect(run(tools, 'computer_click', { app: 'Safari', element_index: 2 })).rejects.toMatchObject({ code: 'tier_insufficient' });
|
|
237
|
+
expect(methods()).not.toContain('act');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('hands the helper parsed chords so no helper parses xdotool itself', async () => {
|
|
241
|
+
const { tools } = backend();
|
|
242
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
243
|
+
await run(tools, 'computer_press_key', { app: 'TextEdit', key: 'super+shift+Z' });
|
|
244
|
+
await run(tools, 'computer_click', { app: 'TextEdit', element_index: 1, modifiers: 'cmd+alt' });
|
|
245
|
+
const sent = helperRequests(requestsFile).filter((request) => request.method !== 'resolve_apps');
|
|
246
|
+
expect(sent[0]?.params.action).toMatchObject({ action: 'press_key', key: 'super+shift+Z', chord: { modifiers: ['shift', 'meta'], key: 'z' } });
|
|
247
|
+
expect(sent[1]?.params.action).toMatchObject({ action: 'click', held: ['alt', 'meta'] });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('refuses a system chord without the grant', async () => {
|
|
251
|
+
const { tools } = backend();
|
|
252
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
253
|
+
await expect(run(tools, 'computer_press_key', { app: 'TextEdit', key: 'super+q' })).rejects.toMatchObject({ code: 'system_key_combo' });
|
|
254
|
+
expect(methods()).not.toContain('act');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('sends a drag between two points as a path the helper walks at a pace apps read as a drag', async () => {
|
|
258
|
+
const { tools } = backend();
|
|
259
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
260
|
+
await run(tools, 'computer_drag', { app: 'TextEdit', from_x: 558, from_y: 660, to_x: 340, to_y: 660 });
|
|
261
|
+
expect(helperRequests(requestsFile).at(-1)?.params.action).toEqual({ action: 'drag', path: [[558, 660], [340, 660]], duration_ms: 600, mouse_button: 'left' });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('a run of steps', () => {
|
|
266
|
+
/** Jev that finds an element by a word of its line and believes every expectation. */
|
|
267
|
+
const jev = (keys: string[]) => (apiKey: string): AskJev => async (state, questions) => {
|
|
268
|
+
keys.push(apiKey);
|
|
269
|
+
const { elements, step } = state as { elements: string; step?: { target?: string } };
|
|
270
|
+
const line = elements.split('\n').find((candidate) => step?.target !== undefined && candidate.includes(step.target));
|
|
271
|
+
const index = line ? (/\[(\d+)\]/.exec(line) ?? [])[1] ?? 'none' : 'none';
|
|
272
|
+
return Object.fromEntries(Object.entries(questions).map(([id, question]) =>
|
|
273
|
+
[id, question.type === 'choice' ? { type: 'choice', choice: index, probabilities: { [index]: 0.9 }, confidence: 0.9 } : { type: 'noul', noul: id === 'expected' ? 0.9 : 0 }])) as JevAnswers;
|
|
274
|
+
};
|
|
275
|
+
const steps = [{ do: 'type', target: 'text area', text: ' world', expect: 'the text ends with world' }, { do: 'key', key: 'Return' }];
|
|
276
|
+
|
|
277
|
+
it('carries out the steps through the helper with the key from the vault, and returns the report with the fresh state', async () => {
|
|
278
|
+
const keys: string[] = [];
|
|
279
|
+
const { tools } = backend([], {}, jev(keys));
|
|
280
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
281
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }, 'turn', { TYPESAFE_API_KEY: 'vault-key' }));
|
|
282
|
+
expect(output).toMatch(/computer_run: 2 of 2 steps done/);
|
|
283
|
+
expect(output).toContain('1. verified — type "text area" " world" → [1] text area value="hello" focused');
|
|
284
|
+
expect(output).toContain('[1] text area value="hello world" focused');
|
|
285
|
+
expect(new Set(keys)).toEqual(new Set(['vault-key']));
|
|
286
|
+
expect(helperRequests(requestsFile).filter((request) => request.method === 'act').map((request) => request.params.action)).toEqual([
|
|
287
|
+
{ action: 'type_text', element_index: 1, text: ' world' },
|
|
288
|
+
{ action: 'press_key', key: 'Return', repeat: 1, chord: { modifiers: [], key: 'enter' } },
|
|
289
|
+
]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('clicks what the screenshot reads as the target when no element is named so', async () => {
|
|
293
|
+
const { tools } = backend([], {}, jev([]));
|
|
294
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
295
|
+
await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Publish', steps: [{ do: 'click', target: 'Publish' }] }, 'turn', { TYPESAFE_API_KEY: 'vault-key' });
|
|
296
|
+
const sent = helperRequests(requestsFile);
|
|
297
|
+
expect(sent.find((request) => request.method === 'read_text')?.params).toEqual({ app: 'com.apple.TextEdit', allowed: ['com.apple.TextEdit'] });
|
|
298
|
+
expect(sent.filter((request) => request.method === 'act').map((request) => request.params.action)).toEqual([
|
|
299
|
+
expect.objectContaining({ action: 'click', x: 330, y: 50 }),
|
|
300
|
+
]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('without a TypeSafe key answers with the state and no error, and touches nothing', async () => {
|
|
304
|
+
vi.stubEnv('TYPESAFE_API_KEY', '');
|
|
305
|
+
try {
|
|
306
|
+
const { tools } = backend([], {}, jev([]));
|
|
307
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
308
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }));
|
|
309
|
+
expect(output).toMatch(/computer_run is off[\s\S]*single tools/);
|
|
310
|
+
expect(output).toContain('[1] text area value="hello" focused');
|
|
311
|
+
expect(methods()).not.toContain('act');
|
|
312
|
+
} finally { vi.unstubAllEnvs(); }
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('switched off in the vault answers like no key, even with one stored', async () => {
|
|
316
|
+
const keys: string[] = [];
|
|
317
|
+
const { tools } = backend([], {}, jev(keys));
|
|
318
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
319
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }, 'turn', { TYPESAFE_API_KEY: 'vault-key', JEV_DISABLED: '1' }));
|
|
320
|
+
expect(output).toMatch(/computer_run is off[\s\S]*single tools/);
|
|
321
|
+
expect(keys).toEqual([]);
|
|
322
|
+
expect(methods()).not.toContain('act');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('offered only with a key', () => {
|
|
326
|
+
const tool = (name: string) => ({ name, description: '', inputSchema: {} }) as never;
|
|
327
|
+
const offered = async (instance: ComputerBackend) => {
|
|
328
|
+
const request = { model: 'm', messages: [], tools: [tool('computer_run'), tool('computer_click'), tool('Read')] };
|
|
329
|
+
const hook = instance.hooks.onBeforeProviderCall as NonNullable<typeof instance.hooks.onBeforeProviderCall>;
|
|
330
|
+
const guided = (await hook(request, { sessionId: 'session', turnId: 'turn' } as never)) ?? request;
|
|
331
|
+
return { names: (guided.tools ?? []).map((entry) => entry.name), system: guided.system ?? '' };
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
it('hides computer_run and every word about it while there is no key', async () => {
|
|
335
|
+
vi.stubEnv('TYPESAFE_API_KEY', '');
|
|
336
|
+
try {
|
|
337
|
+
const { instance, tools } = backend([], {}, jev([]));
|
|
338
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
339
|
+
const { names, system } = await offered(instance);
|
|
340
|
+
expect(names).toEqual(['computer_click', 'Read']);
|
|
341
|
+
expect(system).not.toMatch(/computer_run/);
|
|
342
|
+
expect(system).toMatch(/several tool calls in one response/);
|
|
343
|
+
} finally { vi.unstubAllEnvs(); }
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('offers it once a tool call has seen the key in the vault', async () => {
|
|
347
|
+
vi.stubEnv('TYPESAFE_API_KEY', '');
|
|
348
|
+
try {
|
|
349
|
+
const { instance, tools } = backend([], {}, jev([]));
|
|
350
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
351
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'turn', { TYPESAFE_API_KEY: 'vault-key' });
|
|
352
|
+
const { names, system } = await offered(instance);
|
|
353
|
+
expect(names).toContain('computer_run');
|
|
354
|
+
expect(system).toMatch(/computer_run/);
|
|
355
|
+
} finally { vi.unstubAllEnvs(); }
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('hides it once a tool call has seen it switched off in the vault', async () => {
|
|
359
|
+
vi.stubEnv('TYPESAFE_API_KEY', 'env-key');
|
|
360
|
+
try {
|
|
361
|
+
const { instance, tools } = backend([], {}, jev([]));
|
|
362
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
363
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'turn', { JEV_DISABLED: '1' });
|
|
364
|
+
const { names, system } = await offered(instance);
|
|
365
|
+
expect(names).toEqual(['computer_click', 'Read']);
|
|
366
|
+
expect(system).not.toMatch(/computer_run/);
|
|
367
|
+
} finally { vi.unstubAllEnvs(); }
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('offers it from the first request when the key is in the environment', async () => {
|
|
371
|
+
vi.stubEnv('TYPESAFE_API_KEY', 'env-key');
|
|
372
|
+
try {
|
|
373
|
+
const { names } = await offered(backend([], {}, jev([])).instance);
|
|
374
|
+
expect(names).toContain('computer_run');
|
|
375
|
+
} finally { vi.unstubAllEnvs(); }
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('learning', () => {
|
|
380
|
+
const secrets = { TYPESAFE_API_KEY: 'k' };
|
|
381
|
+
/** Like `jev`, and records which questions each request asked. */
|
|
382
|
+
const recording = (asked: string[][]) => (): AskJev => async (state, questions) => {
|
|
383
|
+
asked.push(Object.keys(questions));
|
|
384
|
+
return jev([])('k')(state, questions, new AbortController().signal);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
it('remembers the element of a verified step: the same step later asks Jev only to check it', async () => {
|
|
388
|
+
const asked: string[][] = [];
|
|
389
|
+
const { tools } = backend([], {}, recording(asked));
|
|
390
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
391
|
+
await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }, 'turn', secrets);
|
|
392
|
+
expect(asked.flat()).toContain('target');
|
|
393
|
+
asked.length = 0;
|
|
394
|
+
const again = backend([], {}, recording(asked));
|
|
395
|
+
const output = forModel(await run(again.tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }, 'later', secrets));
|
|
396
|
+
expect(output).toMatch(/2 of 2 steps done[\s\S]*1\. verified/);
|
|
397
|
+
expect(output).toMatch(/remembered/);
|
|
398
|
+
expect(asked.flat()).not.toContain('target');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('shows the routes that reached their end with the app\'s first state of a turn', async () => {
|
|
402
|
+
const { tools } = backend([], {}, jev([]));
|
|
403
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
404
|
+
await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Add a word', steps }, 'turn', secrets);
|
|
405
|
+
const first = forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'later', secrets));
|
|
406
|
+
expect(first).toMatch(/Routes that worked in this app before[\s\S]*Add a word[\s\S]*"target":"text area"/);
|
|
407
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'later', secrets))).not.toMatch(/Routes that worked/);
|
|
408
|
+
vi.stubEnv('TYPESAFE_API_KEY', '');
|
|
409
|
+
try {
|
|
410
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'no-key'))).not.toMatch(/Routes that worked/);
|
|
411
|
+
} finally { vi.unstubAllEnvs(); }
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('learns from the model: the element it used after a step failed is the step\'s element next time', async () => {
|
|
415
|
+
const asked: string[][] = [];
|
|
416
|
+
const { tools } = backend([], {}, recording(asked));
|
|
417
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
418
|
+
const note = [{ do: 'type', target: 'the notes box', text: ' x', expect: 'the text ends with x' }];
|
|
419
|
+
expect(forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Note', steps: note }, 'turn', secrets))).toMatch(/1\. FAILED/);
|
|
420
|
+
await run(tools, 'computer_type_text', { app: 'TextEdit', element_index: 1, text: ' x' }, 'turn', secrets);
|
|
421
|
+
asked.length = 0;
|
|
422
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Note', steps: note }, 'later', secrets));
|
|
423
|
+
expect(output).toMatch(/1\. verified/);
|
|
424
|
+
expect(asked.flat()).not.toContain('target');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// In Canva a click on a link only lit it up, and that link was then remembered as "the create button".
|
|
428
|
+
it('does not learn an element whose click changed only the picture, such as a hover highlight', async () => {
|
|
429
|
+
const asked: string[][] = [];
|
|
430
|
+
const { tools } = backend([], {}, recording(asked));
|
|
431
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
432
|
+
await run(tools, 'computer_type_text', { app: 'TextEdit', element_index: 1, text: '<hover>' }, 'turn', secrets);
|
|
433
|
+
const create = [{ do: 'click', target: 'the create button', expect: 'a new design opens' }];
|
|
434
|
+
expect(forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Create', steps: create }, 'turn', secrets))).toMatch(/1\. FAILED/);
|
|
435
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }, 'turn', secrets))).toMatch(/No changes since the previous state/);
|
|
436
|
+
asked.length = 0;
|
|
437
|
+
await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Create', steps: create }, 'later', secrets);
|
|
438
|
+
expect(asked.flat()).toContain('target');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('looks at the window without a picture for the run, and hands the model one at the end', async () => {
|
|
443
|
+
const { tools } = backend([], {}, jev([]));
|
|
444
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
445
|
+
const output = await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Note', steps: [{ do: 'type', target: 'the missing box', text: 'x' }] }, 'turn', { TYPESAFE_API_KEY: 'k' }) as ToolImageResult;
|
|
446
|
+
expect(output.forModel).toMatch(/1\. FAILED/);
|
|
447
|
+
expect(output.base64).toEqual(expect.any(String));
|
|
448
|
+
const looks = helperRequests(requestsFile).filter((request) => request.method === 'get_app_state').map((request) => (request.params as { screenshot: boolean }).screenshot);
|
|
449
|
+
expect(looks).toEqual([false, false, true]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('sends steps that check nothing and need no element as one batch on macOS', async () => {
|
|
453
|
+
const { tools } = backend([], {}, jev([]));
|
|
454
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
455
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'TextEdit', goal: 'Note', steps: [{ do: 'type', text: ' a' }, { do: 'key', key: 'Return' }] }, 'turn', { TYPESAFE_API_KEY: 'k' }));
|
|
456
|
+
expect(output).toMatch(/2 of 2 steps done/);
|
|
457
|
+
expect(methods()).toContain('batch');
|
|
458
|
+
expect(methods()).not.toContain('act');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('stops at a step the grant does not allow and leaves the rest', async () => {
|
|
462
|
+
const { tools } = backend([], {}, jev([]));
|
|
463
|
+
await requestAccess(tools, { apps: ['Safari'], reason: 'Read' });
|
|
464
|
+
const output = forModel(await run(tools, 'computer_run', { app: 'Safari', goal: 'Save', steps: [{ do: 'click', target: 'Save' }, { do: 'key', key: 'Return' }] }, 'turn', { TYPESAFE_API_KEY: 'k' }));
|
|
465
|
+
expect(output).toMatch(/0 of 2 steps[\s\S]*1\. FAILED[\s\S]*tier_insufficient[\s\S]*Not run: step 2/);
|
|
466
|
+
expect(methods()).not.toContain('act');
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe('on an app not granted yet', () => {
|
|
470
|
+
/** Record a run the way dispatch does before the handler: requested, then approved. */
|
|
471
|
+
const approveRun = (input: Record<string, unknown>, approval: Record<string, unknown>) => {
|
|
472
|
+
const callId = `call-${seq}`;
|
|
473
|
+
events.push({ ...base('turn'), type: 'tool_call_requested', callId, name: 'computer_run', input } as MoxxyEvent);
|
|
474
|
+
events.push({ ...base('turn'), type: 'tool_call_approved', callId, decidedBy: 'resolver', mode: 'allow', ...approval } as MoxxyEvent);
|
|
475
|
+
};
|
|
476
|
+
const secrets = { TYPESAFE_API_KEY: 'k' };
|
|
477
|
+
|
|
478
|
+
it('runs when the user approved that very call, and the app stays granted for the conversation', async () => {
|
|
479
|
+
const { tools } = backend([], {}, jev([]));
|
|
480
|
+
const input = { app: 'TextEdit', goal: 'Add a word', steps };
|
|
481
|
+
approveRun(input, { decidedNow: true });
|
|
482
|
+
expect(forModel(await run(tools, 'computer_run', input, 'turn', secrets))).toMatch(/computer_run: 2 of 2 steps done/);
|
|
483
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).toContain('App: TextEdit');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('is refused when a standing rule let the call through: nobody was asked about this app', async () => {
|
|
487
|
+
const { tools } = backend([], {}, jev([]));
|
|
488
|
+
const input = { app: 'TextEdit', goal: 'Add a word', steps };
|
|
489
|
+
approveRun(input, {});
|
|
490
|
+
await expect(run(tools, 'computer_run', input, 'turn', secrets)).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
491
|
+
expect(methods()).not.toContain('act');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('gives a browser reached this way its read-only default', async () => {
|
|
495
|
+
const { tools } = backend([], {}, jev([]));
|
|
496
|
+
const input = { app: 'Safari', goal: 'Save', steps: [{ do: 'click', target: 'Save' }] };
|
|
497
|
+
approveRun(input, { decidedNow: true });
|
|
498
|
+
expect(forModel(await run(tools, 'computer_run', input, 'turn', secrets))).toMatch(/0 of 1 steps[\s\S]*tier_insufficient/);
|
|
499
|
+
expect(methods()).not.toContain('act');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('keeps the level the user chose in the access dialog over the default of a later run', async () => {
|
|
503
|
+
const { tools } = backend([], {}, jev([]));
|
|
504
|
+
await requestAccess(tools, { apps: ['Safari'], reason: 'Fill a form', full_access: ['Safari'] });
|
|
505
|
+
const input = { app: 'Safari', goal: 'Save', steps: [{ do: 'click', target: 'Save' }] };
|
|
506
|
+
approveRun(input, { decidedNow: true });
|
|
507
|
+
expect(forModel(await run(tools, 'computer_run', input, 'turn', secrets))).not.toMatch(/tier_insufficient/);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe('while the conversation auto-approves', () => {
|
|
511
|
+
const autoApprove = (enabled: boolean) => {
|
|
512
|
+
events.push({ ...base('turn'), type: 'plugin_event', pluginId: AUTO_APPROVE_PLUGIN_ID, subtype: AUTO_APPROVE_SUBTYPE, payload: { enabled } } as MoxxyEvent);
|
|
513
|
+
};
|
|
514
|
+
const input = { app: 'Safari', goal: 'Search', steps: [{ do: 'key', key: 'Return' }] };
|
|
515
|
+
|
|
516
|
+
// Auto-approve skips the prompt, never the policy: full control still comes from a request the policy sees.
|
|
517
|
+
it('keeps a browser it reached through a run at its default level', async () => {
|
|
518
|
+
const { tools } = backend([], {}, jev([]));
|
|
519
|
+
autoApprove(true);
|
|
520
|
+
approveRun(input, { decidedNow: true });
|
|
521
|
+
expect(forModel(await run(tools, 'computer_run', input, 'turn', secrets))).toMatch(/tier_insufficient/);
|
|
522
|
+
expect(methods()).not.toContain('act');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('lets a run press keys once full control was requested', async () => {
|
|
526
|
+
const { tools } = backend([], {}, jev([]));
|
|
527
|
+
autoApprove(true);
|
|
528
|
+
await requestAccess(tools, { apps: ['Safari'], reason: 'Search', full_access: ['Safari'] });
|
|
529
|
+
expect(forModel(await run(tools, 'computer_run', input, 'turn', secrets))).not.toMatch(/tier_insufficient/);
|
|
530
|
+
expect(methods()).toContain('act');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('says which app it could not find', async () => {
|
|
535
|
+
const { tools } = backend([], {}, jev([]));
|
|
536
|
+
const input = { app: 'Mail', goal: 'Read', steps: [{ do: 'click', target: 'Inbox' }] };
|
|
537
|
+
approveRun(input, { decidedNow: true });
|
|
538
|
+
await expect(run(tools, 'computer_run', input, 'turn', secrets)).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('may reach TypeSafe and nothing else on the network', () => {
|
|
543
|
+
const { tools } = backend();
|
|
544
|
+
expect(tools.get('computer_run')?.isolation?.capabilities.net).toEqual({ mode: 'allowlist', hosts: ['api.typesafe.ai'] });
|
|
545
|
+
expect(tools.get('computer_click')?.isolation?.capabilities.net).toEqual({ mode: 'none' });
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe('a closer look', () => {
|
|
550
|
+
it('zooms into the screenshot of a granted app', async () => {
|
|
551
|
+
const { tools } = backend();
|
|
552
|
+
await expect(run(tools, 'computer_zoom', { app: 'Safari', region: [0, 0, 100, 50] })).rejects.toMatchObject({ code: 'app_not_allowed' });
|
|
553
|
+
await requestAccess(tools, { apps: ['TextEdit', 'Safari'], reason: 'Compare' });
|
|
554
|
+
const zoom = await run(tools, 'computer_zoom', { region: [0, 0, 100, 50], app: 'Safari' }) as ToolImageResult;
|
|
555
|
+
expect(helperRequests(requestsFile).at(-1)).toEqual({ method: 'zoom', params: { region: [0, 0, 100, 50], app: 'com.apple.Safari', allowed: ['com.apple.TextEdit', 'com.apple.Safari'] } });
|
|
556
|
+
expect(zoom.forModel).toMatch(/Reading aid/);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('one state for every client and turn lifecycle', () => {
|
|
561
|
+
it('honours a grant recorded by another client of the same session', async () => {
|
|
562
|
+
const desktop = backend();
|
|
563
|
+
await requestAccess(desktop.tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
564
|
+
const telegram = backend();
|
|
565
|
+
expect(forModel(await run(telegram.tools, 'computer_get_app_state', { app: 'TextEdit' }))).toContain('[0] window "Untitled"');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('starts every turn from a full tree on a fresh helper and closes it at turn end', async () => {
|
|
569
|
+
const { instance, tools } = backend();
|
|
570
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' }, 'one');
|
|
571
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'one');
|
|
572
|
+
await instance.hooks.onTurnEnd?.({ sessionId: 'session', turnId: 'one', iteration: 0 } as unknown as Parameters<NonNullable<typeof instance.hooks.onTurnEnd>>[0]);
|
|
573
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'two'))).toContain('[0] window "Untitled"');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('keeps Computer Use stopped for the rest of the turn after the panel Stop', async () => {
|
|
577
|
+
const { instance, tools } = backend();
|
|
578
|
+
const services = new Map<string, unknown>();
|
|
579
|
+
await instance.hooks.onInit?.({ sessionId: 'session', services: { register: (name: string, impl: unknown) => services.set(name, impl) } } as unknown as AppContext);
|
|
580
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit' });
|
|
581
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
582
|
+
const control = services.get('computerControl') as { control(input: unknown): Promise<void> };
|
|
583
|
+
await control.control({ sessionId: 'session', turnId: 'turn', command: 'stop' });
|
|
584
|
+
await expect(run(tools, 'computer_get_app_state', { app: 'TextEdit' })).rejects.toThrow(/stopped for this turn/);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('computer_status', () => {
|
|
589
|
+
it('reports what the helper may do and which permission is missing, without needing access to any app', async () => {
|
|
590
|
+
const { tools } = backend();
|
|
591
|
+
expect(await run(tools, 'computer_status', {})).toEqual({
|
|
592
|
+
platform: 'darwin', ready: false,
|
|
593
|
+
permissions: { accessibility: true, screenRecording: false },
|
|
594
|
+
limitations: ['Screen Recording is not allowed: the helper cannot capture windows.'],
|
|
595
|
+
});
|
|
596
|
+
expect(methods()).toEqual(['status']);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('opens the system settings pane for a missing permission when asked', async () => {
|
|
600
|
+
const { tools } = backend();
|
|
601
|
+
expect(await run(tools, 'computer_status', { open_settings: 'screen_recording' })).toMatchObject({ ready: false, settings_opened: true });
|
|
602
|
+
expect(helperRequests(requestsFile).at(-1)).toEqual({ method: 'permissions.request', params: { kind: 'screen_recording' } });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('leaves the settings alone when the permission it is asked about is already allowed', async () => {
|
|
606
|
+
const { tools } = backend();
|
|
607
|
+
const status = await run(tools, 'computer_status', { open_settings: 'accessibility' });
|
|
608
|
+
expect(status).toMatchObject({ ready: false });
|
|
609
|
+
expect(status).not.toHaveProperty('settings_opened');
|
|
610
|
+
expect(helperRequests(requestsFile).map((request) => request.method)).not.toContain('permissions.request');
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe('app hints', () => {
|
|
615
|
+
const hints: AppHint[] = [{ apps: ['com.apple.textedit'], text: 'Prefer set_value for whole documents.' }];
|
|
616
|
+
|
|
617
|
+
it('shows an app\'s hint with its first state in a turn, and not again until the next turn', async () => {
|
|
618
|
+
const { tools } = backend(hints);
|
|
619
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
620
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).toContain('Prefer set_value for whole documents.');
|
|
621
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).not.toContain('Prefer set_value');
|
|
622
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }))).not.toContain('Prefer set_value');
|
|
623
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }, 'next-turn'))).toContain('Prefer set_value for whole documents.');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('shows the hint with the first action when the model acts before looking', async () => {
|
|
627
|
+
const { tools } = backend(hints);
|
|
628
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
629
|
+
expect(forModel(await run(tools, 'computer_click', { app: 'TextEdit', element_index: 2 }))).toContain('Prefer set_value for whole documents.');
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('file panel notes', () => {
|
|
634
|
+
it('tells how to choose a file when an open or save panel shows, once in a turn', async () => {
|
|
635
|
+
const { tools } = backend();
|
|
636
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Open a file' });
|
|
637
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).not.toContain('File dialog');
|
|
638
|
+
const opened = forModel(await run(tools, 'computer_type_text', { app: 'TextEdit', text: '<file-panel>' }));
|
|
639
|
+
expect(opened).toContain('File dialog');
|
|
640
|
+
expect(opened).toMatch(/super\+shift\+g/);
|
|
641
|
+
expect(opened).toMatch(/selected even when the list does not show it/);
|
|
642
|
+
expect(forModel(await run(tools, 'computer_get_app_state', { app: 'TextEdit' }))).not.toContain('File dialog');
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe('live preview', () => {
|
|
647
|
+
const waitFor = async (done: () => boolean) => {
|
|
648
|
+
for (let tries = 0; tries < 200 && !done(); tries++) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
649
|
+
expect(done()).toBe(true);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
it('captures for a viewer of the turn that uses the computer, and ends with that turn', async () => {
|
|
653
|
+
const { instance, tools } = backend();
|
|
654
|
+
const messages: PreviewMessage[] = [];
|
|
655
|
+
instance.preview.subscribe((message) => messages.push(message));
|
|
656
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
657
|
+
await waitFor(() => messages.some((message) => message.type === 'frame'));
|
|
658
|
+
expect(messages.find((message) => message.type === 'frame')).toEqual({
|
|
659
|
+
type: 'frame', seq: 1, image: { mediaType: 'image/jpeg', base64: 'frame@2', width: 640, height: 400 },
|
|
660
|
+
});
|
|
661
|
+
expect(helperRequests(requestsFile).filter((request) => request.method === 'preview.start')).toEqual([{ method: 'preview.start', params: { fps: 2 } }]);
|
|
662
|
+
await instance.release('session', 'turn');
|
|
663
|
+
expect(messages.at(-1)).toEqual({ type: 'state', state: 'stopped' });
|
|
664
|
+
expect(instance.preview.snapshot()).toEqual({ state: 'stopped' });
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('shows no picture after the user stops Computer Use', async () => {
|
|
668
|
+
const { instance, tools } = backend();
|
|
669
|
+
const services = new Map<string, unknown>();
|
|
670
|
+
await instance.hooks.onInit?.({ sessionId: 'session', services: { register: (name: string, impl: unknown) => services.set(name, impl) } } as unknown as AppContext);
|
|
671
|
+
const messages: PreviewMessage[] = [];
|
|
672
|
+
instance.preview.subscribe((message) => messages.push(message));
|
|
673
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
674
|
+
await waitFor(() => messages.some((message) => message.type === 'frame'));
|
|
675
|
+
const control = services.get('computerControl') as { control(input: unknown): Promise<void> };
|
|
676
|
+
await control.control({ sessionId: 'session', turnId: 'turn', command: 'stop' });
|
|
677
|
+
await waitFor(() => instance.preview.snapshot().state === 'stopped');
|
|
678
|
+
expect(instance.preview.snapshot()).toEqual({ state: 'stopped' });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('never asks the helper to capture when nobody watches', async () => {
|
|
682
|
+
const { tools } = backend();
|
|
683
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
684
|
+
await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
685
|
+
expect(methods()).not.toContain('preview.start');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('keeps frames away from the model: no tool result carries a preview frame', async () => {
|
|
689
|
+
const { instance, tools } = backend();
|
|
690
|
+
instance.preview.subscribe(() => undefined);
|
|
691
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
692
|
+
const output = await run(tools, 'computer_get_app_state', { app: 'TextEdit' });
|
|
693
|
+
expect(JSON.stringify(output)).not.toContain('frame@');
|
|
694
|
+
expect(JSON.stringify(events)).not.toContain('frame@');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('asks a helper that makes video for video when the viewer can decode it, and for a key frame on demand', async () => {
|
|
698
|
+
const { instance, tools } = backend([], { previewCodecs: ['h264', 'jpeg'] });
|
|
699
|
+
const messages: PreviewMessage[] = [];
|
|
700
|
+
const listener = (message: PreviewMessage) => { messages.push(message); };
|
|
701
|
+
instance.preview.subscribe(listener, ['h264', 'jpeg']);
|
|
702
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
703
|
+
await waitFor(() => messages.some((message) => message.type === 'chunk'));
|
|
704
|
+
expect(messages.find((message) => message.type === 'chunk')).toEqual({
|
|
705
|
+
type: 'chunk', seq: 1, key: true, codec: 'avc1.4d001f', data: 'dmlkZW8=', timestamp: 0, width: 640, height: 400,
|
|
706
|
+
});
|
|
707
|
+
instance.preview.keyframe(listener);
|
|
708
|
+
await waitFor(() => methods().includes('preview.keyframe'));
|
|
709
|
+
expect(helperRequests(requestsFile).find((request) => request.method === 'preview.start')).toEqual({ method: 'preview.start', params: { fps: 30, codec: 'h264' } });
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('asks a helper without video for pictures, with no codec in the request', async () => {
|
|
713
|
+
const { instance, tools } = backend();
|
|
714
|
+
const messages: PreviewMessage[] = [];
|
|
715
|
+
instance.preview.subscribe((message) => messages.push(message), ['h264', 'jpeg']);
|
|
716
|
+
await requestAccess(tools, { apps: ['TextEdit'], reason: 'Edit a note' });
|
|
717
|
+
await waitFor(() => messages.some((message) => message.type === 'frame'));
|
|
718
|
+
expect(helperRequests(requestsFile).filter((request) => request.method.startsWith('preview.'))).toEqual([{ method: 'preview.start', params: { fps: 2 } }]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('offers the preview as a surface of the plugin', () => {
|
|
722
|
+
const { instance } = backend();
|
|
723
|
+
expect(instance.surfaces().map((surface) => surface.kind)).toEqual(['computer-preview']);
|
|
724
|
+
});
|
|
725
|
+
});
|