@frockbot/plugin-computer 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +0 -1
- package/package.json +12 -12
- package/src/agent.ts +74 -70
- package/src/backend.ts +4 -4
- package/src/bot.test.ts +566 -3
- package/src/bot.ts +410 -10
- package/src/capture.ts +107 -0
- package/src/client/ComputerCard.test.ts +24 -4
- package/src/client/ComputerCard.vue +108 -16
- package/src/client/ComputerViewerOverlay.vue +67 -10
- package/src/client/application.test.ts +93 -15
- package/src/client/application.ts +50 -12
- package/src/client/index.ts +0 -6
- package/src/client/progress.test.ts +148 -0
- package/src/client/progress.ts +171 -0
- package/src/client/state-machine.test.ts +1 -1
- package/src/client/styles.css +75 -101
- package/src/protocol.test.ts +37 -0
- package/src/protocol.ts +111 -1
- package/src/screenshot.test.ts +100 -3
- package/src/workspace-fixture.ts +4 -0
- package/src/client/ComputerStrip.test.ts +0 -54
- package/src/client/ComputerStrip.vue +0 -55
package/src/protocol.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const COMPUTER_COMMAND_TYPES = [
|
|
|
9
9
|
"releaseControl",
|
|
10
10
|
"refreshControl",
|
|
11
11
|
"refreshViewer",
|
|
12
|
+
"closeViewer",
|
|
12
13
|
"runDoctor",
|
|
13
14
|
] as const;
|
|
14
15
|
|
|
@@ -70,6 +71,15 @@ export interface ComputerProgressStepViewV1 {
|
|
|
70
71
|
status: ComputerProgressStepStatusV1;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
export interface ComputerProvisioningProgressViewV1 {
|
|
75
|
+
version: 1;
|
|
76
|
+
kind: "provision" | "update";
|
|
77
|
+
label: string;
|
|
78
|
+
index: number;
|
|
79
|
+
total: number;
|
|
80
|
+
resumed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
/** Durable progress projected from the Bot authority; it contains no secrets. */
|
|
74
84
|
export interface ComputerProgressViewV1 {
|
|
75
85
|
version: 1;
|
|
@@ -78,6 +88,7 @@ export interface ComputerProgressViewV1 {
|
|
|
78
88
|
updatedAt: string;
|
|
79
89
|
index: number;
|
|
80
90
|
total: number;
|
|
91
|
+
provisioning?: ComputerProvisioningProgressViewV1;
|
|
81
92
|
steps: ComputerProgressStepViewV1[];
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -136,6 +147,22 @@ export type ComputerCommandReceiptV1 =
|
|
|
136
147
|
failure: string;
|
|
137
148
|
};
|
|
138
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A connect command has been durably admitted and scheduled. Version 2 is a
|
|
152
|
+
* distinct wire shape from the terminal version 1 receipt: it never claims
|
|
153
|
+
* that the Computer effect has completed.
|
|
154
|
+
*/
|
|
155
|
+
export interface ComputerCommandAcceptedV2 {
|
|
156
|
+
version: 2;
|
|
157
|
+
commandId: string;
|
|
158
|
+
type: "connect";
|
|
159
|
+
status: "accepted";
|
|
160
|
+
admittedAt: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ComputerCommandResponse =
|
|
164
|
+
ComputerCommandReceiptV1 | ComputerCommandAcceptedV2;
|
|
165
|
+
|
|
139
166
|
export class ComputerProtocolDecodeError extends Error {
|
|
140
167
|
override readonly name = "ComputerProtocolDecodeError";
|
|
141
168
|
}
|
|
@@ -238,6 +265,46 @@ function decodeProgressStepV1(value: unknown): ComputerProgressStepViewV1 {
|
|
|
238
265
|
};
|
|
239
266
|
}
|
|
240
267
|
|
|
268
|
+
function decodeProvisioningProgressV1(
|
|
269
|
+
value: unknown,
|
|
270
|
+
): ComputerProvisioningProgressViewV1 {
|
|
271
|
+
const candidate = record(value, "Computer provisioning progress");
|
|
272
|
+
exactKeys(
|
|
273
|
+
candidate,
|
|
274
|
+
["version", "kind", "label", "index", "total", "resumed"],
|
|
275
|
+
[],
|
|
276
|
+
"Computer provisioning progress",
|
|
277
|
+
);
|
|
278
|
+
if (
|
|
279
|
+
candidate.version !== 1 ||
|
|
280
|
+
(candidate.kind !== "provision" && candidate.kind !== "update") ||
|
|
281
|
+
typeof candidate.resumed !== "boolean"
|
|
282
|
+
) {
|
|
283
|
+
throw new ComputerProtocolDecodeError(
|
|
284
|
+
"Computer provisioning progress is invalid",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const total = boundedInteger(
|
|
288
|
+
candidate.total,
|
|
289
|
+
1,
|
|
290
|
+
1_000,
|
|
291
|
+
"Computer provisioning progress total",
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
version: 1,
|
|
295
|
+
kind: candidate.kind,
|
|
296
|
+
label: text(candidate.label, "Computer provisioning progress label"),
|
|
297
|
+
index: boundedInteger(
|
|
298
|
+
candidate.index,
|
|
299
|
+
0,
|
|
300
|
+
total,
|
|
301
|
+
"Computer provisioning progress index",
|
|
302
|
+
),
|
|
303
|
+
total,
|
|
304
|
+
resumed: candidate.resumed,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
241
308
|
export function decodeComputerProgressViewV1(
|
|
242
309
|
value: unknown,
|
|
243
310
|
): ComputerProgressViewV1 {
|
|
@@ -245,7 +312,7 @@ export function decodeComputerProgressViewV1(
|
|
|
245
312
|
exactKeys(
|
|
246
313
|
candidate,
|
|
247
314
|
["version", "kind", "startedAt", "updatedAt", "index", "total", "steps"],
|
|
248
|
-
[],
|
|
315
|
+
["provisioning"],
|
|
249
316
|
"Computer progress",
|
|
250
317
|
);
|
|
251
318
|
if (
|
|
@@ -282,6 +349,11 @@ export function decodeComputerProgressViewV1(
|
|
|
282
349
|
updatedAt: timestamp(candidate.updatedAt, "Computer progress updatedAt"),
|
|
283
350
|
index,
|
|
284
351
|
total,
|
|
352
|
+
...(candidate.provisioning === undefined
|
|
353
|
+
? {}
|
|
354
|
+
: {
|
|
355
|
+
provisioning: decodeProvisioningProgressV1(candidate.provisioning),
|
|
356
|
+
}),
|
|
285
357
|
steps,
|
|
286
358
|
};
|
|
287
359
|
}
|
|
@@ -507,6 +579,44 @@ export function decodeComputerCommandReceiptV1(
|
|
|
507
579
|
);
|
|
508
580
|
}
|
|
509
581
|
|
|
582
|
+
export function decodeComputerCommandResponse(
|
|
583
|
+
value: unknown,
|
|
584
|
+
): ComputerCommandResponse {
|
|
585
|
+
if (
|
|
586
|
+
value &&
|
|
587
|
+
typeof value === "object" &&
|
|
588
|
+
!Array.isArray(value) &&
|
|
589
|
+
(value as Record<string, unknown>).version === 2
|
|
590
|
+
) {
|
|
591
|
+
const candidate = record(value, "Computer command acceptance");
|
|
592
|
+
exactKeys(
|
|
593
|
+
candidate,
|
|
594
|
+
["version", "commandId", "type", "status", "admittedAt"],
|
|
595
|
+
[],
|
|
596
|
+
"Computer command acceptance",
|
|
597
|
+
);
|
|
598
|
+
if (candidate.type !== "connect" || candidate.status !== "accepted") {
|
|
599
|
+
throw new ComputerProtocolDecodeError(
|
|
600
|
+
"Computer command acceptance is invalid",
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
version: 2,
|
|
605
|
+
commandId: text(
|
|
606
|
+
candidate.commandId,
|
|
607
|
+
"Computer command acceptance commandId",
|
|
608
|
+
),
|
|
609
|
+
type: "connect",
|
|
610
|
+
status: "accepted",
|
|
611
|
+
admittedAt: timestamp(
|
|
612
|
+
candidate.admittedAt,
|
|
613
|
+
"Computer command acceptance admittedAt",
|
|
614
|
+
),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return decodeComputerCommandReceiptV1(value);
|
|
618
|
+
}
|
|
619
|
+
|
|
510
620
|
export function computerCommandFingerprintV1(
|
|
511
621
|
command: ComputerCommandV1,
|
|
512
622
|
): string {
|
package/src/screenshot.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
10
10
|
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
11
11
|
import {
|
|
12
|
+
ComputerError,
|
|
12
13
|
ComputerRegistry,
|
|
13
14
|
computerBotPathKeyV1,
|
|
14
15
|
type ComputerHandle,
|
|
@@ -47,12 +48,27 @@ function providerWith(
|
|
|
47
48
|
tenant,
|
|
48
49
|
workspace,
|
|
49
50
|
screenshot: { capture: () => capture() },
|
|
51
|
+
exec: {
|
|
52
|
+
execute: () =>
|
|
53
|
+
Promise.resolve({
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
stdout: new TextEncoder().encode("done"),
|
|
56
|
+
stderr: new Uint8Array(),
|
|
57
|
+
outputTruncated: false,
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
50
60
|
close: () => Promise.resolve(),
|
|
51
61
|
}),
|
|
52
62
|
};
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
async function mount(
|
|
65
|
+
async function mount(
|
|
66
|
+
provider: ComputerProvider,
|
|
67
|
+
writer = true,
|
|
68
|
+
projectionFiles?: {
|
|
69
|
+
invalidate(botId: string, kind: "screenshots" | "doctor"): void;
|
|
70
|
+
},
|
|
71
|
+
) {
|
|
56
72
|
const harness = await createPluginHarness([
|
|
57
73
|
ComputerRegistry,
|
|
58
74
|
ToolRegistry,
|
|
@@ -73,13 +89,16 @@ async function mount(provider: ComputerProvider, writer = true) {
|
|
|
73
89
|
},
|
|
74
90
|
}
|
|
75
91
|
: {}),
|
|
92
|
+
...(projectionFiles ? { projectionFiles } : {}),
|
|
76
93
|
}),
|
|
77
94
|
);
|
|
78
95
|
return harness;
|
|
79
96
|
}
|
|
80
97
|
|
|
81
|
-
async function
|
|
98
|
+
async function executeTool(
|
|
82
99
|
harness: Awaited<ReturnType<typeof createPluginHarness>>,
|
|
100
|
+
name: string,
|
|
101
|
+
input: unknown,
|
|
83
102
|
) {
|
|
84
103
|
const context = {
|
|
85
104
|
botId: "bot-1",
|
|
@@ -91,13 +110,17 @@ async function capture(
|
|
|
91
110
|
signal: new AbortController().signal,
|
|
92
111
|
};
|
|
93
112
|
const prepared = await harness.root.tools.prepare(
|
|
94
|
-
{ id: crypto.randomUUID(), name
|
|
113
|
+
{ id: crypto.randomUUID(), name, input },
|
|
95
114
|
context,
|
|
96
115
|
);
|
|
97
116
|
if (prepared.kind !== "ready") throw new Error(prepared.result.content);
|
|
98
117
|
return harness.root.tools.executePrepared(prepared, context);
|
|
99
118
|
}
|
|
100
119
|
|
|
120
|
+
function capture(harness: Awaited<ReturnType<typeof createPluginHarness>>) {
|
|
121
|
+
return executeTool(harness, "computer_screenshot", {});
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
describe("computer_screenshot", () => {
|
|
102
125
|
test("files the capture through the Workspace with the Bot as its writer", async () => {
|
|
103
126
|
const workspace = new FakeWorkspace();
|
|
@@ -239,6 +262,80 @@ describe("computer_screenshot", () => {
|
|
|
239
262
|
).not.toContain("computer_screenshot");
|
|
240
263
|
await harness.dispose();
|
|
241
264
|
});
|
|
265
|
+
|
|
266
|
+
test("captures a final frame after a Turn that used the Computer", async () => {
|
|
267
|
+
const workspace = new FakeWorkspace();
|
|
268
|
+
const invalidations: string[] = [];
|
|
269
|
+
const harness = await mount(
|
|
270
|
+
providerWith(workspace, () =>
|
|
271
|
+
Promise.resolve({
|
|
272
|
+
bytes: png(1280, 720),
|
|
273
|
+
mediaType: "image/png" as const,
|
|
274
|
+
display: ":100",
|
|
275
|
+
capturedAt: "2026-09-03T00:00:10.000Z",
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
true,
|
|
279
|
+
{
|
|
280
|
+
invalidate: (botId, kind) => invalidations.push(`${botId}:${kind}`),
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
const session = harness.root.sessions.create("session-1");
|
|
284
|
+
const agent = { botId: "bot-1", session };
|
|
285
|
+
await harness.root.waterfall(
|
|
286
|
+
"agent/pre-step",
|
|
287
|
+
agent as never,
|
|
288
|
+
[],
|
|
289
|
+
1,
|
|
290
|
+
1,
|
|
291
|
+
() => Promise.resolve({ kind: "enter" as const, inputs: [] }),
|
|
292
|
+
);
|
|
293
|
+
await executeTool(harness, "computer_exec", { command: "pwd" });
|
|
294
|
+
|
|
295
|
+
await harness.root.serial("agent/turn-stopping", agent as never, 1);
|
|
296
|
+
|
|
297
|
+
expect(workspace.writes).toHaveLength(1);
|
|
298
|
+
expect(workspace.writes[0]?.writer).toEqual({
|
|
299
|
+
kind: "bot",
|
|
300
|
+
botId: "bot-1",
|
|
301
|
+
sessionId: "session-1",
|
|
302
|
+
turnId: "run-9",
|
|
303
|
+
runId: "run-9",
|
|
304
|
+
});
|
|
305
|
+
expect(invalidations).toEqual(["bot-1:screenshots"]);
|
|
306
|
+
await harness.dispose();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a final-frame capture is refused while the User holds control", async () => {
|
|
310
|
+
const workspace = new FakeWorkspace();
|
|
311
|
+
let captures = 0;
|
|
312
|
+
const harness = await mount(
|
|
313
|
+
providerWith(workspace, () => {
|
|
314
|
+
captures += 1;
|
|
315
|
+
return Promise.reject(
|
|
316
|
+
new ComputerError("human-control-active", "held by User"),
|
|
317
|
+
);
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
const session = harness.root.sessions.create("session-1");
|
|
321
|
+
const agent = { botId: "bot-1", session };
|
|
322
|
+
await harness.root.waterfall(
|
|
323
|
+
"agent/pre-step",
|
|
324
|
+
agent as never,
|
|
325
|
+
[],
|
|
326
|
+
1,
|
|
327
|
+
1,
|
|
328
|
+
() => Promise.resolve({ kind: "enter" as const, inputs: [] }),
|
|
329
|
+
);
|
|
330
|
+
await executeTool(harness, "computer_exec", { command: "pwd" });
|
|
331
|
+
|
|
332
|
+
await expect(
|
|
333
|
+
harness.root.serial("agent/turn-stopping", agent as never, 1),
|
|
334
|
+
).resolves.toBeUndefined();
|
|
335
|
+
expect(captures).toBe(1);
|
|
336
|
+
expect(workspace.writes).toHaveLength(0);
|
|
337
|
+
await harness.dispose();
|
|
338
|
+
});
|
|
242
339
|
});
|
|
243
340
|
|
|
244
341
|
describe("pngDimensionsV1", () => {
|
package/src/workspace-fixture.ts
CHANGED
|
@@ -38,6 +38,8 @@ export class FakeWorkspace implements ComputerWorkspace {
|
|
|
38
38
|
{ bytes: Uint8Array; generation: WorkspaceGenerationV1 }
|
|
39
39
|
>();
|
|
40
40
|
readonly deleted: string[] = [];
|
|
41
|
+
readonly reads: WorkspacePathV1[] = [];
|
|
42
|
+
readonly lists: { root: WorkspaceRootV1; prefix?: string }[] = [];
|
|
41
43
|
/**
|
|
42
44
|
* Every write, in order, with the root it named.
|
|
43
45
|
*
|
|
@@ -57,6 +59,7 @@ export class FakeWorkspace implements ComputerWorkspace {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
read(path: WorkspacePathV1) {
|
|
62
|
+
this.reads.push(path);
|
|
60
63
|
const held = this.files.get(this.key(path));
|
|
61
64
|
return Promise.resolve(
|
|
62
65
|
held
|
|
@@ -81,6 +84,7 @@ export class FakeWorkspace implements ComputerWorkspace {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
list(request: { root: WorkspaceRootV1; prefix?: string }) {
|
|
87
|
+
this.lists.push(request);
|
|
84
88
|
const entries: WorkspaceEntryV1[] = [...this.files.entries()]
|
|
85
89
|
.filter(([path]) => !request.prefix || path.startsWith(request.prefix))
|
|
86
90
|
.map(([path, held]) => ({
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { parse } from "@vue/compiler-sfc";
|
|
4
|
-
import {
|
|
5
|
-
initialComputerMachineState,
|
|
6
|
-
transitionComputerState,
|
|
7
|
-
} from "./state-machine.js";
|
|
8
|
-
|
|
9
|
-
test("the Vue strip keys its durable capture only by contentHash", () => {
|
|
10
|
-
const source = readFileSync(
|
|
11
|
-
new URL("./ComputerStrip.vue", import.meta.url),
|
|
12
|
-
"utf8",
|
|
13
|
-
);
|
|
14
|
-
const parsed = parse(source, { filename: "ComputerStrip.vue" });
|
|
15
|
-
expect(parsed.errors).toEqual([]);
|
|
16
|
-
const template = parsed.descriptor.template?.content ?? "";
|
|
17
|
-
|
|
18
|
-
expect(template).toContain(':key="screenshot.contentHash"');
|
|
19
|
-
expect(template).toContain(':src="screenshot.url"');
|
|
20
|
-
expect(template).not.toContain("viewerUrl");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("a repeated screenshot projection preserves the rendered capture", () => {
|
|
24
|
-
const first = {
|
|
25
|
-
version: 1 as const,
|
|
26
|
-
path: "scout/latest.png",
|
|
27
|
-
capturedAt: "2026-09-02T00:00:00.000Z",
|
|
28
|
-
contentHash: "sha256:first",
|
|
29
|
-
url: "/workspace/first",
|
|
30
|
-
};
|
|
31
|
-
const state = {
|
|
32
|
-
...initialComputerMachineState(),
|
|
33
|
-
screenshots: [first],
|
|
34
|
-
};
|
|
35
|
-
const project = (contentHash: string) => ({
|
|
36
|
-
type: "projection-received" as const,
|
|
37
|
-
projection: {
|
|
38
|
-
version: 1 as const,
|
|
39
|
-
botId: "scout",
|
|
40
|
-
providerLabel: "Fake Computer",
|
|
41
|
-
phase: "idle" as const,
|
|
42
|
-
message: "Computer available",
|
|
43
|
-
screenshots: [
|
|
44
|
-
{ ...first, contentHash, url: `/workspace/${contentHash}` },
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const unchanged = transitionComputerState(state, project("sha256:first"));
|
|
50
|
-
const changed = transitionComputerState(unchanged, project("sha256:second"));
|
|
51
|
-
|
|
52
|
-
expect(unchanged.screenshots[0]).toBe(first);
|
|
53
|
-
expect(changed.screenshots[0]).not.toBe(first);
|
|
54
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { useRpc } from "@cordisjs/client";
|
|
3
|
-
import { UiIcon } from "@frockbot/client-ui";
|
|
4
|
-
import { computed, inject, ref } from "vue";
|
|
5
|
-
import { computerKey, type ComputerState } from "../shared.ts";
|
|
6
|
-
|
|
7
|
-
const computer = inject(computerKey) ?? useRpc<ComputerState>();
|
|
8
|
-
const busy = ref(false);
|
|
9
|
-
const state = computed(() => computer.value);
|
|
10
|
-
const screenshot = computed(() => state.value.screenshots?.[0]);
|
|
11
|
-
const phaseLabel = computed(() =>
|
|
12
|
-
state.value.phase === "updating"
|
|
13
|
-
? state.value.message
|
|
14
|
-
: state.value.phase.replaceAll("-", " "),
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
async function open(): Promise<void> {
|
|
18
|
-
if (busy.value) return;
|
|
19
|
-
busy.value = true;
|
|
20
|
-
try {
|
|
21
|
-
await state.value.openViewer();
|
|
22
|
-
} catch {
|
|
23
|
-
// The shared state already holds the visible failure.
|
|
24
|
-
} finally {
|
|
25
|
-
busy.value = false;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
</script>
|
|
29
|
-
|
|
30
|
-
<template>
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
class="computer-strip"
|
|
34
|
-
:disabled="busy"
|
|
35
|
-
:aria-label="`Open Computer, ${phaseLabel}`"
|
|
36
|
-
@click="open"
|
|
37
|
-
>
|
|
38
|
-
<span class="computer-strip-capture">
|
|
39
|
-
<img
|
|
40
|
-
v-if="screenshot"
|
|
41
|
-
:key="screenshot.contentHash"
|
|
42
|
-
:src="screenshot.url"
|
|
43
|
-
alt=""
|
|
44
|
-
draggable="false"
|
|
45
|
-
/>
|
|
46
|
-
<span v-else class="computer-strip-placeholder" aria-hidden="true">
|
|
47
|
-
<UiIcon name="sparkle" size="sm" />
|
|
48
|
-
</span>
|
|
49
|
-
</span>
|
|
50
|
-
<span class="computer-strip-phase">
|
|
51
|
-
<span class="computer-strip-dot" :class="`phase-${state.phase}`" />
|
|
52
|
-
<span>{{ phaseLabel }}</span>
|
|
53
|
-
</span>
|
|
54
|
-
</button>
|
|
55
|
-
</template>
|