@frockbot/plugin-shell 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/frockbot.json CHANGED
@@ -55,7 +55,7 @@
55
55
  "kind": "tool",
56
56
  "connectionTypes": [],
57
57
  "admission": {
58
- "turnTypes": ["chat"]
58
+ "turnTypes": ["chat", "agent"]
59
59
  }
60
60
  },
61
61
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,42 +51,42 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@cordisjs/plugin-webui": "0.8.2",
54
- "@frockbot/agent-runtime": "0.3.15",
55
- "@frockbot/application-foundation": "0.3.15",
56
- "@frockbot/catalog-core": "0.3.15",
57
- "@frockbot/client-core": "0.3.15",
58
- "@frockbot/client-ui": "0.3.15",
59
- "@frockbot/computer-core": "0.3.15",
60
- "@frockbot/computer-host-protocol": "0.3.15",
61
- "@frockbot/configuration-core": "0.3.15",
62
- "@frockbot/connection-core": "0.3.15",
63
- "@frockbot/kernel-agent-loop": "0.3.15",
64
- "@frockbot/kernel-composition": "0.3.15",
65
- "@frockbot/kernel-contracts": "0.3.15",
66
- "@frockbot/kernel-do": "0.3.15",
67
- "@frockbot/machine-protocol": "0.3.15",
68
- "@frockbot/plugin-applets": "0.3.15",
69
- "@frockbot/plugin-authoring": "0.3.15",
70
- "@frockbot/plugin-bot-template": "0.3.15",
71
- "@frockbot/plugin-computer": "0.3.15",
72
- "@frockbot/plugin-flock": "0.3.15",
73
- "@frockbot/plugin-image": "0.3.15",
74
- "@frockbot/plugin-machine-messages": "0.3.15",
75
- "@frockbot/plugin-mcp": "0.3.15",
76
- "@frockbot/plugin-memory": "0.3.15",
77
- "@frockbot/plugin-package-catalog": "0.3.15",
78
- "@frockbot/plugin-routines": "0.3.15",
79
- "@frockbot/plugin-skills": "0.3.15",
80
- "@frockbot/plugin-subagents": "0.3.15",
81
- "@frockbot/plugin-user-machine": "0.3.15",
82
- "@frockbot/protocol": "0.3.15",
54
+ "@frockbot/agent-runtime": "0.3.17",
55
+ "@frockbot/application-foundation": "0.3.17",
56
+ "@frockbot/catalog-core": "0.3.17",
57
+ "@frockbot/client-core": "0.3.17",
58
+ "@frockbot/client-ui": "0.3.17",
59
+ "@frockbot/computer-core": "0.3.17",
60
+ "@frockbot/computer-host-protocol": "0.3.17",
61
+ "@frockbot/configuration-core": "0.3.17",
62
+ "@frockbot/connection-core": "0.3.17",
63
+ "@frockbot/kernel-agent-loop": "0.3.17",
64
+ "@frockbot/kernel-composition": "0.3.17",
65
+ "@frockbot/kernel-contracts": "0.3.17",
66
+ "@frockbot/kernel-do": "0.3.17",
67
+ "@frockbot/machine-protocol": "0.3.17",
68
+ "@frockbot/plugin-applets": "0.3.17",
69
+ "@frockbot/plugin-authoring": "0.3.17",
70
+ "@frockbot/plugin-bot-template": "0.3.17",
71
+ "@frockbot/plugin-computer": "0.3.17",
72
+ "@frockbot/plugin-flock": "0.3.17",
73
+ "@frockbot/plugin-image": "0.3.17",
74
+ "@frockbot/plugin-machine-messages": "0.3.17",
75
+ "@frockbot/plugin-mcp": "0.3.17",
76
+ "@frockbot/plugin-memory": "0.3.17",
77
+ "@frockbot/plugin-package-catalog": "0.3.17",
78
+ "@frockbot/plugin-routines": "0.3.17",
79
+ "@frockbot/plugin-skills": "0.3.17",
80
+ "@frockbot/plugin-subagents": "0.3.17",
81
+ "@frockbot/plugin-user-machine": "0.3.17",
82
+ "@frockbot/protocol": "0.3.17",
83
83
  "cordis": "4.0.0-rc.8",
84
84
  "vue": "3.5.41"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@cloudflare/workers-types": "latest",
