@frockbot/plugin-computer 0.0.0 → 0.1.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 +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 +373 -0
- package/src/client/application.ts +340 -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,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ComputerRegistry } from "@frockbot/computer-core";
|
|
3
|
+
import { Context } from "cordis";
|
|
4
|
+
import {
|
|
5
|
+
createSharedComputerProviderPlugin,
|
|
6
|
+
SHARED_COMPUTER_PROVIDER_ID,
|
|
7
|
+
} from "./shared-provider.js";
|
|
8
|
+
|
|
9
|
+
describe("shared Computer provider", () => {
|
|
10
|
+
test("forwards stable effect identity through the provider-neutral host", async () => {
|
|
11
|
+
const requests: unknown[] = [];
|
|
12
|
+
const root = new Context();
|
|
13
|
+
await root.plugin(ComputerRegistry);
|
|
14
|
+
await root.plugin(
|
|
15
|
+
createSharedComputerProviderPlugin({
|
|
16
|
+
effect: (request) => {
|
|
17
|
+
requests.push(request);
|
|
18
|
+
return Promise.resolve({
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
effectId: request.effectId,
|
|
21
|
+
status: "completed",
|
|
22
|
+
result: {
|
|
23
|
+
type: "exec",
|
|
24
|
+
result: {
|
|
25
|
+
exitCode: 0,
|
|
26
|
+
stdout: Uint8Array.from([111, 107]),
|
|
27
|
+
stderr: new Uint8Array(),
|
|
28
|
+
outputTruncated: false,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
const identity = { userId: "user-1" };
|
|
36
|
+
const tenant = { botId: "bot-1" };
|
|
37
|
+
root.computers.assign(identity, SHARED_COMPUTER_PROVIDER_ID);
|
|
38
|
+
const computer = await root.computers.open(identity, tenant);
|
|
39
|
+
const result = await computer.exec?.execute(
|
|
40
|
+
{ executable: "/bin/true" },
|
|
41
|
+
{ effectId: "tool:1:1:0" },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(result?.stdout).toEqual(Uint8Array.from([111, 107]));
|
|
45
|
+
expect(requests).toMatchObject([
|
|
46
|
+
{
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
effectId: "tool:1:1:0",
|
|
49
|
+
identity,
|
|
50
|
+
tenant,
|
|
51
|
+
operation: { type: "exec", request: { executable: "/bin/true" } },
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
await root.fiber.dispose();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ComputerError,
|
|
3
|
+
type ComputerAssignment,
|
|
4
|
+
type ComputerHandle,
|
|
5
|
+
type ComputerOperationOptions,
|
|
6
|
+
type ComputerIdentityV1,
|
|
7
|
+
type ComputerProvider,
|
|
8
|
+
type ComputerTenantV1,
|
|
9
|
+
} from "@frockbot/computer-core";
|
|
10
|
+
import type {
|
|
11
|
+
ComputerHostEffectRequestV1,
|
|
12
|
+
ComputerHostEffectResponseV1,
|
|
13
|
+
} from "@frockbot/computer-core/host-protocol";
|
|
14
|
+
import type { Plugin } from "cordis";
|
|
15
|
+
|
|
16
|
+
export const SHARED_COMPUTER_PROVIDER_ID = "shared-computer";
|
|
17
|
+
|
|
18
|
+
export interface SharedComputerHostClient {
|
|
19
|
+
effect(
|
|
20
|
+
request: ComputerHostEffectRequestV1,
|
|
21
|
+
options?: ComputerOperationOptions,
|
|
22
|
+
): Promise<ComputerHostEffectResponseV1>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function effectId(options: ComputerOperationOptions | undefined): string {
|
|
26
|
+
const value = options?.effectId?.trim();
|
|
27
|
+
if (!value) {
|
|
28
|
+
throw new ComputerError(
|
|
29
|
+
"invalid-request",
|
|
30
|
+
"Computer effect identity is required",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function completed(
|
|
37
|
+
response: ComputerHostEffectResponseV1,
|
|
38
|
+
): Extract<ComputerHostEffectResponseV1, { status: "completed" }> {
|
|
39
|
+
if (response.status === "completed") return response;
|
|
40
|
+
throw new ComputerError(
|
|
41
|
+
response.status === "rejected" ? "provider-failure" : "conflict",
|
|
42
|
+
response.failure,
|
|
43
|
+
response.status === "unresolved",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class SharedComputerProvider implements ComputerProvider {
|
|
48
|
+
readonly id = SHARED_COMPUTER_PROVIDER_ID;
|
|
49
|
+
|
|
50
|
+
constructor(private readonly host: SharedComputerHostClient) {}
|
|
51
|
+
|
|
52
|
+
open(
|
|
53
|
+
identity: ComputerIdentityV1,
|
|
54
|
+
tenant: ComputerTenantV1,
|
|
55
|
+
assignment: ComputerAssignment,
|
|
56
|
+
): Promise<ComputerHandle> {
|
|
57
|
+
return Promise.resolve({
|
|
58
|
+
assignment,
|
|
59
|
+
identity,
|
|
60
|
+
tenant,
|
|
61
|
+
exec: {
|
|
62
|
+
execute: async (request, options) => {
|
|
63
|
+
const response = completed(
|
|
64
|
+
await this.host.effect(
|
|
65
|
+
{
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
effectId: effectId(options),
|
|
68
|
+
identity,
|
|
69
|
+
tenant,
|
|
70
|
+
assignment,
|
|
71
|
+
operation: { type: "exec", request },
|
|
72
|
+
},
|
|
73
|
+
options,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
if (response.result.type !== "exec") {
|
|
77
|
+
throw new ComputerError(
|
|
78
|
+
"provider-failure",
|
|
79
|
+
"Computer host returned the wrong effect result",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return response.result.result;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
browser: {
|
|
86
|
+
perform: async (action, options) => {
|
|
87
|
+
const response = completed(
|
|
88
|
+
await this.host.effect(
|
|
89
|
+
{
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
effectId: effectId(options),
|
|
92
|
+
identity,
|
|
93
|
+
tenant,
|
|
94
|
+
assignment,
|
|
95
|
+
operation: { type: "browser", action },
|
|
96
|
+
},
|
|
97
|
+
options,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
if (response.result.type !== "browser") {
|
|
101
|
+
throw new ComputerError(
|
|
102
|
+
"provider-failure",
|
|
103
|
+
"Computer host returned the wrong effect result",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return response.result.result;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
close: () => Promise.resolve(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createSharedComputerProviderPlugin(
|
|
115
|
+
host: SharedComputerHostClient,
|
|
116
|
+
): Plugin.Function {
|
|
117
|
+
const plugin: Plugin.Function = (ctx) =>
|
|
118
|
+
ctx.computers.register(new SharedComputerProvider(host));
|
|
119
|
+
plugin.inject = ["computers"];
|
|
120
|
+
return plugin;
|
|
121
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from "vue";
|
|
2
|
+
import type {
|
|
3
|
+
ComputerDoctorViewV1,
|
|
4
|
+
ComputerPhase,
|
|
5
|
+
ComputerScreenshotViewV1,
|
|
6
|
+
} from "./protocol.js";
|
|
7
|
+
|
|
8
|
+
export * from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One capture in the Bot's durable screenshots root, as the card renders it.
|
|
12
|
+
*
|
|
13
|
+
* `url` addresses the Workspace read route rather than the Computer: the
|
|
14
|
+
* capture is durable content in object storage, so showing it wakes nothing.
|
|
15
|
+
*/
|
|
16
|
+
export type { ComputerScreenshotViewV1 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Computer's last self-check, as the card renders it.
|
|
20
|
+
*
|
|
21
|
+
* The report is the Computer's own answer, decoded at the provider seam; the
|
|
22
|
+
* card only draws it. A Computer that has never been asked has none, which is
|
|
23
|
+
* a different thing from one whose checks all passed.
|
|
24
|
+
*/
|
|
25
|
+
export type { ComputerDoctorViewV1, ComputerPhase };
|
|
26
|
+
|
|
27
|
+
/** Provider-neutral state published by the selected Computer adapter. */
|
|
28
|
+
export interface ComputerState {
|
|
29
|
+
phase: ComputerPhase;
|
|
30
|
+
botId: string;
|
|
31
|
+
providerLabel: string;
|
|
32
|
+
message: string;
|
|
33
|
+
viewerUrl?: string;
|
|
34
|
+
/** Whether the one live viewer is open over the hosted shell. */
|
|
35
|
+
expanded: boolean;
|
|
36
|
+
takingControl: boolean;
|
|
37
|
+
/** Newest first. Empty where the host publishes no captures. */
|
|
38
|
+
screenshots?: ComputerScreenshotViewV1[];
|
|
39
|
+
/** The last self-check, absent until one has been run. */
|
|
40
|
+
doctor?: ComputerDoctorViewV1;
|
|
41
|
+
/** Absent where the host cannot run one; the card hides the button. */
|
|
42
|
+
runDoctor?(): Promise<void>;
|
|
43
|
+
connect(): Promise<void>;
|
|
44
|
+
/** Explicit User open. An idle Computer may wake; rendering never does. */
|
|
45
|
+
openViewer(): Promise<void>;
|
|
46
|
+
/** Closes the viewer, releasing human control before it disappears. */
|
|
47
|
+
closeViewer(): Promise<void>;
|
|
48
|
+
takeControl(): Promise<void>;
|
|
49
|
+
releaseControl(): Promise<void>;
|
|
50
|
+
retry(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const computerKey: InjectionKey<Ref<ComputerState>> =
|
|
54
|
+
Symbol("computer-data");
|
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// When the Computer Package runs the durable-root sync (ADR 0013), and when it
|
|
2
|
+
// refuses to.
|
|
3
|
+
//
|
|
4
|
+
// The provider here records every call the provider-neutral Computer interface
|
|
5
|
+
// receives, in order, so the claims are about ordering and about absence:
|
|
6
|
+
// the pull lands before the Bot's first Computer tool call, the push lands
|
|
7
|
+
// after the Turn, a Turn that never touches the Computer syncs nothing at all,
|
|
8
|
+
// and a sync that cannot run is a recorded outcome rather than a failed Turn.
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
ComputerRegistry,
|
|
12
|
+
computerSyncSummaryV1,
|
|
13
|
+
type ComputerProvider,
|
|
14
|
+
type ComputerSyncSummaryV1,
|
|
15
|
+
} from "@frockbot/computer-core";
|
|
16
|
+
import { AgentRegistry } from "@frockbot/kernel-agent-loop/agent";
|
|
17
|
+
import { AgentLoop } from "@frockbot/kernel-agent-loop";
|
|
18
|
+
import {
|
|
19
|
+
SessionStore,
|
|
20
|
+
type LlmProvider,
|
|
21
|
+
type SessionEvent,
|
|
22
|
+
} from "@frockbot/kernel-contracts";
|
|
23
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
24
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
25
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
26
|
+
import { Context, type Plugin } from "cordis";
|
|
27
|
+
import { createComputerAgentPlugin } from "./agent.js";
|
|
28
|
+
|
|
29
|
+
const COMPOSITION = {
|
|
30
|
+
generationId: "1970-01-01T00:00:00.000Z:0123456789abcdef",
|
|
31
|
+
artifactSetHash: "a".repeat(64),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
interface SyncFixture {
|
|
35
|
+
calls: string[];
|
|
36
|
+
provider: ComputerProvider;
|
|
37
|
+
/** The change signal the on-Computer watcher reports; move it to force a sync. */
|
|
38
|
+
signal: { value: string | undefined };
|
|
39
|
+
/** What every `reconcile` answers. */
|
|
40
|
+
answer: (reason: string) => ComputerSyncSummaryV1 | Promise<never>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fixture(
|
|
44
|
+
answer: SyncFixture["answer"] = () => computerSyncSummaryV1("ok"),
|
|
45
|
+
): SyncFixture {
|
|
46
|
+
const calls: string[] = [];
|
|
47
|
+
const signal = { value: "signal-1" as string | undefined };
|
|
48
|
+
const provider: ComputerProvider = {
|
|
49
|
+
id: "recording",
|
|
50
|
+
open: (identity, tenant, assignment) => {
|
|
51
|
+
calls.push(`open:${tenant.botId}`);
|
|
52
|
+
return Promise.resolve({
|
|
53
|
+
assignment,
|
|
54
|
+
identity,
|
|
55
|
+
tenant,
|
|
56
|
+
sync: {
|
|
57
|
+
reconcile: async (reason) => {
|
|
58
|
+
calls.push(`sync:${reason}`);
|
|
59
|
+
return await answer(reason);
|
|
60
|
+
},
|
|
61
|
+
signal: () => {
|
|
62
|
+
calls.push("signal");
|
|
63
|
+
return Promise.resolve(signal.value);
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
exec: {
|
|
67
|
+
execute: () => {
|
|
68
|
+
calls.push("exec");
|
|
69
|
+
return Promise.resolve({
|
|
70
|
+
exitCode: 0,
|
|
71
|
+
stdout: new TextEncoder().encode("done"),
|
|
72
|
+
stderr: new Uint8Array(),
|
|
73
|
+
outputTruncated: false,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
close: () => Promise.resolve(),
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
return { calls, provider, signal, answer };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A model that runs the Computer tool once per listed command and then stops.
|
|
86
|
+
* `[]` is a Turn that never touches the Computer.
|
|
87
|
+
*/
|
|
88
|
+
function modelRunning(
|
|
89
|
+
commands: readonly string[],
|
|
90
|
+
beforeStep?: (step: number) => void,
|
|
91
|
+
): LlmProvider {
|
|
92
|
+
let issued = 0;
|
|
93
|
+
let step = 0;
|
|
94
|
+
return {
|
|
95
|
+
id: "scripted",
|
|
96
|
+
async *stream() {
|
|
97
|
+
step += 1;
|
|
98
|
+
beforeStep?.(step);
|
|
99
|
+
const command = commands[issued];
|
|
100
|
+
if (command !== undefined) {
|
|
101
|
+
issued += 1;
|
|
102
|
+
yield {
|
|
103
|
+
type: "tool-call",
|
|
104
|
+
call: {
|
|
105
|
+
id: `call-${issued}`,
|
|
106
|
+
name: "computer_exec",
|
|
107
|
+
input: { command },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
yield { type: "text-delta", text: "done" };
|
|
114
|
+
yield { type: "finish", reason: "completed" };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runTurn(
|
|
120
|
+
provider: ComputerProvider,
|
|
121
|
+
model: LlmProvider,
|
|
122
|
+
): Promise<SessionEvent[]> {
|
|
123
|
+
const root = new Context();
|
|
124
|
+
await root.plugin(SessionStore, {});
|
|
125
|
+
await root.plugin(SystemPromptRegistry);
|
|
126
|
+
await root.plugin(LlmRegistry);
|
|
127
|
+
await root.plugin(ToolRegistry);
|
|
128
|
+
await root.plugin(ComputerRegistry);
|
|
129
|
+
await root.plugin(AgentRegistry);
|
|
130
|
+
const providerPlugin: Plugin.Function = (ctx) => {
|
|
131
|
+
const disposeModel = ctx.llm.register(model);
|
|
132
|
+
const disposeComputer = ctx.computers.register(provider);
|
|
133
|
+
return () => {
|
|
134
|
+
disposeComputer();
|
|
135
|
+
disposeModel();
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
providerPlugin.inject = ["llm", "computers"];
|
|
139
|
+
await root.plugin(providerPlugin);
|
|
140
|
+
await root.plugin(
|
|
141
|
+
createComputerAgentPlugin({
|
|
142
|
+
userId: "user-1",
|
|
143
|
+
defaultProviderId: "recording",
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
await root.plugin(AgentLoop, { maxSteps: 4, composition: COMPOSITION });
|
|
147
|
+
|
|
148
|
+
const handle = await root.agents.create({
|
|
149
|
+
botId: "bot-1",
|
|
150
|
+
sessionId: "session-1",
|
|
151
|
+
provider: model.id,
|
|
152
|
+
model: "test-model",
|
|
153
|
+
admitEffect: () => Promise.resolve(true),
|
|
154
|
+
});
|
|
155
|
+
handle.agent.send("use the Computer");
|
|
156
|
+
await handle.agent.whenIdle();
|
|
157
|
+
const events = [...handle.agent.session.events];
|
|
158
|
+
await root.fiber.dispose();
|
|
159
|
+
return events;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function syncEvents(events: readonly SessionEvent[]) {
|
|
163
|
+
return events.filter(
|
|
164
|
+
(event): event is Extract<SessionEvent, { type: "computer/sync" }> =>
|
|
165
|
+
event.type === "computer/sync",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe("the Computer Package as the sync's caller", () => {
|
|
170
|
+
test("pulls before the Turn's first Computer tool call and pushes after the Turn", async () => {
|
|
171
|
+
const { calls, provider } = fixture();
|
|
172
|
+
|
|
173
|
+
const events = await runTurn(provider, modelRunning(["pwd"]));
|
|
174
|
+
|
|
175
|
+
// The pull is between opening the Computer and the Bot's first look at it,
|
|
176
|
+
// so the Workspace the command sees is the one object storage holds.
|
|
177
|
+
expect(calls.slice(0, 4)).toEqual([
|
|
178
|
+
"open:bot-1",
|
|
179
|
+
"sync:open",
|
|
180
|
+
// The baseline the watcher's signal is compared against next time.
|
|
181
|
+
"signal",
|
|
182
|
+
"exec",
|
|
183
|
+
]);
|
|
184
|
+
expect(calls.at(-1)).toBe("sync:turn-end");
|
|
185
|
+
// Both runs are visible in durable state, on the Turn that caused them.
|
|
186
|
+
expect(
|
|
187
|
+
syncEvents(events).map((event) => [
|
|
188
|
+
event.turn,
|
|
189
|
+
event.reason,
|
|
190
|
+
event.status,
|
|
191
|
+
]),
|
|
192
|
+
).toEqual([
|
|
193
|
+
[1, "open", "ok"],
|
|
194
|
+
[1, "turn-end", "ok"],
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a Turn that never uses the Computer never syncs, so nothing wakes", async () => {
|
|
199
|
+
const { calls, provider } = fixture();
|
|
200
|
+
|
|
201
|
+
const events = await runTurn(provider, modelRunning([]));
|
|
202
|
+
|
|
203
|
+
expect(calls).toEqual([]);
|
|
204
|
+
expect(syncEvents(events)).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("syncs again inside a Turn only when the watcher's change signal moved", async () => {
|
|
208
|
+
const { calls, provider, signal } = fixture();
|
|
209
|
+
// The watcher reports a change before the third step's tool call, and
|
|
210
|
+
// reports nothing new before the second: only one extra sync may follow.
|
|
211
|
+
const model = modelRunning(["first", "second", "third"], (step) => {
|
|
212
|
+
if (step === 3) signal.value = "signal-2";
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const events = await runTurn(provider, model);
|
|
216
|
+
|
|
217
|
+
expect(calls.filter((call) => call.startsWith("sync:"))).toEqual([
|
|
218
|
+
"sync:open",
|
|
219
|
+
"sync:signal",
|
|
220
|
+
"sync:turn-end",
|
|
221
|
+
]);
|
|
222
|
+
expect(syncEvents(events).map((event) => event.reason)).toEqual([
|
|
223
|
+
"open",
|
|
224
|
+
"signal",
|
|
225
|
+
"turn-end",
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("an unavailable sync is recorded on the Turn and never fails it", async () => {
|
|
230
|
+
const { provider } = fixture((reason) => {
|
|
231
|
+
if (reason === "open") {
|
|
232
|
+
return Promise.reject(
|
|
233
|
+
new Error("the Computer is paused"),
|
|
234
|
+
) as Promise<never>;
|
|
235
|
+
}
|
|
236
|
+
return computerSyncSummaryV1("unavailable", "the Computer is paused");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const events = await runTurn(provider, modelRunning(["pwd"]));
|
|
240
|
+
|
|
241
|
+
// The Turn completed: the tool ran and the Turn closed normally.
|
|
242
|
+
expect(
|
|
243
|
+
events.some(
|
|
244
|
+
(event) => event.type === "turn/end" && event.outcome === "completed",
|
|
245
|
+
),
|
|
246
|
+
).toBe(true);
|
|
247
|
+
expect(
|
|
248
|
+
syncEvents(events).map((event) => [event.reason, event.status]),
|
|
249
|
+
).toEqual([
|
|
250
|
+
["open", "unavailable"],
|
|
251
|
+
["turn-end", "unavailable"],
|
|
252
|
+
]);
|
|
253
|
+
expect(syncEvents(events)[0]?.detail).toContain("paused");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Test support: an in-memory `ComputerWorkspace`.
|
|
2
|
+
//
|
|
3
|
+
// A module under `src` rather than a fixture inside one test file because two
|
|
4
|
+
// suites need the same one — the screenshot tool and the background-process
|
|
5
|
+
// tools both write through the Workspace, and a fixture per suite would let
|
|
6
|
+
// two of them drift from one contract. Deliberately absent from this Package's
|
|
7
|
+
// `exports`, and not a `*.test.ts` file, so `bun test` never runs it as one.
|
|
8
|
+
import type {
|
|
9
|
+
ComputerWorkspace,
|
|
10
|
+
WorkspaceLayoutV1,
|
|
11
|
+
} from "@frockbot/computer-core";
|
|
12
|
+
import type {
|
|
13
|
+
WorkspaceEntryV1,
|
|
14
|
+
WorkspaceGenerationV1,
|
|
15
|
+
WorkspacePathV1,
|
|
16
|
+
WorkspaceRootV1,
|
|
17
|
+
WorkspaceWriterV1,
|
|
18
|
+
} from "@frockbot/kernel-contracts";
|
|
19
|
+
|
|
20
|
+
export const FAKE_WORKSPACE_LAYOUT: WorkspaceLayoutV1 = {
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
home: "/home/box",
|
|
23
|
+
roots: [
|
|
24
|
+
{
|
|
25
|
+
kind: "package-declared",
|
|
26
|
+
scope: "user",
|
|
27
|
+
mountPath: "/home/box/agent-data/user-packages/{package}/{root}",
|
|
28
|
+
access: "read-write",
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** An in-memory `ComputerWorkspace` that records every write it admitted. */
|
|
34
|
+
export class FakeWorkspace implements ComputerWorkspace {
|
|
35
|
+
readonly layout = FAKE_WORKSPACE_LAYOUT;
|
|
36
|
+
readonly files = new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ bytes: Uint8Array; generation: WorkspaceGenerationV1 }
|
|
39
|
+
>();
|
|
40
|
+
readonly deleted: string[] = [];
|
|
41
|
+
/**
|
|
42
|
+
* Every write, in order, with the root it named.
|
|
43
|
+
*
|
|
44
|
+
* The file map is keyed by path alone, so it cannot answer *which durable
|
|
45
|
+
* root* a Package wrote to — and that is the question a test about writer
|
|
46
|
+
* attribution has to ask.
|
|
47
|
+
*/
|
|
48
|
+
readonly writes: {
|
|
49
|
+
path: WorkspacePathV1;
|
|
50
|
+
bytes: Uint8Array;
|
|
51
|
+
writer: WorkspaceWriterV1;
|
|
52
|
+
}[] = [];
|
|
53
|
+
private sequence = 0;
|
|
54
|
+
|
|
55
|
+
private key(path: WorkspacePathV1): string {
|
|
56
|
+
return path.path;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
read(path: WorkspacePathV1) {
|
|
60
|
+
const held = this.files.get(this.key(path));
|
|
61
|
+
return Promise.resolve(
|
|
62
|
+
held
|
|
63
|
+
? {
|
|
64
|
+
status: "ok" as const,
|
|
65
|
+
file: { path, generation: held.generation, bytes: held.bytes },
|
|
66
|
+
}
|
|
67
|
+
: { status: "not-found" as const, reason: "no such file" },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stat(path: WorkspacePathV1) {
|
|
72
|
+
const held = this.files.get(this.key(path));
|
|
73
|
+
return Promise.resolve(
|
|
74
|
+
held
|
|
75
|
+
? {
|
|
76
|
+
status: "ok" as const,
|
|
77
|
+
entry: { path, generation: held.generation },
|
|
78
|
+
}
|
|
79
|
+
: { status: "not-found" as const, reason: "no such file" },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
list(request: { root: WorkspaceRootV1; prefix?: string }) {
|
|
84
|
+
const entries: WorkspaceEntryV1[] = [...this.files.entries()]
|
|
85
|
+
.filter(([path]) => !request.prefix || path.startsWith(request.prefix))
|
|
86
|
+
.map(([path, held]) => ({
|
|
87
|
+
path: { root: request.root, path },
|
|
88
|
+
generation: held.generation,
|
|
89
|
+
}));
|
|
90
|
+
return Promise.resolve({ status: "ok" as const, entries });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
write(request: {
|
|
94
|
+
path: WorkspacePathV1;
|
|
95
|
+
bytes: Uint8Array;
|
|
96
|
+
writer: WorkspaceWriterV1;
|
|
97
|
+
}) {
|
|
98
|
+
this.sequence += 1;
|
|
99
|
+
this.writes.push(request);
|
|
100
|
+
const generation: WorkspaceGenerationV1 = {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
generationId: `gen-${this.sequence}`,
|
|
103
|
+
// A stand-in digest with the shape the decoders require.
|
|
104
|
+
contentHash: this.sequence.toString(16).padStart(64, "a"),
|
|
105
|
+
size: request.bytes.byteLength,
|
|
106
|
+
writer: request.writer,
|
|
107
|
+
writtenAt: new Date(1_700_000_000_000 + this.sequence).toISOString(),
|
|
108
|
+
};
|
|
109
|
+
this.files.set(this.key(request.path), {
|
|
110
|
+
bytes: request.bytes,
|
|
111
|
+
generation,
|
|
112
|
+
});
|
|
113
|
+
return Promise.resolve({ status: "ok" as const, generation });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
delete(request: { path: WorkspacePathV1 }) {
|
|
117
|
+
const held = this.files.get(this.key(request.path));
|
|
118
|
+
this.deleted.push(request.path.path);
|
|
119
|
+
this.files.delete(this.key(request.path));
|
|
120
|
+
return Promise.resolve(
|
|
121
|
+
held
|
|
122
|
+
? { status: "ok" as const, generation: held.generation }
|
|
123
|
+
: { status: "not-found" as const, reason: "no such file" },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"baseUrl": ".",
|
|
9
|
+
"paths": {
|
|
10
|
+
"@cordisjs/client": ["src/client/cordis-client-shim.d.ts"]
|
|
11
|
+
},
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
16
|
+
"types": ["bun", "node", "vite/client"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
|
19
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
import { defineConfig } from "vite";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [vue()],
|
|
7
|
+
build: {
|
|
8
|
+
outDir: fileURLToPath(new URL("./dist", import.meta.url)),
|
|
9
|
+
emptyOutDir: true,
|
|
10
|
+
manifest: "manifest.json",
|
|
11
|
+
lib: {
|
|
12
|
+
entry: fileURLToPath(new URL("./src/client/index.ts", import.meta.url)),
|
|
13
|
+
formats: ["es"],
|
|
14
|
+
},
|
|
15
|
+
rollupOptions: {
|
|
16
|
+
external: ["vue", "@cordisjs/client"],
|
|
17
|
+
output: {
|
|
18
|
+
entryFileNames: "assets/computer-[hash].js",
|
|
19
|
+
chunkFileNames: "assets/chunk-[hash].js",
|
|
20
|
+
assetFileNames: "assets/[name]-[hash][extname]",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
package/README.md
DELETED