@frockbot/plugin-shell 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 +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Slice B, the durable half: the partial profile command, the provenance it
|
|
2
|
+
// records, and the rename announcement it appends to the Bot's Session.
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import type { BotSettingsViewV1 } from "@frockbot/configuration-core";
|
|
5
|
+
import { createShellBotBackendContribution } from "./backend.js";
|
|
6
|
+
import { BOT_ANNOUNCEMENT_RETENTION } from "./backend.js";
|
|
7
|
+
|
|
8
|
+
class MemoryStorage {
|
|
9
|
+
readonly values = new Map<string, unknown>();
|
|
10
|
+
|
|
11
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
12
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
16
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
17
|
+
else {
|
|
18
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
19
|
+
this.values.set(entry, structuredClone(item));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
delete(key: string | string[]): Promise<boolean | number> {
|
|
26
|
+
if (Array.isArray(key)) {
|
|
27
|
+
let removed = 0;
|
|
28
|
+
for (const entry of key) if (this.values.delete(entry)) removed += 1;
|
|
29
|
+
return Promise.resolve(removed);
|
|
30
|
+
}
|
|
31
|
+
return Promise.resolve(this.values.delete(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
|
|
35
|
+
return Promise.resolve(
|
|
36
|
+
new Map(
|
|
37
|
+
[...this.values.entries()]
|
|
38
|
+
.filter(([key]) => key.startsWith(options.prefix ?? ""))
|
|
39
|
+
.sort(([left], [right]) => left.localeCompare(right)) as Array<
|
|
40
|
+
[string, T]
|
|
41
|
+
>,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
47
|
+
return callback(this);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setAlarm(): Promise<void> {
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
deleteAlarm(): Promise<void> {
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
60
|
+
|
|
61
|
+
function contributionOn(storage: MemoryStorage) {
|
|
62
|
+
return createShellBotBackendContribution({
|
|
63
|
+
state: { storage } as unknown as DurableObjectState,
|
|
64
|
+
env: {} as never,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function setProfile(
|
|
69
|
+
contribution: ReturnType<typeof contributionOn>,
|
|
70
|
+
commandId: string,
|
|
71
|
+
expectedRevision: number,
|
|
72
|
+
profile: Record<string, unknown>,
|
|
73
|
+
namedBy?: "user" | "bot",
|
|
74
|
+
writer?: Record<string, unknown>,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
await contribution.executeConfiguration({
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
userId: identity.userId,
|
|
79
|
+
botId: identity.botId,
|
|
80
|
+
command: {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
type: "bot/set-profile",
|
|
83
|
+
commandId,
|
|
84
|
+
botId: identity.botId,
|
|
85
|
+
expectedRevision,
|
|
86
|
+
...(namedBy ? { namedBy } : {}),
|
|
87
|
+
...(writer ? { writer } : {}),
|
|
88
|
+
profile,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("bot/set-profile", () => {
|
|
94
|
+
test("changes only the fields the command carries", async () => {
|
|
95
|
+
const storage = new MemoryStorage();
|
|
96
|
+
const contribution = contributionOn(storage);
|
|
97
|
+
await contribution.materializeSettings(identity, { name: "Housework" });
|
|
98
|
+
await setProfile(contribution, "title-1", 0, {
|
|
99
|
+
title: "Chief of staff",
|
|
100
|
+
description: "Keeps things tidy.",
|
|
101
|
+
});
|
|
102
|
+
await setProfile(contribution, "hide-1", 1, { hiddenFromSidebar: true });
|
|
103
|
+
|
|
104
|
+
const settings = (await storage.get(
|
|
105
|
+
"bot-configuration",
|
|
106
|
+
)) as BotSettingsViewV1;
|
|
107
|
+
expect(settings.profile).toEqual({
|
|
108
|
+
name: "Housework",
|
|
109
|
+
title: "Chief of staff",
|
|
110
|
+
description: "Keeps things tidy.",
|
|
111
|
+
hiddenFromSidebar: true,
|
|
112
|
+
});
|
|
113
|
+
expect(settings.revision).toBe(2);
|
|
114
|
+
// Neither command touched the name, so neither recorded a writer for it.
|
|
115
|
+
expect(settings.profile.namedBy).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("records the writer of a rename and announces it in the Session", async () => {
|
|
119
|
+
const storage = new MemoryStorage();
|
|
120
|
+
const contribution = contributionOn(storage);
|
|
121
|
+
await contribution.materializeSettings(identity, { name: "Housework" });
|
|
122
|
+
await setProfile(contribution, "rename-1", 0, { name: "Atlas" }, "bot");
|
|
123
|
+
|
|
124
|
+
const settings = (await storage.get(
|
|
125
|
+
"bot-configuration",
|
|
126
|
+
)) as BotSettingsViewV1;
|
|
127
|
+
expect(settings.profile).toEqual({ name: "Atlas", namedBy: "bot" });
|
|
128
|
+
const announcements = await contribution.listAnnouncements();
|
|
129
|
+
expect(announcements).toHaveLength(1);
|
|
130
|
+
expect(announcements[0]).toMatchObject({
|
|
131
|
+
type: "bot/renamed",
|
|
132
|
+
seq: 0,
|
|
133
|
+
from: "Housework",
|
|
134
|
+
to: "Atlas",
|
|
135
|
+
namedBy: "bot",
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a Bot's self-rename carries its writer into the announcement", async () => {
|
|
140
|
+
const storage = new MemoryStorage();
|
|
141
|
+
const contribution = contributionOn(storage);
|
|
142
|
+
const writer = {
|
|
143
|
+
kind: "bot",
|
|
144
|
+
botId: identity.botId,
|
|
145
|
+
sessionId: "user-1:primary",
|
|
146
|
+
turnId: "turn-4",
|
|
147
|
+
};
|
|
148
|
+
await contribution.materializeSettings(identity, { name: "Housework" });
|
|
149
|
+
await setProfile(
|
|
150
|
+
contribution,
|
|
151
|
+
"rename-1",
|
|
152
|
+
0,
|
|
153
|
+
{ name: "Atlas" },
|
|
154
|
+
"bot",
|
|
155
|
+
writer,
|
|
156
|
+
);
|
|
157
|
+
// A User edit that happens to carry no writer still announces as before.
|
|
158
|
+
await setProfile(contribution, "rename-2", 1, { name: "Housework" });
|
|
159
|
+
|
|
160
|
+
const announcements = await contribution.listAnnouncements();
|
|
161
|
+
expect(announcements[0]).toMatchObject({ namedBy: "bot", writer });
|
|
162
|
+
expect(announcements[1]).toMatchObject({ namedBy: "user" });
|
|
163
|
+
expect(announcements[1]).not.toHaveProperty("writer");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("announces a User rename by default and never for an unchanged name", async () => {
|
|
167
|
+
const storage = new MemoryStorage();
|
|
168
|
+
const contribution = contributionOn(storage);
|
|
169
|
+
await contribution.materializeSettings(identity, { name: "Housework" });
|
|
170
|
+
await setProfile(contribution, "rename-1", 0, { name: "Atlas" });
|
|
171
|
+
await setProfile(contribution, "same-1", 1, { name: "Atlas" });
|
|
172
|
+
await setProfile(contribution, "title-1", 2, { title: "Chief" });
|
|
173
|
+
|
|
174
|
+
const announcements = await contribution.listAnnouncements();
|
|
175
|
+
expect(announcements).toHaveLength(1);
|
|
176
|
+
expect(announcements[0]).toMatchObject({ namedBy: "user", to: "Atlas" });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("bounds the announcement log and keeps the newest renames", async () => {
|
|
180
|
+
const storage = new MemoryStorage();
|
|
181
|
+
const contribution = contributionOn(storage);
|
|
182
|
+
await contribution.materializeSettings(identity, { name: "name-0" });
|
|
183
|
+
const renames = BOT_ANNOUNCEMENT_RETENTION + 3;
|
|
184
|
+
for (let index = 1; index <= renames; index += 1) {
|
|
185
|
+
await setProfile(contribution, `rename-${index}`, index - 1, {
|
|
186
|
+
name: `name-${index}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const announcements = await contribution.listAnnouncements();
|
|
190
|
+
expect(announcements).toHaveLength(BOT_ANNOUNCEMENT_RETENTION);
|
|
191
|
+
expect(announcements.at(-1)).toMatchObject({ to: `name-${renames}` });
|
|
192
|
+
expect(announcements[0]).toMatchObject({
|
|
193
|
+
to: `name-${renames - BOT_ANNOUNCEMENT_RETENTION + 1}`,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a rename announcement rides the run list the Session shows", async () => {
|
|
198
|
+
const storage = new MemoryStorage();
|
|
199
|
+
const contribution = contributionOn(storage);
|
|
200
|
+
await contribution.materializeSettings(identity, { name: "Housework" });
|
|
201
|
+
await setProfile(contribution, "rename-1", 0, { name: "Atlas" }, "user");
|
|
202
|
+
|
|
203
|
+
const page = await contribution.listRuns({ schemaVersion: 1 });
|
|
204
|
+
expect(page.announcements).toEqual([
|
|
205
|
+
{
|
|
206
|
+
type: "bot/renamed",
|
|
207
|
+
announcementId: "announcement-0",
|
|
208
|
+
at: expect.any(String),
|
|
209
|
+
from: "Housework",
|
|
210
|
+
to: "Atlas",
|
|
211
|
+
namedBy: "user",
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
|
|
4
|
+
import {
|
|
5
|
+
botTurnCommandFingerprintV1,
|
|
6
|
+
type BotTurnCompletion,
|
|
7
|
+
type StoredRun,
|
|
8
|
+
} from "./backend-contracts.js";
|
|
9
|
+
import {
|
|
10
|
+
completeStoredRun,
|
|
11
|
+
failStoredRun,
|
|
12
|
+
requireStoredRunReconciliation,
|
|
13
|
+
type RunTerminalKeys,
|
|
14
|
+
type RunTerminalStorage,
|
|
15
|
+
} from "./backend-completion.js";
|
|
16
|
+
|
|
17
|
+
const keys: RunTerminalKeys = {
|
|
18
|
+
run: "run:run-1",
|
|
19
|
+
activeRun: "active-run",
|
|
20
|
+
latestEvents: "latest-events",
|
|
21
|
+
notificationPrefix: "notification:",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ended = {
|
|
25
|
+
type: "turn/end" as const,
|
|
26
|
+
seq: 0,
|
|
27
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
28
|
+
turn: 1,
|
|
29
|
+
outcome: "completed" as const,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function storedRun(): StoredRun {
|
|
33
|
+
return {
|
|
34
|
+
runId: "run-1",
|
|
35
|
+
commandFingerprint: botTurnCommandFingerprintV1({
|
|
36
|
+
userId: "user-1",
|
|
37
|
+
botId: "primary",
|
|
38
|
+
runId: "run-1",
|
|
39
|
+
sessionId: "user:primary",
|
|
40
|
+
acceptedAt: "2026-08-28T00:00:00.000Z",
|
|
41
|
+
text: "hello",
|
|
42
|
+
}),
|
|
43
|
+
sessionId: "user:primary",
|
|
44
|
+
acceptedAt: "2026-08-28T00:00:00.000Z",
|
|
45
|
+
input: "hello",
|
|
46
|
+
events: [ended],
|
|
47
|
+
effectAdmissions: [],
|
|
48
|
+
status: "running",
|
|
49
|
+
phase: "executing",
|
|
50
|
+
compositionGenerationId: "test-composition-generation",
|
|
51
|
+
configurationSnapshot: initializeBotSettingsV1("primary"),
|
|
52
|
+
previousEventCount: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function result(): BotTurnCompletion {
|
|
57
|
+
return {
|
|
58
|
+
runId: "run-1",
|
|
59
|
+
text: "Done",
|
|
60
|
+
events: [ended],
|
|
61
|
+
notification: {
|
|
62
|
+
notificationId: "run-1",
|
|
63
|
+
runId: "run-1",
|
|
64
|
+
createdAt: "2026-08-28T00:00:01.000Z",
|
|
65
|
+
title: "Bot replied",
|
|
66
|
+
body: "Done",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class MemoryRunStorage implements RunTerminalStorage {
|
|
72
|
+
readonly values = new Map<string, unknown>([
|
|
73
|
+
[keys.run, storedRun()],
|
|
74
|
+
[keys.activeRun, "run-1"],
|
|
75
|
+
[keys.latestEvents, []],
|
|
76
|
+
]);
|
|
77
|
+
putFailure: Error | undefined;
|
|
78
|
+
putBatches: Array<Record<string, unknown>> = [];
|
|
79
|
+
|
|
80
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
81
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
put(entries: Record<string, unknown>): Promise<void> {
|
|
85
|
+
this.putBatches.push(structuredClone(entries));
|
|
86
|
+
if (this.putFailure) return Promise.reject(this.putFailure);
|
|
87
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
88
|
+
this.values.set(key, structuredClone(value));
|
|
89
|
+
}
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
delete(key: string): Promise<boolean> {
|
|
94
|
+
return Promise.resolve(this.values.delete(key));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("Bot run terminal persistence", () => {
|
|
99
|
+
test("commits completion and notification in one durable batch", async () => {
|
|
100
|
+
const storage = new MemoryRunStorage();
|
|
101
|
+
|
|
102
|
+
await completeStoredRun(storage, keys, "run-1", [], result());
|
|
103
|
+
|
|
104
|
+
expect(storage.putBatches).toHaveLength(1);
|
|
105
|
+
expect(storage.putBatches[0]).toHaveProperty(keys.run);
|
|
106
|
+
expect(storage.putBatches[0]).toHaveProperty("notification:run-1");
|
|
107
|
+
expect(storage.values.get(keys.run)).toMatchObject({ status: "completed" });
|
|
108
|
+
expect(storage.values.has(keys.activeRun)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("cancels without notifying when Stop wins the terminal transaction", async () => {
|
|
112
|
+
const storage = new MemoryRunStorage();
|
|
113
|
+
storage.values.set(keys.run, {
|
|
114
|
+
...storedRun(),
|
|
115
|
+
stopRequestedAt: "2026-08-30T00:00:00.000Z",
|
|
116
|
+
} satisfies StoredRun);
|
|
117
|
+
|
|
118
|
+
await expect(
|
|
119
|
+
completeStoredRun(storage, keys, "run-1", [], result()),
|
|
120
|
+
).resolves.toBe("cancelled");
|
|
121
|
+
|
|
122
|
+
expect(storage.values.get(keys.run)).toMatchObject({
|
|
123
|
+
status: "cancelled",
|
|
124
|
+
stopRequestedAt: "2026-08-30T00:00:00.000Z",
|
|
125
|
+
});
|
|
126
|
+
expect(storage.values.has("notification:run-1")).toBe(false);
|
|
127
|
+
expect(storage.values.has(keys.activeRun)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rejects malformed terminal events before clearing active work", async () => {
|
|
131
|
+
const storage = new MemoryRunStorage();
|
|
132
|
+
const malformed = {
|
|
133
|
+
...result(),
|
|
134
|
+
events: [
|
|
135
|
+
{
|
|
136
|
+
type: "model/request",
|
|
137
|
+
seq: 0,
|
|
138
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
139
|
+
turn: 1,
|
|
140
|
+
step: 1,
|
|
141
|
+
},
|
|
142
|
+
] as SessionEvent[],
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
completeStoredRun(storage, keys, "run-1", [], malformed),
|
|
147
|
+
).rejects.toThrow();
|
|
148
|
+
|
|
149
|
+
expect(storage.values.get(keys.run)).toMatchObject({ status: "running" });
|
|
150
|
+
expect(storage.values.get(keys.activeRun)).toBe("run-1");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("persists responses up to the public wire limit", async () => {
|
|
154
|
+
const storage = new MemoryRunStorage();
|
|
155
|
+
|
|
156
|
+
await completeStoredRun(storage, keys, "run-1", [], {
|
|
157
|
+
...result(),
|
|
158
|
+
text: "x".repeat(64_000),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(storage.values.get(keys.run)).toMatchObject({
|
|
162
|
+
status: "completed",
|
|
163
|
+
responseText: "x".repeat(64_000),
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("does not leave a success notification when completion rolls back", async () => {
|
|
168
|
+
const storage = new MemoryRunStorage();
|
|
169
|
+
storage.putFailure = new Error("completion transaction failed");
|
|
170
|
+
|
|
171
|
+
await expect(
|
|
172
|
+
completeStoredRun(storage, keys, "run-1", [], result()),
|
|
173
|
+
).rejects.toThrow("completion transaction failed");
|
|
174
|
+
storage.putFailure = undefined;
|
|
175
|
+
await failStoredRun(
|
|
176
|
+
storage,
|
|
177
|
+
keys,
|
|
178
|
+
"run-1",
|
|
179
|
+
[],
|
|
180
|
+
[ended],
|
|
181
|
+
"completion transaction failed",
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(storage.values.get(keys.run)).toMatchObject({ status: "failed" });
|
|
185
|
+
expect(storage.values.has("notification:run-1")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("cancels instead of failing when Stop wins the failure transaction", async () => {
|
|
189
|
+
const storage = new MemoryRunStorage();
|
|
190
|
+
storage.values.set(keys.run, {
|
|
191
|
+
...storedRun(),
|
|
192
|
+
stopRequestedAt: "2026-08-30T00:00:00.000Z",
|
|
193
|
+
} satisfies StoredRun);
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
failStoredRun(storage, keys, "run-1", [], [ended], "late failure"),
|
|
197
|
+
).resolves.toBe("cancelled");
|
|
198
|
+
|
|
199
|
+
expect(storage.values.get(keys.run)).toMatchObject({ status: "cancelled" });
|
|
200
|
+
expect(storage.values.has(keys.activeRun)).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("terminalizes an explicitly abandoned reconciliation", async () => {
|
|
204
|
+
const storage = new MemoryRunStorage();
|
|
205
|
+
storage.values.set(keys.run, {
|
|
206
|
+
...storedRun(),
|
|
207
|
+
status: "reconciliation-required",
|
|
208
|
+
phase: "reconciliation-required",
|
|
209
|
+
failure: "provider outcome is uncertain",
|
|
210
|
+
} satisfies StoredRun);
|
|
211
|
+
|
|
212
|
+
await failStoredRun(
|
|
213
|
+
storage,
|
|
214
|
+
keys,
|
|
215
|
+
"run-1",
|
|
216
|
+
[],
|
|
217
|
+
[ended],
|
|
218
|
+
"reconciliation abandoned",
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(storage.values.get(keys.run)).toMatchObject({
|
|
222
|
+
status: "failed",
|
|
223
|
+
phase: "executing",
|
|
224
|
+
failure: "reconciliation abandoned",
|
|
225
|
+
});
|
|
226
|
+
expect(storage.values.has(keys.activeRun)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("preserves committed success after an uncertain response", async () => {
|
|
230
|
+
const storage = new MemoryRunStorage();
|
|
231
|
+
await completeStoredRun(storage, keys, "run-1", [], result());
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
failStoredRun(
|
|
235
|
+
storage,
|
|
236
|
+
keys,
|
|
237
|
+
"run-1",
|
|
238
|
+
[],
|
|
239
|
+
[
|
|
240
|
+
{
|
|
241
|
+
...ended,
|
|
242
|
+
outcome: "model-error",
|
|
243
|
+
} satisfies SessionEvent,
|
|
244
|
+
],
|
|
245
|
+
"completion response lost",
|
|
246
|
+
),
|
|
247
|
+
).resolves.toBe("preserved-completion");
|
|
248
|
+
|
|
249
|
+
expect(storage.values.get(keys.run)).toMatchObject({ status: "completed" });
|
|
250
|
+
expect(storage.values.has("notification:run-1")).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("keeps an unretrievable effect active and reconciliation-required", async () => {
|
|
254
|
+
const storage = new MemoryRunStorage();
|
|
255
|
+
const request = {
|
|
256
|
+
type: "model/request" as const,
|
|
257
|
+
seq: 0,
|
|
258
|
+
timestamp: "2026-08-28T00:00:00.000Z",
|
|
259
|
+
turn: 1,
|
|
260
|
+
step: 1,
|
|
261
|
+
request: {
|
|
262
|
+
requestId: "effect-1",
|
|
263
|
+
provider: "openai-compatible",
|
|
264
|
+
model: "model-1",
|
|
265
|
+
system: "",
|
|
266
|
+
messages: [],
|
|
267
|
+
tools: [],
|
|
268
|
+
},
|
|
269
|
+
} satisfies SessionEvent;
|
|
270
|
+
storage.values.set(keys.run, { ...storedRun(), events: [request] });
|
|
271
|
+
|
|
272
|
+
await requireStoredRunReconciliation(
|
|
273
|
+
storage,
|
|
274
|
+
keys,
|
|
275
|
+
"run-1",
|
|
276
|
+
[],
|
|
277
|
+
[request],
|
|
278
|
+
"provider-bound retrieval unavailable",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(storage.values.get(keys.run)).toMatchObject({
|
|
282
|
+
status: "reconciliation-required",
|
|
283
|
+
phase: "reconciliation-required",
|
|
284
|
+
failure: "provider-bound retrieval unavailable",
|
|
285
|
+
});
|
|
286
|
+
expect(storage.values.get(keys.activeRun)).toBe("run-1");
|
|
287
|
+
expect(storage.values.get(keys.latestEvents)).toEqual([request]);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Kernel run-terminal transitions, bound to the Shell Package's run codec.
|
|
2
|
+
import { type SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
|
+
import {
|
|
4
|
+
cancelStoredRun as cancelKernelStoredRun,
|
|
5
|
+
completeStoredRun as completeKernelStoredRun,
|
|
6
|
+
failStoredRun as failKernelStoredRun,
|
|
7
|
+
requireStoredRunReconciliation as requireKernelStoredRunReconciliation,
|
|
8
|
+
type TerminalPackageRecords,
|
|
9
|
+
} from "@frockbot/kernel-do";
|
|
10
|
+
import {
|
|
11
|
+
storedRunCodecV1,
|
|
12
|
+
type BotTurnCompletion,
|
|
13
|
+
} from "./backend-contracts.js";
|
|
14
|
+
import type { BotSettingsViewV1 } from "@frockbot/configuration-core";
|
|
15
|
+
|
|
16
|
+
export type { RunTerminalKeys, RunTerminalStorage } from "@frockbot/kernel-do";
|
|
17
|
+
import type { RunTerminalKeys, RunTerminalStorage } from "@frockbot/kernel-do";
|
|
18
|
+
|
|
19
|
+
export function completeStoredRun(
|
|
20
|
+
storage: RunTerminalStorage,
|
|
21
|
+
keys: RunTerminalKeys,
|
|
22
|
+
runId: string,
|
|
23
|
+
previous: readonly SessionEvent[],
|
|
24
|
+
result: BotTurnCompletion,
|
|
25
|
+
packageRecords?: TerminalPackageRecords<BotSettingsViewV1>,
|
|
26
|
+
): Promise<"completed" | "cancelled"> {
|
|
27
|
+
return completeKernelStoredRun(
|
|
28
|
+
storedRunCodecV1,
|
|
29
|
+
storage,
|
|
30
|
+
keys,
|
|
31
|
+
runId,
|
|
32
|
+
previous,
|
|
33
|
+
result,
|
|
34
|
+
packageRecords,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function failStoredRun(
|
|
39
|
+
storage: RunTerminalStorage,
|
|
40
|
+
keys: RunTerminalKeys,
|
|
41
|
+
runId: string,
|
|
42
|
+
previous: readonly SessionEvent[],
|
|
43
|
+
events: readonly SessionEvent[],
|
|
44
|
+
failure: string,
|
|
45
|
+
): Promise<"failed" | "cancelled" | "preserved-completion" | "missing"> {
|
|
46
|
+
return failKernelStoredRun(
|
|
47
|
+
storedRunCodecV1,
|
|
48
|
+
storage,
|
|
49
|
+
keys,
|
|
50
|
+
runId,
|
|
51
|
+
previous,
|
|
52
|
+
events,
|
|
53
|
+
failure,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Settles a stopped run as terminal `cancelled` and clears its active marker.
|
|
59
|
+
* A cancelled run produces no response text, no failure, and no notification.
|
|
60
|
+
*/
|
|
61
|
+
export function cancelStoredRun(
|
|
62
|
+
storage: RunTerminalStorage,
|
|
63
|
+
keys: RunTerminalKeys,
|
|
64
|
+
runId: string,
|
|
65
|
+
previous: readonly SessionEvent[],
|
|
66
|
+
events: readonly SessionEvent[],
|
|
67
|
+
): Promise<"cancelled" | "preserved-completion" | "missing"> {
|
|
68
|
+
return cancelKernelStoredRun(
|
|
69
|
+
storedRunCodecV1,
|
|
70
|
+
storage,
|
|
71
|
+
keys,
|
|
72
|
+
runId,
|
|
73
|
+
previous,
|
|
74
|
+
events,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function requireStoredRunReconciliation(
|
|
79
|
+
storage: RunTerminalStorage,
|
|
80
|
+
keys: RunTerminalKeys,
|
|
81
|
+
runId: string,
|
|
82
|
+
previous: readonly SessionEvent[],
|
|
83
|
+
events: readonly SessionEvent[],
|
|
84
|
+
failure: string,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
return requireKernelStoredRunReconciliation(
|
|
87
|
+
storedRunCodecV1,
|
|
88
|
+
storage,
|
|
89
|
+
keys,
|
|
90
|
+
runId,
|
|
91
|
+
previous,
|
|
92
|
+
events,
|
|
93
|
+
failure,
|
|
94
|
+
);
|
|
95
|
+
}
|