@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.
Files changed (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,1281 @@
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 type { StoredRun } from "./backend-contracts.js";
5
+ import { planBotRunRecovery } from "./backend-recovery.js";
6
+ import {
7
+ createClientRunStopReceiptV1,
8
+ decodeClientNotificationAcknowledgementCommandV1,
9
+ decodeClientRunAdmissionFenceCommandV1,
10
+ decodeClientRunLookupQueryV1,
11
+ decodeClientRunReconciliationCommandV1,
12
+ decodeClientRunStopCommandV1,
13
+ decodeClientRunStopReceiptV1,
14
+ decodeClientTurnCommandV1,
15
+ decodeClientRunLookupV1,
16
+ decodeClientTurnV1,
17
+ decodeClientRunPageV1,
18
+ decodeClientRunListV1,
19
+ createClientRunListV1,
20
+ projectClientAnnouncementsV1,
21
+ decodeClientRunListQueryV1,
22
+ projectClientRunLookupV1,
23
+ projectClientRunListV1,
24
+ projectClientRunV1,
25
+ projectClientTurnV1,
26
+ } from "./run-protocol.js";
27
+
28
+ const timestamp = "2026-08-29T00:00:00.000Z";
29
+
30
+ function event<T extends SessionEvent>(value: T): T {
31
+ return value;
32
+ }
33
+
34
+ function toolEvents(count: number): SessionEvent[] {
35
+ return Array.from({ length: count }, (_, index) => index).flatMap((index) => [
36
+ event({
37
+ type: "tool/call",
38
+ seq: index * 2,
39
+ timestamp,
40
+ turn: 1,
41
+ step: 1,
42
+ occurrenceId: `tool:1:1:${index}`,
43
+ name: "lookup",
44
+ input: {},
45
+ }),
46
+ event({
47
+ type: "tool/result",
48
+ seq: index * 2 + 1,
49
+ timestamp,
50
+ turn: 1,
51
+ step: 1,
52
+ occurrenceId: `tool:1:1:${index}`,
53
+ name: "lookup",
54
+ content: `result-${index}`,
55
+ isError: false,
56
+ status: "completed",
57
+ }),
58
+ ]);
59
+ }
60
+
61
+ function storedRun(
62
+ events: SessionEvent[],
63
+ status: StoredRun["status"] = "completed",
64
+ ): StoredRun {
65
+ return {
66
+ runId: "run-events",
67
+ commandFingerprint: "fingerprint",
68
+ sessionId: "user:primary",
69
+ acceptedAt: timestamp,
70
+ input: "continue",
71
+ events,
72
+ effectAdmissions: [],
73
+ status,
74
+ phase: status === "reconciliation-required" ? status : "executing",
75
+ compositionGenerationId: "test-composition-generation",
76
+ configurationSnapshot: initializeBotSettingsV1("primary"),
77
+ previousEventCount: 0,
78
+ ...(status === "completed" ? { responseText: "done" } : {}),
79
+ ...(status === "failed" ? { failure: "failed" } : {}),
80
+ ...(status === "cancelled"
81
+ ? { stopRequestedAt: "2026-08-28T00:00:05.000Z" }
82
+ : {}),
83
+ };
84
+ }
85
+
86
+ describe("client run protocol v1", () => {
87
+ test("rejects durable runs missing current admission fields", () => {
88
+ const complete = storedRun([], "running");
89
+ for (const field of [
90
+ "runId",
91
+ "commandFingerprint",
92
+ "sessionId",
93
+ "acceptedAt",
94
+ "input",
95
+ "events",
96
+ "status",
97
+ "phase",
98
+ "compositionGenerationId",
99
+ "configurationSnapshot",
100
+ "previousEventCount",
101
+ ] as const) {
102
+ const incomplete = structuredClone(complete) as unknown as Record<
103
+ string,
104
+ unknown
105
+ >;
106
+ delete incomplete[field];
107
+ expect(() =>
108
+ projectClientRunLookupV1(incomplete as unknown as StoredRun),
109
+ ).toThrow();
110
+ }
111
+ expect(() =>
112
+ projectClientRunLookupV1({
113
+ ...complete,
114
+ unrecognized: true,
115
+ } as unknown as StoredRun),
116
+ ).toThrow("stored run has invalid fields");
117
+ for (const runId of [42, "", "x".repeat(129)]) {
118
+ expect(() =>
119
+ projectClientRunLookupV1({
120
+ ...complete,
121
+ runId,
122
+ } as unknown as StoredRun),
123
+ ).toThrow("stored run has invalid runId");
124
+ }
125
+ expect(() =>
126
+ projectClientRunLookupV1({
127
+ ...complete,
128
+ events: [{ type: "turn/start" }],
129
+ } as unknown as StoredRun),
130
+ ).toThrow("session event.seq must be an integer");
131
+ expect(() =>
132
+ projectClientRunLookupV1({
133
+ ...complete,
134
+ events: [
135
+ {
136
+ type: "model/request",
137
+ seq: 0,
138
+ timestamp,
139
+ turn: 1,
140
+ step: 1,
141
+ },
142
+ ],
143
+ } as unknown as StoredRun),
144
+ ).toThrow("session event has invalid fields");
145
+ });
146
+
147
+ test("projects and strictly decodes command-specific admission state", () => {
148
+ expect(
149
+ decodeClientRunLookupV1(projectClientRunLookupV1(undefined)),
150
+ ).toEqual({ state: "not-admitted" });
151
+
152
+ const running = projectClientRunLookupV1(storedRun([], "running"));
153
+ expect(decodeClientRunLookupV1(structuredClone(running))).toMatchObject({
154
+ state: "running",
155
+ run: { runId: "run-events", status: "running" },
156
+ });
157
+
158
+ const terminal = projectClientRunLookupV1(storedRun([], "completed"));
159
+ if (terminal.state === "not-admitted") {
160
+ throw new Error("expected an admitted run");
161
+ }
162
+ expect(decodeClientRunLookupV1(structuredClone(terminal))).toMatchObject({
163
+ state: "terminal",
164
+ run: { runId: "run-events", status: "completed" },
165
+ });
166
+
167
+ expect(() =>
168
+ decodeClientRunLookupV1({
169
+ ...terminal,
170
+ state: "running",
171
+ }),
172
+ ).toThrow("run lookup.state does not match run.status");
173
+ expect(() =>
174
+ decodeClientRunLookupV1({
175
+ schemaVersion: 1,
176
+ state: "not-admitted",
177
+ run: terminal.run,
178
+ }),
179
+ ).toThrow("not-admitted run lookup must not include a run");
180
+ expect(() =>
181
+ decodeClientRunLookupV1({
182
+ schemaVersion: 1,
183
+ state: "not-admitted",
184
+ commandFingerprint: "private",
185
+ }),
186
+ ).toThrow("run lookup.commandFingerprint is not allowed");
187
+ });
188
+
189
+ test("strictly decodes exact Stop commands that target one run", () => {
190
+ expect(
191
+ decodeClientRunStopCommandV1({
192
+ schemaVersion: 1,
193
+ action: "stop",
194
+ commandId: "stop-1",
195
+ runId: "run-1",
196
+ }),
197
+ ).toEqual({
198
+ schemaVersion: 1,
199
+ action: "stop",
200
+ commandId: "stop-1",
201
+ runId: "run-1",
202
+ });
203
+ const hidden = {
204
+ schemaVersion: 1,
205
+ action: "stop",
206
+ commandId: "stop-1",
207
+ runId: "run-1",
208
+ };
209
+ Object.defineProperty(hidden, "reason", { value: "user" });
210
+ const symbol = {
211
+ schemaVersion: 1,
212
+ action: "stop",
213
+ commandId: "stop-1",
214
+ runId: "run-1",
215
+ [Symbol("reason")]: "user",
216
+ };
217
+ for (const invalid of [
218
+ { schemaVersion: 2, action: "stop", commandId: "stop-1", runId: "run-1" },
219
+ {
220
+ schemaVersion: 1,
221
+ action: "cancel",
222
+ commandId: "stop-1",
223
+ runId: "run-1",
224
+ },
225
+ { schemaVersion: 1, action: "stop", commandId: "stop-1" },
226
+ { schemaVersion: 1, action: "stop", runId: "run-1" },
227
+ {
228
+ schemaVersion: 1,
229
+ action: "stop",
230
+ commandId: "stop/1",
231
+ runId: "run-1",
232
+ },
233
+ {
234
+ schemaVersion: 1,
235
+ action: "stop",
236
+ commandId: "stop-1",
237
+ runId: "run/1",
238
+ },
239
+ {
240
+ schemaVersion: 1,
241
+ action: "stop",
242
+ commandId: "stop-1",
243
+ runId: "run-1",
244
+ reason: "user",
245
+ },
246
+ hidden,
247
+ symbol,
248
+ ]) {
249
+ expect(() => decodeClientRunStopCommandV1(invalid)).toThrow();
250
+ }
251
+ });
252
+
253
+ test("projects Stop intent and cancellation without claiming terminality", () => {
254
+ const command = decodeClientRunStopCommandV1({
255
+ schemaVersion: 1,
256
+ action: "stop",
257
+ commandId: "stop-1",
258
+ runId: "run-events",
259
+ });
260
+ const stopping = {
261
+ ...storedRun([], "running"),
262
+ stopRequestedAt: "2026-08-29T00:00:05.000Z",
263
+ } satisfies StoredRun;
264
+
265
+ const accepted = createClientRunStopReceiptV1(
266
+ command,
267
+ projectClientRunV1(stopping),
268
+ );
269
+ expect(accepted).toMatchObject({
270
+ schemaVersion: 1,
271
+ status: "accepted",
272
+ commandId: "stop-1",
273
+ runId: "run-events",
274
+ run: { status: "running", stopRequestedAt: "2026-08-29T00:00:05.000Z" },
275
+ });
276
+ expect(
277
+ decodeClientRunStopReceiptV1(structuredClone(accepted)).run,
278
+ ).toMatchObject({ status: "running" });
279
+
280
+ const cancelled = projectClientRunV1(storedRun([], "cancelled"));
281
+ expect(cancelled).toMatchObject({
282
+ status: "cancelled",
283
+ stopRequestedAt: "2026-08-28T00:00:05.000Z",
284
+ outcome: {
285
+ type: "cancelled",
286
+ message: "Stopped by an authenticated Stop command.",
287
+ },
288
+ });
289
+ expect(projectClientRunLookupV1(storedRun([], "cancelled"))).toMatchObject({
290
+ state: "terminal",
291
+ });
292
+ expect(
293
+ decodeClientRunStopReceiptV1(
294
+ createClientRunStopReceiptV1(command, cancelled),
295
+ ).run,
296
+ ).toMatchObject({
297
+ status: "cancelled",
298
+ failure: "Stopped by an authenticated Stop command.",
299
+ });
300
+
301
+ expect(() =>
302
+ createClientRunStopReceiptV1(
303
+ command,
304
+ projectClientRunV1({ ...storedRun([], "running"), runId: "other-run" }),
305
+ ),
306
+ ).toThrow("run stop receipt does not match its command");
307
+ expect(() =>
308
+ decodeClientRunStopReceiptV1({ ...accepted, status: "cancelled" }),
309
+ ).toThrow("run stop receipt is invalid");
310
+ expect(() =>
311
+ decodeClientRunStopReceiptV1({
312
+ ...accepted,
313
+ run: { ...accepted.run, status: "cancelled" },
314
+ }),
315
+ ).toThrow();
316
+ });
317
+
318
+ test("strictly decodes run lookup queries", () => {
319
+ expect(
320
+ decodeClientRunLookupQueryV1({
321
+ schemaVersion: 1,
322
+ runId: "command-1",
323
+ }),
324
+ ).toEqual({ schemaVersion: 1, runId: "command-1" });
325
+ expect(() =>
326
+ decodeClientRunLookupQueryV1({
327
+ schemaVersion: 1,
328
+ runId: "command/1",
329
+ }),
330
+ ).toThrow("run lookup query.runId is invalid");
331
+ expect(() =>
332
+ decodeClientRunLookupQueryV1({
333
+ schemaVersion: 1,
334
+ runId: "command-1",
335
+ extra: true,
336
+ }),
337
+ ).toThrow("run lookup query.extra is not allowed");
338
+ });
339
+
340
+ test("carries invoked Skills on a Turn command, bounded and exact", () => {
341
+ const ref = (slug: string) => ({
342
+ schemaVersion: 1 as const,
343
+ source: "bot" as const,
344
+ slug,
345
+ });
346
+ expect(
347
+ decodeClientTurnCommandV1({
348
+ schemaVersion: 1,
349
+ commandId: "command-1",
350
+ text: "run it",
351
+ skills: [ref("daily-standup")],
352
+ }),
353
+ ).toEqual({
354
+ schemaVersion: 1,
355
+ commandId: "command-1",
356
+ text: "run it",
357
+ skills: [ref("daily-standup")],
358
+ });
359
+ // Absent and empty mean the same thing, and both leave the field off, so
360
+ // an unchanged client is byte-identical to what it sent before.
361
+ expect(
362
+ decodeClientTurnCommandV1({
363
+ schemaVersion: 1,
364
+ commandId: "command-1",
365
+ text: "run it",
366
+ skills: [],
367
+ }),
368
+ ).toEqual({ schemaVersion: 1, commandId: "command-1", text: "run it" });
369
+ // The wire admits every declared source, so K1 and K2 add no codec change.
370
+ expect(
371
+ decodeClientTurnCommandV1({
372
+ schemaVersion: 1,
373
+ commandId: "command-1",
374
+ text: "run it",
375
+ skills: [{ schemaVersion: 1, source: "managed", slug: "teach" }],
376
+ }).skills,
377
+ ).toEqual([{ schemaVersion: 1, source: "managed", slug: "teach" }]);
378
+ for (const skills of [
379
+ [ref("a"), ref("b"), ref("c"), ref("d")],
380
+ [{ schemaVersion: 1, source: "workflow", slug: "a" }],
381
+ [{ schemaVersion: 1, source: "bot", slug: "a", body: "text" }],
382
+ "bot/a",
383
+ ]) {
384
+ expect(() =>
385
+ decodeClientTurnCommandV1({
386
+ schemaVersion: 1,
387
+ commandId: "command-1",
388
+ text: "run it",
389
+ skills,
390
+ }),
391
+ ).toThrow();
392
+ }
393
+ });
394
+
395
+ test("strictly decodes hosted Turn, notification, and reconciliation commands", () => {
396
+ expect(
397
+ decodeClientTurnCommandV1({
398
+ schemaVersion: 1,
399
+ commandId: "command-1",
400
+ text: " hello ",
401
+ }),
402
+ ).toEqual({ schemaVersion: 1, commandId: "command-1", text: "hello" });
403
+ expect(() =>
404
+ decodeClientTurnCommandV1({
405
+ schemaVersion: 1,
406
+ commandId: "command-1",
407
+ text: "hello",
408
+ action: "cancel",
409
+ }),
410
+ ).toThrow("turn command.action is not allowed");
411
+ expect(() =>
412
+ decodeClientTurnCommandV1({
413
+ schemaVersion: 2,
414
+ commandId: "command-1",
415
+ text: "hello",
416
+ }),
417
+ ).toThrow("turn command.schemaVersion is invalid");
418
+
419
+ expect(
420
+ decodeClientNotificationAcknowledgementCommandV1({
421
+ schemaVersion: 1,
422
+ action: "acknowledge",
423
+ notificationId: "notification-1",
424
+ }),
425
+ ).toEqual({
426
+ schemaVersion: 1,
427
+ action: "acknowledge",
428
+ notificationId: "notification-1",
429
+ });
430
+ expect(() =>
431
+ decodeClientNotificationAcknowledgementCommandV1({
432
+ schemaVersion: 1,
433
+ action: "acknowledge",
434
+ notificationId: "notification-1",
435
+ extra: true,
436
+ }),
437
+ ).toThrow("notification acknowledgement command.extra is not allowed");
438
+
439
+ expect(
440
+ decodeClientRunReconciliationCommandV1({
441
+ schemaVersion: 1,
442
+ action: "resume",
443
+ }),
444
+ ).toEqual({ schemaVersion: 1, action: "resume" });
445
+ expect(() =>
446
+ decodeClientRunReconciliationCommandV1({
447
+ schemaVersion: 2,
448
+ action: "resume",
449
+ }),
450
+ ).toThrow("run reconciliation command is invalid");
451
+ });
452
+
453
+ test("strictly decodes authoritative admission fence commands", () => {
454
+ expect(
455
+ decodeClientRunAdmissionFenceCommandV1({
456
+ schemaVersion: 1,
457
+ action: "fence-admission",
458
+ }),
459
+ ).toEqual({ schemaVersion: 1, action: "fence-admission" });
460
+ expect(() =>
461
+ decodeClientRunAdmissionFenceCommandV1({
462
+ schemaVersion: 1,
463
+ action: "fence-admission",
464
+ extra: true,
465
+ }),
466
+ ).toThrow("run admission fence command.extra is not allowed");
467
+ expect(() =>
468
+ decodeClientRunAdmissionFenceCommandV1({
469
+ schemaVersion: 2,
470
+ action: "fence-admission",
471
+ }),
472
+ ).toThrow("run admission fence command is invalid");
473
+ });
474
+
475
+ test("projects bounded Turn responses without internal events", () => {
476
+ const providerCallId = "provider-call-private";
477
+ const projected = projectClientTurnV1({
478
+ runId: "run-turn-1",
479
+ text: "✅".repeat(40_000),
480
+ events: [
481
+ event({
482
+ type: "model/request",
483
+ seq: 0,
484
+ timestamp,
485
+ turn: 1,
486
+ step: 1,
487
+ request: {
488
+ requestId: "request-private",
489
+ provider: "provider-private",
490
+ model: "model-private",
491
+ system: "system-prompt-secret",
492
+ messages: [],
493
+ tools: [],
494
+ },
495
+ }),
496
+ event({
497
+ type: "assistant/message",
498
+ seq: 1,
499
+ timestamp,
500
+ turn: 1,
501
+ step: 1,
502
+ requestId: "request-private",
503
+ text: "",
504
+ toolCalls: [
505
+ {
506
+ id: providerCallId,
507
+ name: "calendar_lookup",
508
+ input: { accessToken: "tool-input-secret" },
509
+ },
510
+ ],
511
+ }),
512
+ event({
513
+ type: "tool/call",
514
+ seq: 2,
515
+ timestamp,
516
+ turn: 1,
517
+ step: 1,
518
+ occurrenceId: "tool:1:1:0",
519
+ name: "calendar_lookup",
520
+ input: { accessToken: "tool-input-secret" },
521
+ }),
522
+ event({
523
+ type: "tool/result",
524
+ seq: 3,
525
+ timestamp,
526
+ turn: 1,
527
+ step: 1,
528
+ occurrenceId: "tool:1:1:0",
529
+ name: "calendar_lookup",
530
+ content: "visible result",
531
+ isError: false,
532
+ status: "completed",
533
+ }),
534
+ ],
535
+ });
536
+
537
+ expect(projected).toMatchObject({
538
+ schemaVersion: 1,
539
+ runId: "run-turn-1",
540
+ events: [
541
+ {
542
+ type: "tool/call",
543
+ call: { id: "tool-1", name: "calendar_lookup" },
544
+ },
545
+ {
546
+ type: "tool/result",
547
+ callId: "tool-1",
548
+ content: "visible result",
549
+ isError: false,
550
+ },
551
+ ],
552
+ });
553
+ expect(
554
+ new TextEncoder().encode(JSON.stringify(projected.text)).length,
555
+ ).toBeLessThanOrEqual(64_000);
556
+ expect(decodeClientTurnV1(structuredClone(projected))).toMatchObject({
557
+ runId: "run-turn-1",
558
+ events: projected.events,
559
+ });
560
+ const wire = JSON.stringify(projected);
561
+ for (const privateValue of [
562
+ "model/request",
563
+ "provider-private",
564
+ "model-private",
565
+ "system-prompt-secret",
566
+ "tool-input-secret",
567
+ providerCallId,
568
+ "occurrenceId",
569
+ ]) {
570
+ expect(wire).not.toContain(privateValue);
571
+ }
572
+ });
573
+
574
+ test("projects only bounded user-visible run state", () => {
575
+ const stored = {
576
+ runId: "run-1",
577
+ commandFingerprint: "fingerprint-secret",
578
+ sessionId: "user:private-session",
579
+ acceptedAt: timestamp,
580
+ input: "continue",
581
+ events: [
582
+ event({
583
+ type: "model/request",
584
+ seq: 0,
585
+ timestamp,
586
+ turn: 1,
587
+ step: 1,
588
+ request: {
589
+ requestId: "request-private",
590
+ provider: "provider-private",
591
+ model: "model-private",
592
+ system: "system-prompt-secret",
593
+ messages: [],
594
+ tools: [],
595
+ },
596
+ }),
597
+ event({
598
+ type: "tool/call",
599
+ seq: 1,
600
+ timestamp,
601
+ turn: 1,
602
+ step: 1,
603
+ occurrenceId: "tool:1:1:0",
604
+ name: "calendar_lookup",
605
+ input: { accessToken: "tool-input-secret" },
606
+ }),
607
+ event({
608
+ type: "tool/result",
609
+ seq: 2,
610
+ timestamp,
611
+ turn: 1,
612
+ step: 1,
613
+ occurrenceId: "tool:1:1:0",
614
+ name: "calendar_lookup",
615
+ content: "visible result",
616
+ isError: false,
617
+ status: "completed",
618
+ }),
619
+ ],
620
+ effectAdmissions: [],
621
+ status: "reconciliation-required",
622
+ failure: "Provider confirmation required",
623
+ phase: "reconciliation-required",
624
+ compositionGenerationId: "test-composition-generation",
625
+ configurationSnapshot: initializeBotSettingsV1("primary"),
626
+ previousEventCount: 17,
627
+ } satisfies StoredRun;
628
+
629
+ const projected = projectClientRunListV1([stored]);
630
+
631
+ expect(projected).toEqual({
632
+ schemaVersion: 1,
633
+ runs: [
634
+ {
635
+ schemaVersion: 2,
636
+ runId: "run-1",
637
+ admittedAt: timestamp,
638
+ input: "continue",
639
+ status: "reconciliation-required",
640
+ events: [
641
+ {
642
+ type: "tool/call",
643
+ call: { id: "tool-1", name: "calendar_lookup" },
644
+ },
645
+ {
646
+ type: "tool/result",
647
+ callId: "tool-1",
648
+ content: "visible result",
649
+ isError: false,
650
+ },
651
+ ],
652
+ recovery: {
653
+ action: "resume",
654
+ message: "Provider confirmation required",
655
+ },
656
+ },
657
+ ],
658
+ page: { truncated: false },
659
+ });
660
+ expect(decodeClientRunListV1(structuredClone(projected))).toEqual([
661
+ {
662
+ runId: "run-1",
663
+ admittedAt: timestamp,
664
+ input: "continue",
665
+ status: "reconciliation-required",
666
+ events: projected.runs[0]?.events,
667
+ failure: "Provider confirmation required",
668
+ recovery: {
669
+ action: "resume",
670
+ message: "Provider confirmation required",
671
+ },
672
+ },
673
+ ]);
674
+ const wire = JSON.stringify(projected);
675
+ for (const privateValue of [
676
+ "fingerprint-secret",
677
+ "private-session",
678
+ "system-prompt-secret",
679
+ "tool-input-secret",
680
+ "provider-private",
681
+ "model-private",
682
+ "compositionGenerationId",
683
+ "configurationSnapshot",
684
+ "previousEventCount",
685
+ "phase",
686
+ ]) {
687
+ expect(wire).not.toContain(privateValue);
688
+ }
689
+ });
690
+
691
+ test("rejects unversioned, extended, and inconsistent wire values", () => {
692
+ const completed = {
693
+ schemaVersion: 1,
694
+ runId: "run-1",
695
+ admittedAt: timestamp,
696
+ input: "continue",
697
+ status: "completed",
698
+ events: [],
699
+ outcome: { type: "completed", text: "done" },
700
+ };
701
+
702
+ expect(() => decodeClientRunListV1({ runs: [] })).toThrow(
703
+ "run list.schemaVersion is invalid",
704
+ );
705
+ expect(() =>
706
+ decodeClientRunListV1({
707
+ schemaVersion: 1,
708
+ runs: [{ ...completed, commandFingerprint: "secret" }],
709
+ page: { truncated: false },
710
+ }),
711
+ ).toThrow("run.commandFingerprint is not allowed");
712
+ expect(() =>
713
+ decodeClientRunListV1({
714
+ schemaVersion: 1,
715
+ runs: [
716
+ {
717
+ ...completed,
718
+ status: "failed",
719
+ },
720
+ ],
721
+ page: { truncated: false },
722
+ }),
723
+ ).toThrow("run.outcome does not match run.status");
724
+ expect(() =>
725
+ decodeClientRunListV1({
726
+ schemaVersion: 1,
727
+ runs: [
728
+ {
729
+ ...completed,
730
+ events: [{ type: "model/request" }],
731
+ },
732
+ ],
733
+ page: { truncated: false },
734
+ }),
735
+ ).toThrow("run event.type is invalid");
736
+ expect(() =>
737
+ decodeClientRunPageV1({
738
+ schemaVersion: 1,
739
+ runs: [],
740
+ page: { truncated: true },
741
+ }),
742
+ ).toThrow("truncated run list requires a next cursor");
743
+ expect(() =>
744
+ decodeClientRunListQueryV1({ schemaVersion: 1, before: "" }),
745
+ ).toThrow("run list query.before is invalid");
746
+ expect(() =>
747
+ decodeClientRunListQueryV1({ schemaVersion: 1, before: "garbage" }),
748
+ ).toThrow("run list query.before is invalid");
749
+ expect(
750
+ decodeClientRunListQueryV1({
751
+ schemaVersion: 1,
752
+ before: "run-index:2026-08-29T00:00:00.000Z:run-1",
753
+ }),
754
+ ).toEqual({
755
+ schemaVersion: 1,
756
+ before: "run-index:2026-08-29T00:00:00.000Z:run-1",
757
+ });
758
+ });
759
+
760
+ test("bounds projected visible history and messages", () => {
761
+ const stored: StoredRun = {
762
+ runId: "run-bounded",
763
+ commandFingerprint: "fingerprint",
764
+ sessionId: "user:primary",
765
+ acceptedAt: timestamp,
766
+ input: "🧪".repeat(8_000),
767
+ events: toolEvents(300),
768
+ effectAdmissions: [],
769
+ status: "failed",
770
+ phase: "executing",
771
+ compositionGenerationId: "test-composition-generation",
772
+ configurationSnapshot: initializeBotSettingsV1("primary"),
773
+ previousEventCount: 0,
774
+ failure: "💥".repeat(2_000),
775
+ };
776
+
777
+ const projected = projectClientRunListV1([stored]).runs[0];
778
+
779
+ expect(
780
+ new TextEncoder().encode(JSON.stringify(projected?.input)).length,
781
+ ).toBeLessThanOrEqual(32_000);
782
+ expect(projected?.events).toHaveLength(511);
783
+ expect(projected?.events[0]).toEqual({
784
+ type: "run/events-truncated",
785
+ omittedInteractions: 45,
786
+ });
787
+ expect(projected?.events[1]).toMatchObject({
788
+ type: "tool/call",
789
+ call: { id: "tool-46" },
790
+ });
791
+ expect(projected?.events.at(-1)).toMatchObject({
792
+ type: "tool/result",
793
+ callId: "tool-300",
794
+ });
795
+ const failure =
796
+ projected?.outcome?.type === "failed"
797
+ ? projected.outcome.message
798
+ : undefined;
799
+ expect(
800
+ new TextEncoder().encode(JSON.stringify(failure)).length,
801
+ ).toBeLessThanOrEqual(8_000);
802
+ });
803
+
804
+ test("uses the full boundary without splitting an interaction", () => {
805
+ const atBoundary = projectClientRunListV1([storedRun(toolEvents(256))])
806
+ .runs[0];
807
+ const overBoundary = projectClientRunListV1([storedRun(toolEvents(257))])
808
+ .runs[0];
809
+
810
+ expect(atBoundary?.events).toHaveLength(512);
811
+ expect(atBoundary?.events[0]).toMatchObject({
812
+ type: "tool/call",
813
+ call: { id: "tool-1" },
814
+ });
815
+ expect(atBoundary?.events.at(-1)).toMatchObject({
816
+ type: "tool/result",
817
+ callId: "tool-256",
818
+ });
819
+ expect(overBoundary?.events).toHaveLength(511);
820
+ expect(overBoundary?.events[0]).toEqual({
821
+ type: "run/events-truncated",
822
+ omittedInteractions: 2,
823
+ });
824
+ expect(overBoundary?.events[1]).toMatchObject({
825
+ type: "tool/call",
826
+ call: { id: "tool-3" },
827
+ });
828
+ expect(overBoundary?.events.at(-1)).toMatchObject({
829
+ type: "tool/result",
830
+ callId: "tool-257",
831
+ });
832
+ expect(overBoundary?.outcome).toEqual({ type: "completed", text: "done" });
833
+ expect(
834
+ decodeClientRunListV1({
835
+ schemaVersion: 1,
836
+ runs: [overBoundary],
837
+ page: { truncated: false },
838
+ })[0]?.events[0],
839
+ ).toEqual({
840
+ type: "run/events-truncated",
841
+ omittedInteractions: 2,
842
+ });
843
+ });
844
+
845
+ test("projects sends and hand-offs in the order the log wrote them", () => {
846
+ const [call, result] = toolEvents(1) as [SessionEvent, SessionEvent];
847
+ const projected = projectClientRunV1(
848
+ storedRun([
849
+ event({
850
+ type: "send/to-user",
851
+ seq: 10,
852
+ timestamp,
853
+ turn: 1,
854
+ step: 1,
855
+ occurrenceId: "tool:1:1:send",
856
+ payload: { type: "text", text: "On it." },
857
+ }),
858
+ call,
859
+ result,
860
+ event({
861
+ type: "send/to-user",
862
+ seq: 13,
863
+ timestamp,
864
+ turn: 1,
865
+ step: 1,
866
+ occurrenceId: "tool:1:1:widget",
867
+ payload: {
868
+ type: "widget",
869
+ widget: { prompt: "Which day?", options: ["Tue", "Thu"] },
870
+ },
871
+ }),
872
+ event({
873
+ type: "wake/parent",
874
+ seq: 14,
875
+ timestamp,
876
+ turn: 1,
877
+ step: 1,
878
+ occurrenceId: "tool:1:1:wake",
879
+ message: "Paid.",
880
+ }),
881
+ ]),
882
+ );
883
+
884
+ // Version 2 is what carries the two new event types; a client pinned to 1
885
+ // still decodes the body it produces.
886
+ expect(projected.schemaVersion).toBe(2);
887
+ expect(projected.events).toEqual([
888
+ { type: "send/to-user", payload: { type: "text", text: "On it." } },
889
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
890
+ {
891
+ type: "tool/result",
892
+ callId: "tool-1",
893
+ content: "result-0",
894
+ isError: false,
895
+ },
896
+ {
897
+ type: "send/to-user",
898
+ payload: {
899
+ type: "widget",
900
+ widget: { prompt: "Which day?", options: ["Tue", "Thu"] },
901
+ },
902
+ },
903
+ { type: "wake/parent", message: "Paid." },
904
+ ]);
905
+ expect(
906
+ decodeClientRunListV1({
907
+ schemaVersion: 1,
908
+ runs: [projected],
909
+ page: { truncated: false },
910
+ })[0]?.events,
911
+ ).toEqual(projected.events);
912
+ });
913
+
914
+ test("truncates sends alongside tool interactions, oldest first", () => {
915
+ const sends = Array.from({ length: 600 }, (_, index) =>
916
+ event({
917
+ type: "send/to-user" as const,
918
+ seq: index,
919
+ timestamp,
920
+ turn: 1,
921
+ step: 1,
922
+ occurrenceId: `tool:1:1:${index}`,
923
+ payload: { type: "text" as const, text: `send-${index}` },
924
+ }),
925
+ );
926
+
927
+ const projected = projectClientRunV1(storedRun(sends));
928
+
929
+ expect(projected.events).toHaveLength(512);
930
+ expect(projected.events[0]).toEqual({
931
+ type: "run/events-truncated",
932
+ omittedInteractions: 89,
933
+ });
934
+ // The newest send survives: truncation drops history, never the latest
935
+ // thing the User is looking at.
936
+ expect(projected.events.at(-1)).toEqual({
937
+ type: "send/to-user",
938
+ payload: { type: "text", text: "send-599" },
939
+ });
940
+ expect(
941
+ decodeClientRunListV1({
942
+ schemaVersion: 1,
943
+ runs: [projected],
944
+ page: { truncated: false },
945
+ })[0]?.events,
946
+ ).toHaveLength(512);
947
+ });
948
+
949
+ test("refuses a projected send whose payload it cannot decode", () => {
950
+ expect(() =>
951
+ decodeClientRunListV1({
952
+ schemaVersion: 1,
953
+ runs: [
954
+ {
955
+ ...projectClientRunV1(storedRun([])),
956
+ events: [{ type: "send/to-user", payload: { type: "sms" } }],
957
+ },
958
+ ],
959
+ page: { truncated: false },
960
+ }),
961
+ ).toThrow("run event.payload.type is invalid");
962
+ });
963
+
964
+ test("rejects orphaned tool history at projection", () => {
965
+ const call = toolEvents(1)[0]!;
966
+ const result = toolEvents(1)[1]!;
967
+
968
+ expect(() => projectClientRunListV1([storedRun([result])])).toThrow(
969
+ 'tool result has no matching occurrence "tool:1:1:0"',
970
+ );
971
+ expect(() => projectClientRunListV1([storedRun([call])])).toThrow(
972
+ 'terminal run has no result for tool call "tool-1"',
973
+ );
974
+ expect(() =>
975
+ projectClientRunListV1([storedRun([call, call, result])]),
976
+ ).toThrow('tool occurrence "tool:1:1:0" has duplicate intent');
977
+ });
978
+
979
+ test("retains pending calls only for nonterminal runs", () => {
980
+ const call = toolEvents(1)[0]!;
981
+ const projected = projectClientRunListV1([storedRun([call], "running")])
982
+ .runs[0];
983
+
984
+ expect(projected?.events).toEqual([
985
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
986
+ ]);
987
+ expect(
988
+ decodeClientRunListV1({
989
+ schemaVersion: 1,
990
+ runs: [projected],
991
+ page: { truncated: false },
992
+ }),
993
+ ).toMatchObject([{ status: "running", events: [{ type: "tool/call" }] }]);
994
+ });
995
+
996
+ test("aliases repeated and oversized provider call identifiers", () => {
997
+ const providerId = "provider-call-".repeat(100);
998
+ const events = [
999
+ event({
1000
+ type: "assistant/message",
1001
+ seq: 0,
1002
+ timestamp,
1003
+ turn: 1,
1004
+ step: 1,
1005
+ requestId: "request-1",
1006
+ text: "",
1007
+ toolCalls: [
1008
+ { id: providerId, name: "first", input: {} },
1009
+ { id: providerId, name: "second", input: {} },
1010
+ ],
1011
+ }),
1012
+ event({
1013
+ type: "tool/call",
1014
+ seq: 1,
1015
+ timestamp,
1016
+ turn: 1,
1017
+ step: 1,
1018
+ occurrenceId: "tool:1:1:0",
1019
+ name: "first",
1020
+ input: {},
1021
+ }),
1022
+ event({
1023
+ type: "tool/call",
1024
+ seq: 2,
1025
+ timestamp,
1026
+ turn: 1,
1027
+ step: 1,
1028
+ occurrenceId: "tool:1:1:1",
1029
+ name: "second",
1030
+ input: {},
1031
+ }),
1032
+ event({
1033
+ type: "tool/result",
1034
+ seq: 3,
1035
+ timestamp,
1036
+ turn: 1,
1037
+ step: 1,
1038
+ occurrenceId: "tool:1:1:0",
1039
+ name: "first",
1040
+ content: "first-result",
1041
+ isError: false,
1042
+ status: "completed",
1043
+ }),
1044
+ event({
1045
+ type: "tool/result",
1046
+ seq: 4,
1047
+ timestamp,
1048
+ turn: 1,
1049
+ step: 1,
1050
+ occurrenceId: "tool:1:1:1",
1051
+ name: "second",
1052
+ content: "second-result",
1053
+ isError: false,
1054
+ status: "completed",
1055
+ }),
1056
+ event({
1057
+ type: "assistant/message",
1058
+ seq: 5,
1059
+ timestamp,
1060
+ turn: 1,
1061
+ step: 2,
1062
+ requestId: "request-2",
1063
+ text: "",
1064
+ toolCalls: [{ id: providerId, name: "third", input: {} }],
1065
+ }),
1066
+ event({
1067
+ type: "tool/call",
1068
+ seq: 6,
1069
+ timestamp,
1070
+ turn: 1,
1071
+ step: 2,
1072
+ occurrenceId: "tool:1:2:0",
1073
+ name: "third",
1074
+ input: {},
1075
+ }),
1076
+ event({
1077
+ type: "tool/result",
1078
+ seq: 7,
1079
+ timestamp,
1080
+ turn: 1,
1081
+ step: 2,
1082
+ occurrenceId: "tool:1:2:0",
1083
+ name: "third",
1084
+ content: "third-result",
1085
+ isError: false,
1086
+ status: "completed",
1087
+ }),
1088
+ ] satisfies SessionEvent[];
1089
+
1090
+ const stored = storedRun(events);
1091
+ const projected = projectClientRunListV1([stored]);
1092
+
1093
+ expect(projected.runs[0]?.events).toEqual([
1094
+ { type: "tool/call", call: { id: "tool-1", name: "first" } },
1095
+ {
1096
+ type: "tool/result",
1097
+ callId: "tool-1",
1098
+ content: "first-result",
1099
+ isError: false,
1100
+ },
1101
+ { type: "tool/call", call: { id: "tool-2", name: "second" } },
1102
+ {
1103
+ type: "tool/result",
1104
+ callId: "tool-2",
1105
+ content: "second-result",
1106
+ isError: false,
1107
+ },
1108
+ { type: "tool/call", call: { id: "tool-3", name: "third" } },
1109
+ {
1110
+ type: "tool/result",
1111
+ callId: "tool-3",
1112
+ content: "third-result",
1113
+ isError: false,
1114
+ },
1115
+ ]);
1116
+ expect(stored.events[0]).toMatchObject({
1117
+ toolCalls: [{ id: providerId }, { id: providerId }],
1118
+ });
1119
+ expect(JSON.stringify(projected)).not.toContain(providerId);
1120
+ });
1121
+ test("surfaces a failed Turn's reason as the client run failure", () => {
1122
+ const events = [
1123
+ event({ type: "turn/start", seq: 0, timestamp, turn: 1 }),
1124
+ event({
1125
+ type: "turn/end",
1126
+ seq: 1,
1127
+ timestamp,
1128
+ turn: 1,
1129
+ outcome: "model-error",
1130
+ reason: "Ollama Cloud responded 401: invalid api key",
1131
+ }),
1132
+ ];
1133
+ const plan = planBotRunRecovery(storedRun(events, "running"), events);
1134
+ if (plan.kind !== "fail")
1135
+ throw new Error("expected a failed recovery plan");
1136
+
1137
+ const lookup = projectClientRunLookupV1({
1138
+ ...storedRun(events, "failed"),
1139
+ events,
1140
+ failure: plan.failure,
1141
+ });
1142
+ expect(decodeClientRunLookupV1(structuredClone(lookup))).toMatchObject({
1143
+ state: "terminal",
1144
+ run: {
1145
+ status: "failed",
1146
+ failure:
1147
+ "Bot turn ended with outcome model-error: Ollama Cloud responded 401: invalid api key",
1148
+ },
1149
+ });
1150
+ });
1151
+
1152
+ test("carries rename announcements beside the Turns, and refuses a bad one", () => {
1153
+ const announcements = projectClientAnnouncementsV1([
1154
+ { type: "turn/start", seq: 0, timestamp, turn: 1 },
1155
+ {
1156
+ type: "bot/renamed",
1157
+ seq: 3,
1158
+ timestamp,
1159
+ from: "Housework",
1160
+ to: "Atlas",
1161
+ namedBy: "bot",
1162
+ },
1163
+ ]);
1164
+ expect(announcements).toEqual([
1165
+ {
1166
+ type: "bot/renamed",
1167
+ announcementId: "announcement-3",
1168
+ at: timestamp,
1169
+ from: "Housework",
1170
+ to: "Atlas",
1171
+ namedBy: "bot",
1172
+ },
1173
+ ]);
1174
+ const page = createClientRunListV1([], { truncated: false }, announcements);
1175
+ expect(decodeClientRunPageV1(structuredClone(page)).announcements).toEqual(
1176
+ announcements,
1177
+ );
1178
+ // A page written before announcements existed still decodes.
1179
+ expect(
1180
+ decodeClientRunPageV1({
1181
+ schemaVersion: 1,
1182
+ runs: [],
1183
+ page: { truncated: false },
1184
+ }).announcements,
1185
+ ).toEqual([]);
1186
+ expect(() =>
1187
+ decodeClientRunPageV1({
1188
+ ...page,
1189
+ announcements: [{ ...announcements[0], namedBy: "admin" }],
1190
+ }),
1191
+ ).toThrow("run list.announcement.namedBy is invalid");
1192
+ expect(() =>
1193
+ decodeClientRunPageV1({
1194
+ ...page,
1195
+ announcements: [{ ...announcements[0], at: "not a date" }],
1196
+ }),
1197
+ ).toThrow("run list.announcement.at is invalid");
1198
+ });
1199
+ });
1200
+
1201
+ describe("dispatched subagents in the run projection", () => {
1202
+ const dispatched: SessionEvent = {
1203
+ type: "task/dispatched",
1204
+ seq: 4,
1205
+ timestamp,
1206
+ turn: 1,
1207
+ step: 1,
1208
+ occurrenceId: "tool:1:1:0",
1209
+ taskId: "tk-1",
1210
+ taskType: "executor",
1211
+ description: "Read the release notes",
1212
+ model: "provider-ollama-cloud/glm-5.3-flash:cloud",
1213
+ background: true,
1214
+ };
1215
+
1216
+ test("projects a chip, and round-trips it through the page decoder", () => {
1217
+ const projected = projectClientRunV1(storedRun([dispatched]));
1218
+ expect(projected.events).toEqual([
1219
+ {
1220
+ type: "task/dispatched",
1221
+ taskId: "tk-1",
1222
+ taskType: "executor",
1223
+ description: "Read the release notes",
1224
+ model: "provider-ollama-cloud/glm-5.3-flash:cloud",
1225
+ background: true,
1226
+ },
1227
+ ]);
1228
+ const page = createClientRunListV1([projected], { truncated: false });
1229
+ expect(
1230
+ decodeClientRunPageV1(structuredClone(page)).runs[0]?.events,
1231
+ ).toEqual(projected.events);
1232
+ });
1233
+
1234
+ test("stands alone, so it never breaks the tool call/result walk", () => {
1235
+ const projected = projectClientRunV1(
1236
+ storedRun([...toolEvents(1), dispatched]),
1237
+ );
1238
+ const page = createClientRunListV1([projected], { truncated: false });
1239
+ expect(
1240
+ decodeClientRunPageV1(structuredClone(page)).runs[0]?.events,
1241
+ ).toHaveLength(3);
1242
+ });
1243
+
1244
+ test("carries nothing of the child but its identity — no prompt, no transcript", () => {
1245
+ const projected = projectClientRunV1(
1246
+ storedRun([
1247
+ {
1248
+ ...dispatched,
1249
+ description: "Read the release notes",
1250
+ } as SessionEvent,
1251
+ ]),
1252
+ );
1253
+ expect(JSON.stringify(projected)).not.toContain("summarise");
1254
+ });
1255
+
1256
+ test("refuses a chip that carries a field it does not have", () => {
1257
+ const page = createClientRunListV1(
1258
+ [projectClientRunV1(storedRun([dispatched]))],
1259
+ { truncated: false },
1260
+ );
1261
+ const tampered = structuredClone(page) as {
1262
+ runs: Array<{ events: Array<Record<string, unknown>> }>;
1263
+ };
1264
+ tampered.runs[0]!.events[0]!.prompt = "the child's brief";
1265
+ expect(() => decodeClientRunPageV1(tampered)).toThrow();
1266
+ });
1267
+
1268
+ test("refuses a chip whose background flag is not a boolean", () => {
1269
+ const page = createClientRunListV1(
1270
+ [projectClientRunV1(storedRun([dispatched]))],
1271
+ { truncated: false },
1272
+ );
1273
+ const tampered = structuredClone(page) as {
1274
+ runs: Array<{ events: Array<Record<string, unknown>> }>;
1275
+ };
1276
+ tampered.runs[0]!.events[0]!.background = "yes";
1277
+ expect(() => decodeClientRunPageV1(tampered)).toThrow(
1278
+ "run event.background must be a boolean",
1279
+ );
1280
+ });
1281
+ });