88
- "@frockbot/plugin-prompt": "0.3.15",
89
- "@frockbot/plugin-tools": "0.3.15",
88
+ "@frockbot/plugin-prompt": "0.3.17",
89
+ "@frockbot/plugin-tools": "0.3.17",
90
90
  "@types/bun": "1.3.6",
91
91
  "@types/node": "26.2.0",
92
92
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -74,7 +74,7 @@ async function invoke(
74
74
  }
75
75
 
76
76
  describe("the Shell's tool admission", () => {
77
- test("offers the send tool and its alias on chat turns only", async () => {
77
+ test("offers the send tool on chat and agent Turns", async () => {
78
78
  const mounted = await mount();
79
79
  try {
80
80
  const chat = mounted.root.tools
@@ -86,10 +86,16 @@ describe("the Shell's tool admission", () => {
86
86
  const subagent = mounted.root.tools
87
87
  .schemas({ turnType: "subagent" })
88
88
  .map((tool) => tool.name);
89
+ const agent = mounted.root.tools
90
+ .schemas({ turnType: "agent" })
91
+ .map((tool) => tool.name);
89
92
 
90
93
  expect(chat).toContain(SEND_TO_USER_TOOL_V1);
91
94
  expect(chat).toContain(SEND_MESSAGE_ALIAS_V1);
92
95
  expect(chat).not.toContain(WAKE_PARENT_TOOL_V1);
96
+ expect(agent).toContain(SEND_TO_USER_TOOL_V1);
97
+ expect(agent).toContain(SEND_MESSAGE_ALIAS_V1);
98
+ expect(agent).not.toContain(WAKE_PARENT_TOOL_V1);
93
99
  expect(automation).toEqual([
94
100
  WAKE_PARENT_TOOL_V1,
95
101
  "get_dynamic_tools",
@@ -179,7 +185,10 @@ describe("the Shell's tool admission", () => {
179
185
  });
180
186
 
181
187
  test("bounds each tool by the turn types its manifest Capability declares", () => {
182
- expect(shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1)).toEqual(["chat"]);
188
+ expect(shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1)).toEqual([
189
+ "chat",
190
+ "agent",
191
+ ]);
183
192
  expect(shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1)).toEqual([
184
193
  "automation",
185
194
  "subagent",
@@ -492,6 +501,80 @@ describe("the acknowledgement reaches the user", () => {
492
501
  }
493
502
  });
494
503
 
504
+ test("a step that is about to call send_to_user is not promoted", async () => {
505
+ const mounted = await mount();
506
+ try {
507
+ // Bob on production, 2026-09-04: the model wrote the acknowledgement as
508
+ // plain text and passed the same line to `send_to_user` in one step, and
509
+ // the person saw two identical bubbles.
510
+ await mounted.root.serial(
511
+ "agent/assistant-text",
512
+ { session: mounted.session } as never,
513
+ "On it — building your to-do applet now.",
514
+ {
515
+ turn: 3,
516
+ step: 1,
517
+ requestId: "request-1",
518
+ toolNames: [SEND_TO_USER_TOOL_V1, "applet_create"],
519
+ },
520
+ );
521
+ await mounted.root.serial(
522
+ "agent/assistant-text",
523
+ { session: mounted.session } as never,
524
+ "Sending it another way.",
525
+ {
526
+ turn: 3,
527
+ step: 2,
528
+ requestId: "request-2",
529
+ toolNames: [SEND_MESSAGE_ALIAS_V1],
530
+ },
531
+ );
532
+
533
+ const sends = mounted.session.events.filter(
534
+ (event) => event.type === "send/to-user",
535
+ );
536
+ expect(sends).toHaveLength(0);
537
+ } finally {
538
+ await mounted.dispose();
539
+ }
540
+ });
541
+
542
+ test("the last steps of a reply tell the model to send a status", async () => {
543
+ const mounted = await mount();
544
+ try {
545
+ const assemble = (current: number) =>
546
+ mounted.root.systemPrompt.assemble({
547
+ sessionId: "session-1",
548
+ provider: "provider-1",
549
+ model: "model-1",
550
+ turnType: "chat",
551
+ step: { current, max: 64 },
552
+ });
553
+ const early = await assemble(10);
554
+ expect(early.text).not.toContain("<step_budget>");
555
+ const warning = await assemble(61);
556
+ expect(warning.text).toContain("<step_budget>");
557
+ expect(warning.text).toContain("3 steps left after this one");
558
+ expect(warning.text).toContain(SEND_TO_USER_TOOL_V1);
559
+ const last = await assemble(64);
560
+ expect(last.text).toContain("This is the last step of this reply");
561
+ // The budget section sits after the conversation contract, where the
562
+ // model reads it last.
563
+ expect(last.text.indexOf("<step_budget>")).toBeGreaterThan(
564
+ last.text.indexOf("send_to_user"),
565
+ );
566
+ const outside = await mounted.root.systemPrompt.assemble({
567
+ sessionId: "session-1",
568
+ provider: "provider-1",
569
+ model: "model-1",
570
+ turnType: "chat",
571
+ });
572
+ expect(outside.text).not.toContain("<step_budget>");
573
+ } finally {
574
+ await mounted.dispose();
575
+ }
576
+ });
577
+
495
578
  test("a step that already spoke is left alone, and a replay adds nothing", async () => {
496
579
  const mounted = await mount();
497
580
  try {
package/src/agent.ts CHANGED
@@ -46,7 +46,12 @@ import {
46
46
  turnScopedMessagesV1,
47
47
  turnTypesByTurnV1,
48
48
  } from "./history.js";
49
- import { runCompactionV1 } from "./compaction.js";
49
+ import {
50
+ COMPACTION_RESPONSE_SCHEMA_V1,
51
+ type CompactionSummaryPayloadV1,
52
+ renderCompactionSummaryV1,
53
+ runCompactionV1,
54
+ } from "./compaction.js";
50
55
  import { compactionWorkV1 } from "./compaction-scheduler.js";
51
56
  import type { Plugin } from "cordis";
52
57
  import manifest from "../frockbot.json" with { type: "json" };
@@ -155,11 +160,25 @@ export function promotedSendOccurrenceIdV1(requestId: string): string {
155
160
  export async function promoteAssistantTextToSendV1(
156
161
  session: Session,
157
162
  text: string,
158
- position: { turn: number; step: number; requestId: string },
163
+ position: {
164
+ turn: number;
165
+ step: number;
166
+ requestId: string;
167
+ toolNames?: readonly string[];
168
+ },
159
169
  ): Promise<void> {
160
170
  const trimmed = text.trim();
161
171
  if (trimmed.length === 0 || trimmed.length > PROMOTED_ASSISTANT_TEXT_LIMIT_V1)
162
172
  return;
173
+ // A step that narrates "On it — building it now." AND calls `send_to_user`
174
+ // with the same line is about to speak for itself; promoting the narration
175
+ // too is how the person saw the bubble twice. The send has not run yet when
176
+ // this hook fires (it runs before the tools, on purpose), so the pending
177
+ // call names are the only evidence.
178
+ const aboutToSend = (position.toolNames ?? []).some(
179
+ (name) => name === SEND_TO_USER_TOOL_V1 || name === SEND_MESSAGE_ALIAS_V1,
180
+ );
181
+ if (aboutToSend) return;
163
182
  const occurrenceId = promotedSendOccurrenceIdV1(position.requestId);
164
183
  const spokeThisStep = session.events.some(
165
184
  (event) =>
@@ -210,6 +229,42 @@ export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
210
229
  /** Ordered after identity (0), before anything a Package contributes. */
211
230
  export const CONVERSATION_PROMPT_ORDER_V1 = 1;
212
231
 
232
+ export const STEP_BUDGET_PROMPT_SECTION_V1 = "step-budget";
233
+ /** Late in the prompt, where the model reads it last and heeds it most. */
234
+ export const STEP_BUDGET_PROMPT_ORDER_V1 = 90;
235
+ /** The section appears when this many steps or fewer remain after this one. */
236
+ export const STEP_BUDGET_WARNING_STEPS_V1 = 3;
237
+
238
+ /**
239
+ * What the model is told when its reply is about to be stopped.
240
+ *
241
+ * A Turn that reaches the loop's step ceiling ends `interrupted`, and a model
242
+ * that was mid-work says nothing: the person sees a notice and no status. Bob
243
+ * (2026-09-04) spent twenty steps retrying a failing publish and the thread
244
+ * went quiet. So the last few steps carry a countdown and one instruction —
245
+ * stop starting work, send a status — and the final step allows nothing else.
246
+ */
247
+ export function stepBudgetPromptTextV1(context: {
248
+ step?: { current: number; max: number };
249
+ }): string {
250
+ const step = context.step;
251
+ if (!step) return "";
252
+ const remaining = step.max - step.current;
253
+ if (remaining > STEP_BUDGET_WARNING_STEPS_V1 || remaining < 0) return "";
254
+ if (remaining === 0) {
255
+ return [
256
+ "<step_budget>",
257
+ `This is the last step of this reply; after it the reply is stopped automatically. Do nothing except call \`${SEND_TO_USER_TOOL_V1}\` once with a short status for the person: what is finished, what is not, and what they can do next.`,
258
+ "</step_budget>",
259
+ ].join("\n");
260
+ }
261
+ return [
262
+ "<step_budget>",
263
+ `This reply has ${remaining} ${remaining === 1 ? "step" : "steps"} left after this one before it is stopped automatically. Do not start new work. Call \`${SEND_TO_USER_TOOL_V1}\` now with a short status for the person: what is finished, what is not, and what they can do next.`,
264
+ "</step_budget>",
265
+ ].join("\n");
266
+ }
267
+
213
268
  export const CONVERSATION_PROMPT_TEXT_V1 = [
214
269
  "## Talking to the user",
215
270
  "",
@@ -271,7 +326,7 @@ function createSendToUserTool(
271
326
  string,
272
327
  unknown
273
328
  >,
274
- admission: { turnTypes: ["chat"] },
329
+ admission: { turnTypes: ["chat", "agent"] },
275
330
  validate: (input: unknown) =>
276
331
  typeof input === "object" && input !== null && !Array.isArray(input),
277
332
  execute: async (
@@ -407,6 +462,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
407
462
  order: CONVERSATION_PROMPT_ORDER_V1,
408
463
  render: () => CONVERSATION_PROMPT_TEXT_V1,
409
464
  }),
465
+ // Empty for most of a Turn; a countdown and one instruction at the end of
466
+ // its step budget. See `stepBudgetPromptTextV1`.
467
+ ctx.systemPrompt.register({
468
+ id: STEP_BUDGET_PROMPT_SECTION_V1,
469
+ order: STEP_BUDGET_PROMPT_ORDER_V1,
470
+ render: (context) => stepBudgetPromptTextV1(context),
471
+ }),
410
472
  ctx.tools.register(
411
473
  createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
412
474
  userVoice ? { admissionCeiling: userVoice } : undefined,
@@ -449,27 +511,32 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
449
511
  currentTurn: turn,
450
512
  newEffectId: () => `compaction-${crypto.randomUUID()}`,
451
513
  summarise: async (request) => {
452
- let text = "";
453
514
  // Two deadlines, one call: the compaction's own, and the abort
454
515
  // a newly admitted Turn raises when it takes the log back.
455
516
  const cancelled = AbortSignal.any([request.signal, signal]);
456
- for await (const event of scoped.llm.stream(
457
- {
458
- requestId: `compaction-${crypto.randomUUID()}`,
459
- provider: request.provider,
460
- model: request.model,
461
- system: request.system,
462
- messages: request.messages,
463
- tools: [],
464
- ...(request.modelBinding
465
- ? { modelBinding: request.modelBinding }
466
- : {}),
467
- },
468
- cancelled,
469
- )) {
470
- if (event.type === "text-delta") text += event.text;
517
+ const result =
518
+ await scoped.llm.structured<CompactionSummaryPayloadV1>(
519
+ {
520
+ requestId: `compaction-${crypto.randomUUID()}`,
521
+ provider: request.provider,
522
+ model: request.model,
523
+ system: request.system,
524
+ messages: request.messages,
525
+ tools: [],
526
+ ...(request.modelBinding
527
+ ? { modelBinding: request.modelBinding }
528
+ : {}),
529
+ },
530
+ {
531
+ name: "conversation_compaction",
532
+ schema: COMPACTION_RESPONSE_SCHEMA_V1,
533
+ },
534
+ cancelled,
535
+ );
536
+ if (result.status === "failed") {
537
+ throw new Error(result.failure.message);
471
538
  }
472
- return text;
539
+ return renderCompactionSummaryV1(result.value);
473
540
  },
474
541
  });
475
542
  });
@@ -294,7 +294,7 @@ export interface AppletCapabilityHostOptionsV1 {
294
294
  * told so in the failure when the files are missing, rather than the publish
295
295
  * silently using old bytes.
296
296
  */
297
- syncSourceRootNow?(): Promise<void>;
297
+ syncSourceRootNow?(appletId: string): Promise<void>;
298
298
  composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
299
299
  now?(): Date;
300
300
  }
@@ -731,7 +731,7 @@ export function createAppletCapabilityHostV1(
731
731
 
732
732
  // Force a pull of the source root so the publish sees what the Bot just
733
733
  // built, not the last synced copy. See the seam note on the option.
734
- await options.syncSourceRootNow?.();
734
+ await options.syncSourceRootNow?.(input.appletId);
735
735
 
736
736
  const server = await readFile(
737
737
  input.appletId,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { type SessionEvent } from "@frockbot/kernel-contracts";
3
3
  import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
4
+ import { SessionEventLog } from "@frockbot/kernel-do";
4
5
  import {
5
6
  botTurnCommandFingerprintV1,
6
7
  type BotTurnCompletion,
@@ -81,11 +82,15 @@ class MemoryRunStorage implements RunTerminalStorage {
81
82
  return Promise.resolve(this.values.get(key) as T | undefined);
82
83
  }
83
84
 
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)) {
85
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
86
+ if (typeof key === "string") {
88
87
  this.values.set(key, structuredClone(value));
88
+ return Promise.resolve();
89
+ }
90
+ this.putBatches.push(structuredClone(key));
91
+ if (this.putFailure) return Promise.reject(this.putFailure);
92
+ for (const [entry, item] of Object.entries(key)) {
93
+ this.values.set(entry, structuredClone(item));
89
94
  }
90
95
  return Promise.resolve();
91
96
  }
@@ -93,6 +98,16 @@ class MemoryRunStorage implements RunTerminalStorage {
93
98
  delete(key: string): Promise<boolean> {
94
99
  return Promise.resolve(this.values.delete(key));
95
100
  }
101
+
102
+ list<T>(options: { prefix: string }): Promise<Map<string, T>> {
103
+ return Promise.resolve(
104
+ new Map(
105
+ [...this.values.entries()].filter(([key]) =>
106
+ key.startsWith(options.prefix),
107
+ ) as Array<[string, T]>,
108
+ ),
109
+ );
110
+ }
96
111
  }
97
112
 
98
113
  describe("Bot run terminal persistence", () => {
@@ -284,6 +299,8 @@ describe("Bot run terminal persistence", () => {
284
299
  failure: "provider-bound retrieval unavailable",
285
300
  });
286
301
  expect(storage.values.get(keys.activeRun)).toBe("run-1");
287
- expect(storage.values.get(keys.latestEvents)).toEqual([request]);
302
+ expect(await new SessionEventLog(storage).read("user:primary")).toEqual([
303
+ request,
304
+ ]);
288
305
  });
289
306
  });
@@ -112,6 +112,11 @@ export interface ShellCompositionMountOptions {
112
112
  agentPackages?: readonly FoundationAgentPackage[];
113
113
  modelSelection?: RuntimeModelSelection;
114
114
  systemPromptSection?: string;
115
+ /** Called after the loop has flushed `turn/end`; errors are non-fatal. */
116
+ onTurnStopping?(input: {
117
+ turn: number;
118
+ events: readonly SessionEvent[];
119
+ }): Promise<void>;
115
120
  /**
116
121
  * Durably linearizes each provider or tool effect against Stop immediately
117
122
  * before it is used. The Bot Durable Object owns the transaction; the mounted
@@ -180,6 +185,20 @@ export function createShellCompositionHost(
180
185
  ...(options.turnType ? { turnType: options.turnType } : {}),
181
186
  ...(options.subagentRole ? { subagentRole: options.subagentRole } : {}),
182
187
  });
188
+ const disposeTurnStopping = options.onTurnStopping
189
+ ? runtime.root.on("agent/turn-stopping", async (agent, turn) => {
190
+ try {
191
+ await options.onTurnStopping!({
192
+ turn,
193
+ events: structuredClone(agent.session.events),
194
+ });
195
+ } catch {
196
+ // Accounting is a durable projection. Its own outbox exposes a
197
+ // gap; it can never turn a completed model response into a
198
+ // failed conversation Turn.
199
+ }
200
+ })
201
+ : undefined;
183
202
 
184
203
  const isolateMembers = generation.members.filter(
185
204
  (member) => member.artifact !== undefined,
@@ -290,6 +309,7 @@ export function createShellCompositionHost(
290
309
  }
291
310
 
292
311
  const dispose = async () => {
312
+ disposeTurnStopping?.();
293
313
  for (const unregister of unregisterApplets.toReversed()) unregister();
294
314
  for (const contribution of active.toReversed()) {
295
315
  await contribution.dispose();
@@ -6,6 +6,7 @@ import type {
6
6
  UserSettingsViewV1,
7
7
  } from "@frockbot/configuration-core";
8
8
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
+ import { SessionEventLog } from "@frockbot/kernel-do";
9
10
  import { createShellBotBackendContribution } from "./backend.js";
10
11
  import { createIsolateCapabilityHost } from "./backend-isolate.js";
11
12
  import { notificationIdV1 } from "./notification-id.js";
@@ -549,11 +550,12 @@ describe("generic per-Turn model resolution", () => {
549
550
  });
550
551
 
551
552
  expect(result.text).toBe("Cordis runtime: hello");
552
- const durableEvents = storage.values.get("latest-events") as
553
- Array<{ seq: number }> | undefined;
554
- expect(durableEvents?.length).toBeGreaterThan(0);
555
- expect(durableEvents?.map((event) => event.seq)).toEqual(
556
- durableEvents?.map((_, index) => index),
553
+ const durableEvents = await new SessionEventLog(storage).read(
554
+ "user-1:primary",
555
+ );
556
+ expect(durableEvents.length).toBeGreaterThan(0);
557
+ expect(durableEvents.map((event) => event.seq)).toEqual(
558
+ durableEvents.map((_, index) => index),
557
559
  );
558
560
  const settings = await contribution.getSettings(identity);
559
561
  expect(settings).toMatchObject({ revision: 0, packageValues: {} });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
3
+ import { SessionEventLog, storedRunRecordV2 } from "@frockbot/kernel-do";
3
4
  import { createShellBotBackendContribution } from "./backend.js";
4
5
  import type { StoredRun } from "./backend-contracts.js";
5
6
 
@@ -154,6 +155,54 @@ describe("Bot debug snapshot", () => {
154
155
  expect(snapshot.activeRunId).toBeUndefined();
155
156
  });
156
157
 
158
+ test("reads bounded projections instead of hydrating full model prompts", async () => {
159
+ const storage = new MemoryStorage();
160
+ const event = {
161
+ type: "model/request",
162
+ turn: 1,
163
+ step: 1,
164
+ request: {
165
+ requestId: "request-large",
166
+ provider: "fake",
167
+ model: "large-context",
168
+ system: "s".repeat(80_000),
169
+ messages: [{ role: "user", content: "last question" }],
170
+ tools: [],
171
+ },
172
+ seq: 0,
173
+ timestamp: "2026-08-28T00:00:01.000Z",
174
+ } as StoredRun["events"][number];
175
+ const run = storedRun({ events: [event] });
176
+ await new SessionEventLog(storage).rewrite(run.sessionId, [event]);
177
+ await storage.put({
178
+ identity: IDENTITY,
179
+ [`run:${run.runId}`]: storedRunRecordV2(run),
180
+ [`run-index:${run.acceptedAt}:${run.runId}`]: run.runId,
181
+ });
182
+
183
+ const snapshot = await contributionOver(storage).debugSnapshot(IDENTITY, {
184
+ schemaVersion: 1,
185
+ runId: run.runId,
186
+ });
187
+
188
+ expect(snapshot.runs[0]?.eventCount).toBe(1);
189
+ expect(snapshot.runs[0]?.events?.[0]).toMatchObject({
190
+ type: "model/request",
191
+ cut: { marker: "content-cut", originalBytes: expect.any(Number) },
192
+ request: {
193
+ requestId: "request-large",
194
+ messageCount: 1,
195
+ toolCount: 0,
196
+ sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
197
+ truncated: true,
198
+ },
199
+ });
200
+ expect(JSON.stringify(snapshot)).not.toContain("s".repeat(10_000));
201
+ expect(
202
+ new TextEncoder().encode(JSON.stringify(snapshot)).byteLength,
203
+ ).toBeLessThan(20_000);
204
+ });
205
+
157
206
  test("includes an active run older than the page it would otherwise fall off", async () => {
158
207
  const storage = new MemoryStorage();
159
208
  const stale = storedRun({