@frockbot/kernel-do 0.3.15 → 0.3.17
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/package.json +3 -3
- package/src/applets.test.ts +6 -1
- package/src/applets.ts +3 -1
- package/src/authority.ts +319 -159
- package/src/conversations.test.ts +7 -9
- package/src/index.ts +1 -0
- package/src/model-timeout-settlement.test.ts +8 -8
- package/src/run-liveness.test.ts +2 -1
- package/src/run-records.ts +142 -6
- package/src/run-recovery.ts +2 -1
- package/src/run-terminal-open-turn.test.ts +9 -4
- package/src/run-terminal.ts +61 -44
- package/src/session-event-log.test.ts +267 -0
- package/src/session-event-log.ts +731 -0
- package/src/session-log-size.test.ts +250 -0
- package/src/storage-keys.ts +12 -0
- package/src/turn-admission.test.ts +54 -9
- package/src/turn-supersede.test.ts +161 -8
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Session, type SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { MemoryStorage } from "./memory-storage.fixture.ts";
|
|
4
|
+
import {
|
|
5
|
+
SESSION_EVENT_PAGE_BYTES_V1,
|
|
6
|
+
SessionEventLog,
|
|
7
|
+
sessionEventLogIndexKeyV1,
|
|
8
|
+
sessionEventLogPagePrefixV1,
|
|
9
|
+
sessionEventPayloadPrefixV1,
|
|
10
|
+
} from "./session-event-log.ts";
|
|
11
|
+
|
|
12
|
+
const SESSION_ID = "user-1:primary";
|
|
13
|
+
|
|
14
|
+
function journal(systemBytes = 80_000): SessionEvent[] {
|
|
15
|
+
const session = new Session(SESSION_ID, () => {});
|
|
16
|
+
session.appendBatch([
|
|
17
|
+
{ type: "turn/start", turn: 1 },
|
|
18
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
19
|
+
{
|
|
20
|
+
type: "user/message",
|
|
21
|
+
turn: 1,
|
|
22
|
+
step: 1,
|
|
23
|
+
messageId: "message-1",
|
|
24
|
+
text: "hello",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "model/request",
|
|
28
|
+
turn: 1,
|
|
29
|
+
step: 1,
|
|
30
|
+
request: {
|
|
31
|
+
requestId: "request-1",
|
|
32
|
+
provider: "fake",
|
|
33
|
+
model: "large-context",
|
|
34
|
+
system: "s".repeat(systemBytes),
|
|
35
|
+
messages: [{ role: "user", content: "hello" }],
|
|
36
|
+
tools: [],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: "assistant/message",
|
|
41
|
+
turn: 1,
|
|
42
|
+
step: 1,
|
|
43
|
+
requestId: "request-1",
|
|
44
|
+
text: "done",
|
|
45
|
+
toolCalls: [],
|
|
46
|
+
},
|
|
47
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
48
|
+
{ type: "turn/end", turn: 1, outcome: "completed" },
|
|
49
|
+
]);
|
|
50
|
+
return [...session.events];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("the paged Session event log", () => {
|
|
54
|
+
test("keeps exact large requests behind bounded cut projections", async () => {
|
|
55
|
+
const storage = new MemoryStorage();
|
|
56
|
+
const log = new SessionEventLog(storage);
|
|
57
|
+
const events = journal();
|
|
58
|
+
|
|
59
|
+
await log.rewrite(SESSION_ID, events);
|
|
60
|
+
|
|
61
|
+
expect(await log.read(SESSION_ID)).toEqual(events);
|
|
62
|
+
const projection = (await log.readProjections(SESSION_ID)).find(
|
|
63
|
+
(event) => (event as { type?: string }).type === "model/request",
|
|
64
|
+
) as {
|
|
65
|
+
cut: { marker: string; originalBytes: number; sha256: string };
|
|
66
|
+
request: {
|
|
67
|
+
requestId: string;
|
|
68
|
+
messageCount: number;
|
|
69
|
+
toolCount: number;
|
|
70
|
+
truncated: boolean;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
expect(projection).toMatchObject({
|
|
74
|
+
cut: { marker: "content-cut", originalBytes: expect.any(Number) },
|
|
75
|
+
request: {
|
|
76
|
+
requestId: "request-1",
|
|
77
|
+
messageCount: 1,
|
|
78
|
+
toolCount: 0,
|
|
79
|
+
truncated: true,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
expect(projection.cut.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
83
|
+
expect(
|
|
84
|
+
[...storage.values.entries()]
|
|
85
|
+
.filter(([key]) =>
|
|
86
|
+
key.startsWith(sessionEventLogPagePrefixV1(SESSION_ID)),
|
|
87
|
+
)
|
|
88
|
+
.every(
|
|
89
|
+
([, value]) =>
|
|
90
|
+
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
|
|
91
|
+
SESSION_EVENT_PAGE_BYTES_V1,
|
|
92
|
+
),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(
|
|
95
|
+
[...storage.values.keys()].some((key) =>
|
|
96
|
+
key.startsWith(sessionEventPayloadPrefixV1(SESSION_ID)),
|
|
97
|
+
),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("cuts large assistant and tool bodies without losing their exact events", async () => {
|
|
102
|
+
const storage = new MemoryStorage();
|
|
103
|
+
const log = new SessionEventLog(storage);
|
|
104
|
+
const session = new Session(SESSION_ID, () => {});
|
|
105
|
+
const assistantText = "assistant".repeat(4_000);
|
|
106
|
+
const toolContent = "tool-result".repeat(4_000);
|
|
107
|
+
session.appendBatch([
|
|
108
|
+
{ type: "turn/start", turn: 1 },
|
|
109
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
110
|
+
{
|
|
111
|
+
type: "assistant/message",
|
|
112
|
+
turn: 1,
|
|
113
|
+
step: 1,
|
|
114
|
+
requestId: "request-1",
|
|
115
|
+
text: assistantText,
|
|
116
|
+
toolCalls: [{ id: "call-1", name: "computer_exec", input: {} }],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: "tool/call",
|
|
120
|
+
turn: 1,
|
|
121
|
+
step: 1,
|
|
122
|
+
occurrenceId: "tool:1:1:0",
|
|
123
|
+
name: "computer_exec",
|
|
124
|
+
input: {},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
type: "tool/result",
|
|
128
|
+
turn: 1,
|
|
129
|
+
step: 1,
|
|
130
|
+
occurrenceId: "tool:1:1:0",
|
|
131
|
+
name: "computer_exec",
|
|
132
|
+
content: toolContent,
|
|
133
|
+
isError: false,
|
|
134
|
+
status: "completed",
|
|
135
|
+
},
|
|
136
|
+
{ type: "step/end", turn: 1, step: 1, outcome: "completed" },
|
|
137
|
+
{ type: "turn/end", turn: 1, outcome: "completed" },
|
|
138
|
+
]);
|
|
139
|
+
const events = [...session.events];
|
|
140
|
+
|
|
141
|
+
await log.rewrite(SESSION_ID, events);
|
|
142
|
+
|
|
143
|
+
expect(await log.read(SESSION_ID)).toEqual(events);
|
|
144
|
+
const cut = (await log.readProjections(SESSION_ID)).filter(
|
|
145
|
+
(event) =>
|
|
146
|
+
typeof event === "object" &&
|
|
147
|
+
event !== null &&
|
|
148
|
+
Object.hasOwn(event, "cut"),
|
|
149
|
+
) as Array<{
|
|
150
|
+
type: string;
|
|
151
|
+
cut: { marker: string; originalBytes: number; sha256: string };
|
|
152
|
+
truncated: boolean;
|
|
153
|
+
}>;
|
|
154
|
+
expect(cut.map((event) => event.type)).toEqual([
|
|
155
|
+
"assistant/message",
|
|
156
|
+
"tool/result",
|
|
157
|
+
]);
|
|
158
|
+
expect(cut).toEqual(
|
|
159
|
+
expect.arrayContaining([
|
|
160
|
+
expect.objectContaining({
|
|
161
|
+
truncated: true,
|
|
162
|
+
cut: expect.objectContaining({
|
|
163
|
+
marker: "content-cut",
|
|
164
|
+
originalBytes: expect.any(Number),
|
|
165
|
+
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
166
|
+
}),
|
|
167
|
+
}),
|
|
168
|
+
]),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("appends across fixed-size pages and preserves contiguous ranges", async () => {
|
|
173
|
+
const storage = new MemoryStorage();
|
|
174
|
+
const log = new SessionEventLog(storage);
|
|
175
|
+
const session = new Session(SESSION_ID, () => {});
|
|
176
|
+
for (let turn = 1; turn <= 40; turn += 1) {
|
|
177
|
+
session.appendBatch([
|
|
178
|
+
{ type: "turn/start", turn },
|
|
179
|
+
{ type: "step/start", turn, step: 1 },
|
|
180
|
+
{
|
|
181
|
+
type: "user/message",
|
|
182
|
+
turn,
|
|
183
|
+
step: 1,
|
|
184
|
+
messageId: `message-${turn}`,
|
|
185
|
+
text: "u".repeat(10_000),
|
|
186
|
+
},
|
|
187
|
+
{ type: "step/end", turn, step: 1, outcome: "completed" },
|
|
188
|
+
{ type: "turn/end", turn, outcome: "completed" },
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
const events = [...session.events];
|
|
192
|
+
|
|
193
|
+
await log.rewrite(SESSION_ID, events.slice(0, 101));
|
|
194
|
+
await log.append(SESSION_ID, events.slice(101));
|
|
195
|
+
|
|
196
|
+
expect(await log.read(SESSION_ID)).toEqual(events);
|
|
197
|
+
expect(await log.readRange(SESSION_ID, 95, 110)).toEqual(
|
|
198
|
+
events.slice(95, 110),
|
|
199
|
+
);
|
|
200
|
+
const pages = [...storage.values.keys()].filter((key) =>
|
|
201
|
+
key.startsWith(sessionEventLogPagePrefixV1(SESSION_ID)),
|
|
202
|
+
);
|
|
203
|
+
expect(pages.length).toBeGreaterThan(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("rebases compact run ranges when repair inserts into the log", async () => {
|
|
207
|
+
const storage = new MemoryStorage();
|
|
208
|
+
const log = new SessionEventLog(storage);
|
|
209
|
+
const timestamp = "2026-09-04T00:00:00.000Z";
|
|
210
|
+
const before: SessionEvent[] = [
|
|
211
|
+
{ type: "turn/start", seq: 0, timestamp, turn: 1 },
|
|
212
|
+
{ type: "turn/start", seq: 1, timestamp, turn: 2 },
|
|
213
|
+
];
|
|
214
|
+
await log.rewrite(SESSION_ID, before);
|
|
215
|
+
await storage.put({
|
|
216
|
+
"run:first": {
|
|
217
|
+
sessionId: SESSION_ID,
|
|
218
|
+
previousEventCount: 0,
|
|
219
|
+
eventRange: { startSeq: 0, endSeq: 1 },
|
|
220
|
+
},
|
|
221
|
+
"run:second": {
|
|
222
|
+
sessionId: SESSION_ID,
|
|
223
|
+
previousEventCount: 1,
|
|
224
|
+
eventRange: { startSeq: 1, endSeq: 2 },
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const repaired: SessionEvent[] = [
|
|
228
|
+
before[0]!,
|
|
229
|
+
{
|
|
230
|
+
type: "turn/end",
|
|
231
|
+
seq: 1,
|
|
232
|
+
timestamp,
|
|
233
|
+
turn: 1,
|
|
234
|
+
outcome: "interrupted",
|
|
235
|
+
},
|
|
236
|
+
{ ...before[1]!, seq: 2 },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
await log.rewrite(SESSION_ID, repaired);
|
|
240
|
+
|
|
241
|
+
expect(
|
|
242
|
+
await storage.get<{ eventRange: unknown }>("run:first"),
|
|
243
|
+
).toMatchObject({ eventRange: { startSeq: 0, endSeq: 2 } });
|
|
244
|
+
expect(
|
|
245
|
+
await storage.get<{ eventRange: unknown }>("run:second"),
|
|
246
|
+
).toMatchObject({
|
|
247
|
+
previousEventCount: 2,
|
|
248
|
+
eventRange: { startSeq: 2, endSeq: 3 },
|
|
249
|
+
});
|
|
250
|
+
expect(await log.readRange(SESSION_ID, 2, 3)).toEqual([repaired[2]]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("migrates the legacy single value on demand", async () => {
|
|
254
|
+
const storage = new MemoryStorage();
|
|
255
|
+
const events = journal(1_900_000);
|
|
256
|
+
storage.values.set("latest-events", structuredClone(events));
|
|
257
|
+
const log = new SessionEventLog(storage);
|
|
258
|
+
|
|
259
|
+
expect(await log.migrate(SESSION_ID)).toEqual(events);
|
|
260
|
+
|
|
261
|
+
expect(storage.values.has("latest-events")).toBe(false);
|
|
262
|
+
expect(storage.values.has(sessionEventLogIndexKeyV1(SESSION_ID))).toBe(
|
|
263
|
+
true,
|
|
264
|
+
);
|
|
265
|
+
expect(await log.read(SESSION_ID)).toEqual(events);
|
|
266
|
+
});
|
|
267
|
+
});
|