@frockbot/plugin-computer 0.0.0 → 0.1.1
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 +25 -0
- package/package.json +54 -6
- package/src/agent.test.ts +271 -0
- package/src/agent.ts +1419 -0
- package/src/backend.test.ts +149 -0
- package/src/backend.ts +163 -0
- package/src/bot.test.ts +411 -0
- package/src/bot.ts +831 -0
- package/src/client/ComputerCard.test.ts +96 -0
- package/src/client/ComputerCard.vue +60 -0
- package/src/client/ComputerStrip.test.ts +54 -0
- package/src/client/ComputerStrip.vue +55 -0
- package/src/client/ComputerViewerOverlay.vue +252 -0
- package/src/client/application.test.ts +403 -0
- package/src/client/application.ts +359 -0
- package/src/client/cordis-client-shim.d.ts +16 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.ts +28 -0
- package/src/client/state-machine.test.ts +200 -0
- package/src/client/state-machine.ts +172 -0
- package/src/client/styles.css +594 -0
- package/src/client/viewer.ts +58 -0
- package/src/control-record.ts +57 -0
- package/src/doctor.test.ts +247 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +3 -0
- package/src/process-records.test.ts +178 -0
- package/src/process-records.ts +278 -0
- package/src/process-store.ts +96 -0
- package/src/processes.test.ts +388 -0
- package/src/protocol.ts +405 -0
- package/src/roots.ts +6 -0
- package/src/screenshot.test.ts +253 -0
- package/src/shared-provider.test.ts +56 -0
- package/src/shared-provider.ts +121 -0
- package/src/shared.ts +54 -0
- package/src/sync.test.ts +255 -0
- package/src/workspace-fixture.ts +126 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
- package/README.md +0 -3
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// `computer_exec{background:true}` and the three process tools, as a Turn
|
|
2
|
+
// drives them.
|
|
3
|
+
//
|
|
4
|
+
// The rules under test are the ones a later change could quietly break: the
|
|
5
|
+
// record is written before anything launches, the outcome is read rather than
|
|
6
|
+
// re-run, a moved generation answers `unknown` rather than `running`, and the
|
|
7
|
+
// log tail leaves the Computer so a rebuild cannot erase the only evidence a
|
|
8
|
+
// job ran.
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
11
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
12
|
+
import {
|
|
13
|
+
ComputerRegistry,
|
|
14
|
+
computerBotPathKeyV1,
|
|
15
|
+
type ComputerBackgroundStateV1,
|
|
16
|
+
type ComputerHandle,
|
|
17
|
+
type ComputerProvider,
|
|
18
|
+
} from "@frockbot/computer-core";
|
|
19
|
+
import { createPluginHarness } from "@frockbot/plugin-testkit";
|
|
20
|
+
import { SessionStore } from "@frockbot/kernel-contracts";
|
|
21
|
+
import { createComputerAgentPlugin } from "./agent.js";
|
|
22
|
+
import type { ComputerProcessStorageV1 } from "./process-store.js";
|
|
23
|
+
import { FakeWorkspace } from "./workspace-fixture.js";
|
|
24
|
+
|
|
25
|
+
/** Storage enough for the store: a map with a prefix listing. */
|
|
26
|
+
function storage(): ComputerProcessStorageV1 & { map: Map<string, unknown> } {
|
|
27
|
+
const map = new Map<string, unknown>();
|
|
28
|
+
return {
|
|
29
|
+
map,
|
|
30
|
+
get: <T>(key: string) => Promise.resolve(map.get(key) as T | undefined),
|
|
31
|
+
put: (key, value) => {
|
|
32
|
+
map.set(key, value);
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
},
|
|
35
|
+
delete: (key) => Promise.resolve(map.delete(key)),
|
|
36
|
+
list: <T>(options: { prefix: string; limit?: number }) => {
|
|
37
|
+
const held = new Map<string, T>();
|
|
38
|
+
for (const [key, value] of map) {
|
|
39
|
+
if (!key.startsWith(options.prefix)) continue;
|
|
40
|
+
if (options.limit !== undefined && held.size >= options.limit) break;
|
|
41
|
+
held.set(key, value as T);
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve(held);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Computer {
|
|
49
|
+
provider: ComputerProvider;
|
|
50
|
+
calls: string[];
|
|
51
|
+
generation: number;
|
|
52
|
+
state: ComputerBackgroundStateV1;
|
|
53
|
+
workspace: FakeWorkspace;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fakeComputer(): Computer {
|
|
57
|
+
const calls: string[] = [];
|
|
58
|
+
const workspace = new FakeWorkspace();
|
|
59
|
+
const computer: Computer = {
|
|
60
|
+
calls,
|
|
61
|
+
generation: 1,
|
|
62
|
+
state: { alive: true, logTail: "building…" },
|
|
63
|
+
workspace,
|
|
64
|
+
provider: {
|
|
65
|
+
id: "fixture",
|
|
66
|
+
open: (identity, tenant, assignment): Promise<ComputerHandle> =>
|
|
67
|
+
Promise.resolve({
|
|
68
|
+
assignment,
|
|
69
|
+
identity,
|
|
70
|
+
tenant,
|
|
71
|
+
workspace,
|
|
72
|
+
processes: {
|
|
73
|
+
launch: (request) => {
|
|
74
|
+
calls.push(`launch:${request.processId}:${request.command}`);
|
|
75
|
+
return Promise.resolve({
|
|
76
|
+
pid: 4321,
|
|
77
|
+
logPath: `/processes/${request.processId}/log`,
|
|
78
|
+
generation: computer.generation,
|
|
79
|
+
cwd: "/workspaces/bot-1",
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
inspect: (processId) => {
|
|
83
|
+
calls.push(`inspect:${processId}`);
|
|
84
|
+
return Promise.resolve(computer.state);
|
|
85
|
+
},
|
|
86
|
+
stop: (processId) => {
|
|
87
|
+
calls.push(`stop:${processId}`);
|
|
88
|
+
return Promise.resolve(computer.state);
|
|
89
|
+
},
|
|
90
|
+
generation: () => Promise.resolve(computer.generation),
|
|
91
|
+
},
|
|
92
|
+
close: () => Promise.resolve(),
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
return computer;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function mount(computer: Computer, held: ComputerProcessStorageV1) {
|
|
100
|
+
const harness = await createPluginHarness([
|
|
101
|
+
ComputerRegistry,
|
|
102
|
+
ToolRegistry,
|
|
103
|
+
SystemPromptRegistry,
|
|
104
|
+
SessionStore,
|
|
105
|
+
]);
|
|
106
|
+
harness.root.computers.register(computer.provider);
|
|
107
|
+
await harness.mount(
|
|
108
|
+
createComputerAgentPlugin({
|
|
109
|
+
userId: "user-1",
|
|
110
|
+
defaultProviderId: "fixture",
|
|
111
|
+
writer: { sessionId: "session-1", turnId: "run-9", runId: "run-9" },
|
|
112
|
+
processes: held,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
return harness;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function call(
|
|
119
|
+
harness: Awaited<ReturnType<typeof createPluginHarness>>,
|
|
120
|
+
name: string,
|
|
121
|
+
input: unknown,
|
|
122
|
+
effectId = "tool:1:1:0",
|
|
123
|
+
) {
|
|
124
|
+
const context = {
|
|
125
|
+
botId: "bot-1",
|
|
126
|
+
agentId: "run-9",
|
|
127
|
+
compositionGenerationId: "bootstrap",
|
|
128
|
+
turnType: "chat" as const,
|
|
129
|
+
sessionId: "session-1",
|
|
130
|
+
effectId,
|
|
131
|
+
signal: new AbortController().signal,
|
|
132
|
+
};
|
|
133
|
+
const prepared = await harness.root.tools.prepare(
|
|
134
|
+
{ id: crypto.randomUUID(), name, input },
|
|
135
|
+
context,
|
|
136
|
+
);
|
|
137
|
+
if (prepared.kind !== "ready") throw new Error(prepared.result.content);
|
|
138
|
+
return harness.root.tools.executePrepared(prepared, context);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("computer_exec with background:true", () => {
|
|
142
|
+
test("records the intent before it launches anything", async () => {
|
|
143
|
+
const computer = fakeComputer();
|
|
144
|
+
const held = storage();
|
|
145
|
+
const harness = await mount(computer, held);
|
|
146
|
+
|
|
147
|
+
const result = await call(harness, "computer_exec", {
|
|
148
|
+
command: "npm run build",
|
|
149
|
+
background: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.isError).toBe(false);
|
|
153
|
+
const answer = JSON.parse(result.content) as { processId: string };
|
|
154
|
+
const record = held.map.get(`computer-process:${answer.processId}`) as
|
|
155
|
+
Record<string, unknown> | undefined;
|
|
156
|
+
expect(record).toMatchObject({
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
botId: "bot-1",
|
|
159
|
+
sessionId: "session-1",
|
|
160
|
+
turnId: "run-9",
|
|
161
|
+
command: "npm run build",
|
|
162
|
+
status: "running",
|
|
163
|
+
generation: 1,
|
|
164
|
+
effectId: "tool:1:1:0",
|
|
165
|
+
pid: 4321,
|
|
166
|
+
});
|
|
167
|
+
// The record names the effect that produced it, so a recovery reads the
|
|
168
|
+
// outcome rather than launching a second process.
|
|
169
|
+
expect(computer.calls).toEqual([
|
|
170
|
+
`launch:${answer.processId}:npm run build`,
|
|
171
|
+
]);
|
|
172
|
+
// And the answer says out loud that nothing keeps the Computer awake.
|
|
173
|
+
expect(result.content).toContain("hibernates");
|
|
174
|
+
await harness.dispose();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("is refused where there is nowhere durable to record it", async () => {
|
|
178
|
+
const computer = fakeComputer();
|
|
179
|
+
const harness = await createPluginHarness([
|
|
180
|
+
ComputerRegistry,
|
|
181
|
+
ToolRegistry,
|
|
182
|
+
SystemPromptRegistry,
|
|
183
|
+
SessionStore,
|
|
184
|
+
]);
|
|
185
|
+
harness.root.computers.register(computer.provider);
|
|
186
|
+
await harness.mount(
|
|
187
|
+
createComputerAgentPlugin({
|
|
188
|
+
userId: "user-1",
|
|
189
|
+
defaultProviderId: "fixture",
|
|
190
|
+
writer: { sessionId: "session-1", turnId: "run-9", runId: "run-9" },
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const result = await call(harness, "computer_exec", {
|
|
195
|
+
command: "sleep 60",
|
|
196
|
+
background: true,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result).toMatchObject({ isError: true });
|
|
200
|
+
expect(result.content).toContain("nowhere durable to record it");
|
|
201
|
+
expect(computer.calls).toEqual([]);
|
|
202
|
+
// And the tools that read a process are not offered either.
|
|
203
|
+
expect(
|
|
204
|
+
harness.root.tools.schemas({ turnType: "chat" }).map((tool) => tool.name),
|
|
205
|
+
).not.toContain("computer_process_check");
|
|
206
|
+
await harness.dispose();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("checking a background process", () => {
|
|
211
|
+
test("answers running, then the exit code, and mirrors the log on completion", async () => {
|
|
212
|
+
const computer = fakeComputer();
|
|
213
|
+
const held = storage();
|
|
214
|
+
const harness = await mount(computer, held);
|
|
215
|
+
const launched = JSON.parse(
|
|
216
|
+
(
|
|
217
|
+
await call(harness, "computer_exec", {
|
|
218
|
+
command: "npm run build",
|
|
219
|
+
background: true,
|
|
220
|
+
})
|
|
221
|
+
).content,
|
|
222
|
+
) as { processId: string };
|
|
223
|
+
|
|
224
|
+
const running = JSON.parse(
|
|
225
|
+
(
|
|
226
|
+
await call(harness, "computer_process_check", {
|
|
227
|
+
processId: launched.processId,
|
|
228
|
+
})
|
|
229
|
+
).content,
|
|
230
|
+
) as { status: string };
|
|
231
|
+
expect(running.status).toBe("running");
|
|
232
|
+
// Nothing durable is mirrored while it runs: there is no outcome yet.
|
|
233
|
+
expect(computer.workspace.files.size).toBe(0);
|
|
234
|
+
|
|
235
|
+
computer.state = { alive: false, exitCode: 0, logTail: "build ok" };
|
|
236
|
+
const finished = JSON.parse(
|
|
237
|
+
(
|
|
238
|
+
await call(harness, "computer_process_check", {
|
|
239
|
+
processId: launched.processId,
|
|
240
|
+
})
|
|
241
|
+
).content,
|
|
242
|
+
) as { status: string; exitCode: number };
|
|
243
|
+
|
|
244
|
+
expect(finished).toMatchObject({ status: "exited", exitCode: 0 });
|
|
245
|
+
// Written through the Workspace, so the Bot is recorded as its writer and
|
|
246
|
+
// an image rebuild cannot erase the only evidence the job ran.
|
|
247
|
+
const mirrored = computer.workspace.files.get(
|
|
248
|
+
`${computerBotPathKeyV1("bot-1")}/${launched.processId}.log`,
|
|
249
|
+
);
|
|
250
|
+
expect(mirrored).toBeDefined();
|
|
251
|
+
expect(new TextDecoder().decode(mirrored!.bytes)).toContain("build ok");
|
|
252
|
+
expect(mirrored!.generation.writer).toEqual({
|
|
253
|
+
kind: "bot",
|
|
254
|
+
botId: "bot-1",
|
|
255
|
+
sessionId: "session-1",
|
|
256
|
+
turnId: "run-9",
|
|
257
|
+
runId: "run-9",
|
|
258
|
+
});
|
|
259
|
+
await harness.dispose();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("answers unknown, never running, once the Computer's generation moved", async () => {
|
|
263
|
+
const computer = fakeComputer();
|
|
264
|
+
const held = storage();
|
|
265
|
+
const harness = await mount(computer, held);
|
|
266
|
+
const launched = JSON.parse(
|
|
267
|
+
(
|
|
268
|
+
await call(harness, "computer_exec", {
|
|
269
|
+
command: "sleep 600",
|
|
270
|
+
background: true,
|
|
271
|
+
})
|
|
272
|
+
).content,
|
|
273
|
+
) as { processId: string };
|
|
274
|
+
|
|
275
|
+
// The Computer was reprovisioned under it. Whatever its pid table says,
|
|
276
|
+
// the process this Bot launched is gone.
|
|
277
|
+
computer.generation = 2;
|
|
278
|
+
computer.state = { alive: true, logTail: "…" };
|
|
279
|
+
const answer = JSON.parse(
|
|
280
|
+
(
|
|
281
|
+
await call(harness, "computer_process_check", {
|
|
282
|
+
processId: launched.processId,
|
|
283
|
+
})
|
|
284
|
+
).content,
|
|
285
|
+
) as { status: string; note?: string };
|
|
286
|
+
|
|
287
|
+
expect(answer.status).toBe("unknown");
|
|
288
|
+
expect(answer.note).toContain("not running");
|
|
289
|
+
expect(
|
|
290
|
+
(
|
|
291
|
+
held.map.get(`computer-process:${launched.processId}`) as {
|
|
292
|
+
status: string;
|
|
293
|
+
}
|
|
294
|
+
).status,
|
|
295
|
+
).toBe("unknown");
|
|
296
|
+
await harness.dispose();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("refuses a process this Bot never launched", async () => {
|
|
300
|
+
const computer = fakeComputer();
|
|
301
|
+
const harness = await mount(computer, storage());
|
|
302
|
+
|
|
303
|
+
const result = await call(harness, "computer_process_check", {
|
|
304
|
+
processId: "p-someone-else",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(result).toMatchObject({ isError: true });
|
|
308
|
+
expect(result.content).toContain("No background process");
|
|
309
|
+
expect(computer.calls).toEqual([]);
|
|
310
|
+
await harness.dispose();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("the process tools' admission", () => {
|
|
315
|
+
test("offers check and logs on an automation turn, beside computer_exec", async () => {
|
|
316
|
+
const harness = await mount(fakeComputer(), storage());
|
|
317
|
+
const named = (turnType: "chat" | "automation") =>
|
|
318
|
+
harness.root.tools.schemas({ turnType }).map((tool) => tool.name);
|
|
319
|
+
|
|
320
|
+
// A Routine has to be able to collect the outcome of a job a chat Turn
|
|
321
|
+
// started, so these two declare their turn types rather than inheriting
|
|
322
|
+
// whatever the default happens to be.
|
|
323
|
+
expect(named("automation")).toContain("computer_process_check");
|
|
324
|
+
expect(named("automation")).toContain("computer_process_logs");
|
|
325
|
+
// `stop` is admitted wherever `computer_exec` is: ending a process is no
|
|
326
|
+
// narrower than starting one.
|
|
327
|
+
expect(named("automation")).toContain("computer_exec");
|
|
328
|
+
expect(named("automation")).toContain("computer_process_stop");
|
|
329
|
+
expect(named("chat")).toContain("computer_process_stop");
|
|
330
|
+
await harness.dispose();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("the Computer tools' subagent roles", () => {
|
|
335
|
+
test("browserUse gets the browser and nothing else on the box", async () => {
|
|
336
|
+
const harness = await mount(fakeComputer(), storage());
|
|
337
|
+
const named = (subagentRole: string) =>
|
|
338
|
+
harness.root.tools
|
|
339
|
+
.schemas({ turnType: "subagent", subagentRole })
|
|
340
|
+
.map((tool) => tool.name);
|
|
341
|
+
|
|
342
|
+
// `browserUse` is "page-level Chrome" (`docs/research/
|
|
343
|
+
// grokbot-computer.md` l.351–356): the browser, and not the shell, the
|
|
344
|
+
// screen, or the processes a shell left behind.
|
|
345
|
+
expect(named("browserUse")).toEqual(["computer_browser"]);
|
|
346
|
+
expect(named("browserUse")).not.toContain("computer_exec");
|
|
347
|
+
expect(named("computerUse")).toContain("computer_exec");
|
|
348
|
+
expect(named("computerUse")).toContain("computer_screenshot");
|
|
349
|
+
expect(named("computerUse")).toContain("computer_browser");
|
|
350
|
+
expect(named("executor")).toContain("computer_exec");
|
|
351
|
+
// The two video roles have no Computer at all.
|
|
352
|
+
expect(named("watchVideo")).toEqual([]);
|
|
353
|
+
expect(named("videoReview")).toEqual([]);
|
|
354
|
+
await harness.dispose();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("stopping a background process", () => {
|
|
359
|
+
test("ends it and records the outcome once", async () => {
|
|
360
|
+
const computer = fakeComputer();
|
|
361
|
+
const held = storage();
|
|
362
|
+
const harness = await mount(computer, held);
|
|
363
|
+
const launched = JSON.parse(
|
|
364
|
+
(
|
|
365
|
+
await call(harness, "computer_exec", {
|
|
366
|
+
command: "sleep 600",
|
|
367
|
+
background: true,
|
|
368
|
+
})
|
|
369
|
+
).content,
|
|
370
|
+
) as { processId: string };
|
|
371
|
+
computer.state = { alive: false, exitCode: 143, logTail: "terminated" };
|
|
372
|
+
|
|
373
|
+
const stopped = JSON.parse(
|
|
374
|
+
(
|
|
375
|
+
await call(harness, "computer_process_stop", {
|
|
376
|
+
processId: launched.processId,
|
|
377
|
+
})
|
|
378
|
+
).content,
|
|
379
|
+
) as { status: string; exitCode: number };
|
|
380
|
+
|
|
381
|
+
expect(stopped).toMatchObject({ status: "exited", exitCode: 143 });
|
|
382
|
+
expect(computer.calls).toEqual([
|
|
383
|
+
`launch:${launched.processId}:sleep 600`,
|
|
384
|
+
`stop:${launched.processId}`,
|
|
385
|
+
]);
|
|
386
|
+
await harness.dispose();
|
|
387
|
+
});
|
|
388
|
+
});
|