@frockbot/plugin-shell 0.3.16 → 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/src/backend.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  decodeIsolateWorkspaceListRequestV1,
10
10
  decodeIsolateWorkspacePathV1,
11
11
  decodeIsolateWorkspaceWriteRequestV1,
12
+ decodeSendToUserPayloadV1,
12
13
  decodeWorkspacePathV1,
13
14
  decodeWorkspaceRootV1,
14
15
  decodeSessionEvent,
@@ -134,6 +135,7 @@ import {
134
135
  import {
135
136
  createAppletCapabilityHostV1,
136
137
  createAppletInstanceBindingV1,
138
+ APPLET_DIST_FILES_V1,
137
139
  appletRpcSnapshotV1 as rpcJsonSnapshotV1,
138
140
  resolveAppletCompositionV1,
139
141
  type AppletCapabilityHostV1,
@@ -253,6 +255,7 @@ import {
253
255
  decodeSubagentSlotReceiptV1,
254
256
  type SubagentSlotBinding,
255
257
  } from "@frockbot/plugin-subagents/quota";
258
+ import { decodeAgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
256
259
  import {
257
260
  subagentModelCatalogV1,
258
261
  type SubagentModelOptionV1,
@@ -406,6 +409,7 @@ import {
406
409
  decodeClientRunLookupQueryV1,
407
410
  decodeClientRunListQueryV1,
408
411
  decodeClientRunStopCommandV1,
412
+ decodeClientTurnV1,
409
413
  isVisibleRunV1,
410
414
  projectClientRunLookupV1,
411
415
  projectClientRunV1,
@@ -667,6 +671,16 @@ export interface ShellBotBackendHost {
667
671
  scheduledWorkInFlight?(): boolean;
668
672
  deferScheduledWork?(transaction: DurableObjectTransaction): Promise<void>;
669
673
  settleScheduledWork?(): Promise<void>;
674
+ /**
675
+ * A derived accounting projection after `turn/end` is durable. The host
676
+ * queues it before delivery, so failure never changes the Turn's outcome.
677
+ */
678
+ recordSettledUsage?(input: {
679
+ botId: string;
680
+ runId: string;
681
+ turn: number;
682
+ events: readonly SessionEvent[];
683
+ }): Promise<void>;
670
684
  }
671
685
 
672
686
  /** The narrow storage seam the Bot's announcement log is written through. */
@@ -769,6 +783,7 @@ export class ShellBotBackendContribution {
769
783
  private readonly hostScheduledWorkInFlight?: ShellBotBackendHost["scheduledWorkInFlight"];
770
784
  private readonly hostDeferScheduledWork?: ShellBotBackendHost["deferScheduledWork"];
771
785
  private readonly hostSettleScheduledWork?: ShellBotBackendHost["settleScheduledWork"];
786
+ private readonly recordSettledUsage?: ShellBotBackendHost["recordSettledUsage"];
772
787
 
773
788
  constructor(host: ShellBotBackendHost) {
774
789
  this.ctx = host.state;
@@ -784,6 +799,7 @@ export class ShellBotBackendContribution {
784
799
  this.hostScheduledWorkInFlight = host.scheduledWorkInFlight;
785
800
  this.hostDeferScheduledWork = host.deferScheduledWork;
786
801
  this.hostSettleScheduledWork = host.settleScheduledWork;
802
+ this.recordSettledUsage = host.recordSettledUsage;
787
803
  const routines = createBotRoutines(
788
804
  host.state.storage,
789
805
  createBotRoutineHookMinter(
@@ -1645,6 +1661,9 @@ export class ShellBotBackendContribution {
1645
1661
  // One admitted Turn is one run; the Turn ordinal lives in the session log.
1646
1662
  turnId: input.command.runId,
1647
1663
  sessionId: input.command.sessionId,
1664
+ // The admitted configuration snapshot is durable, so a recovered
1665
+ // bot_message reuses the same sender display name in its target command.
1666
+ fromBotName: settings.profile.name,
1648
1667
  // The pin this Turn was admitted under, and the type it was admitted as.
1649
1668
  // A subagent dispatched from here runs on this generation, and the model
1650
1669
  // catalog it is offered is narrowed by this turn type.
@@ -1660,6 +1679,17 @@ export class ShellBotBackendContribution {
1660
1679
  ...(input.command.origin?.kind === "subagent"
1661
1680
  ? { subagentTaskId: input.command.origin.taskId }
1662
1681
  : {}),
1682
+ ...(input.command.origin?.kind === "bot"
1683
+ ? {
1684
+ inboundAgent: {
1685
+ kind: "bot" as const,
1686
+ fromBotId: input.command.origin.fromBotId,
1687
+ fromBotName: input.command.origin.fromBotName,
1688
+ },
1689
+ }
1690
+ : input.command.origin?.kind === "voice"
1691
+ ? { inboundAgent: { kind: "voice" as const } }
1692
+ : {}),
1663
1693
  };
1664
1694
  let mountedRoot: ShellMountedComposition["root"] | undefined;
1665
1695
  let mountedGeneration: CompositionGenerationV1 | undefined;
@@ -1729,6 +1759,16 @@ export class ShellBotBackendContribution {
1729
1759
  input.command.sessionId,
1730
1760
  effect,
1731
1761
  ),
1762
+ ...(this.recordSettledUsage
1763
+ ? {
1764
+ onTurnStopping: (settled) =>
1765
+ this.recordSettledUsage!({
1766
+ botId: input.identity.botId,
1767
+ runId: input.command.runId,
1768
+ ...settled,
1769
+ }),
1770
+ }
1771
+ : {}),
1732
1772
  ...(isolate ? { isolate } : {}),
1733
1773
  ...(appletRouting ? { applets: appletRouting } : {}),
1734
1774
  }).mount(mounting, signal);
@@ -2414,7 +2454,7 @@ export class ShellBotBackendContribution {
2414
2454
  // User with no Computer assignment has no root to pull, and the Bot that
2415
2455
  // just built on its Computer has it open already.
2416
2456
  syncSourceRootNow: active
2417
- ? async () => {
2457
+ ? async (appletId) => {
2418
2458
  const root = active.mounted.runtime.root as unknown as {
2419
2459
  computers?: ComputerRegistry;
2420
2460
  sessions: typeof active.mounted.runtime.root.sessions;
@@ -2437,6 +2477,9 @@ export class ShellBotBackendContribution {
2437
2477
  sessionId: active.sessionId,
2438
2478
  turn,
2439
2479
  root: appletsSourceRootV1(identity.userId),
2480
+ requiredPaths: APPLET_DIST_FILES_V1.map(
2481
+ (path) => `${appletId}/${path}`,
2482
+ ),
2440
2483
  signal: active.signal,
2441
2484
  });
2442
2485
  }
@@ -3251,6 +3294,22 @@ export class ShellBotBackendContribution {
3251
3294
  };
3252
3295
  }
3253
3296
 
3297
+ /** Narrow RPC for the User-wide agent-lane concurrency lease. */
3298
+ private agentTurnSlots(identity: BotIdentity) {
3299
+ const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
3300
+ const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
3301
+ reserveAgentTurnSlot(input: unknown): Promise<unknown>;
3302
+ releaseAgentTurnSlot(input: unknown): Promise<unknown>;
3303
+ };
3304
+ return {
3305
+ reserve: async (request: unknown) =>
3306
+ decodeAgentTurnSlotReceiptV1(await rpc.reserveAgentTurnSlot(request)),
3307
+ release: async (request: unknown) => {
3308
+ await rpc.releaseAgentTurnSlot(request);
3309
+ },
3310
+ };
3311
+ }
3312
+
3254
3313
  /**
3255
3314
  * The User-wide `desktop-gui` lease, held at the Computer host.
3256
3315
  *
@@ -4333,6 +4392,7 @@ export class ShellBotBackendContribution {
4333
4392
  runId: string;
4334
4393
  turnId: string;
4335
4394
  sessionId: string;
4395
+ fromBotName: string;
4336
4396
  /**
4337
4397
  * The generation this Turn pinned, and the type it was admitted as. A
4338
4398
  * dispatched subagent runs on the generation its parent pinned, and the
@@ -4522,6 +4582,39 @@ export class ShellBotBackendContribution {
4522
4582
  this.userConfiguration(identity).listBots(userId),
4523
4583
  createBot: (userId, command) =>
4524
4584
  this.userConfiguration(identity).createBot(userId, command),
4585
+ reserveAgentTurn: (request) =>
4586
+ this.agentTurnSlots(identity).reserve(request),
4587
+ releaseAgentTurn: (request) =>
4588
+ this.agentTurnSlots(identity).release(request),
4589
+ runAgent: async (request) => {
4590
+ if (!this.env.BOT_STATES) {
4591
+ throw new Error("Bot-to-Bot messaging is unavailable");
4592
+ }
4593
+ const id = this.env.BOT_STATES.idFromName(
4594
+ `${request.userId}:${request.botId}`,
4595
+ );
4596
+ const rpc = this.env.BOT_STATES.get(id) as unknown as {
4597
+ runAgent(input: unknown): Promise<unknown>;
4598
+ };
4599
+ const completed = decodeClientTurnV1(
4600
+ structuredClone(await rpc.runAgent(request)),
4601
+ );
4602
+ let sentText: string | undefined;
4603
+ for (const event of completed.events) {
4604
+ if (event.type !== "send/to-user") continue;
4605
+ const payload = decodeSendToUserPayloadV1(
4606
+ event.payload,
4607
+ "agent send/to-user payload",
4608
+ );
4609
+ if (payload.type === "text") {
4610
+ sentText = payload.text;
4611
+ break;
4612
+ }
4613
+ }
4614
+ return {
4615
+ text: sentText ?? completed.text,
4616
+ };
4617
+ },
4525
4618
  }),
4526
4619
  }
4527
4620
  : {}),
@@ -1490,7 +1490,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1490
1490
  </div>
1491
1491
  </div>
1492
1492
  </template>
1493
- <div v-else class="message-bubble">{{ message.text }}</div>
1493
+ <div v-else class="message-user-column">
1494
+ <div class="message-bubble">{{ message.text }}</div>
1495
+ <span v-if="message.via" class="message-via">
1496
+ via {{ message.via.name }}
1497
+ </span>
1498
+ </div>
1494
1499
  </article>
1495
1500
  <!--
1496
1501
  The working row: the Bot's own avatar on its own line at the end of
@@ -95,6 +95,40 @@ afterEach(() => {
95
95
  }
96
96
  });
97
97
 
98
+ test("a degraded Computer sync is a notice beneath the Turn once", () => {
99
+ const state: Pick<
100
+ FrockBotWebData,
101
+ "messages" | "activeRunId" | "runningRunId" | "activeRun"
102
+ > = { messages: [] };
103
+
104
+ projectDurableRuns(
105
+ state,
106
+ [],
107
+ [
108
+ {
109
+ runId: "run-sync",
110
+ input: "build the applet",
111
+ status: "completed",
112
+ responseText: "The Applet is ready.",
113
+ events: [
114
+ {
115
+ type: "computer/sync",
116
+ status: "degraded",
117
+ message: "Excluded 1 reproducible Workspace item from sync.",
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ );
123
+
124
+ expect(state.messages).toHaveLength(2);
125
+ expect(state.messages[1]).toMatchObject({
126
+ role: "assistant",
127
+ text: "The Applet is ready.",
128
+ notice: "Excluded 1 reproducible Workspace item from sync.",
129
+ });
130
+ });
131
+
98
132
  describe("application manifest protocol", () => {
99
133
  const emptyManifest = {
100
134
  schemaVersion: 1,
@@ -1451,6 +1485,33 @@ describe("active durable Turn projection", () => {
1451
1485
  expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1452
1486
  });
1453
1487
 
1488
+ test("keeps the source marker on an agent-origin user bubble", () => {
1489
+ const state: Pick<
1490
+ FrockBotWebData,
1491
+ "messages" | "activeRunId" | "activeRun"
1492
+ > = { messages: [] };
1493
+ projectDurableRuns(
1494
+ state,
1495
+ [],
1496
+ [
1497
+ {
1498
+ runId: "run-agent",
1499
+ input: "What changed?",
1500
+ events: [],
1501
+ status: "completed",
1502
+ responseText: "The answer",
1503
+ via: { kind: "bot", name: "Researcher", botId: "researcher" },
1504
+ },
1505
+ ],
1506
+ );
1507
+
1508
+ expect(state.messages[0]).toMatchObject({
1509
+ role: "user",
1510
+ text: "What changed?",
1511
+ via: { kind: "bot", name: "Researcher" },
1512
+ });
1513
+ });
1514
+
1454
1515
  test("projects dispatched subagents as chips, and skips one it cannot draw", () => {
1455
1516
  const state: Pick<
1456
1517
  FrockBotWebData,
@@ -363,6 +363,13 @@ function assistantMessage(
363
363
  run: ClientRun,
364
364
  notification: ClientNotificationIntent | undefined,
365
365
  ): WebChatMessage {
366
+ const syncNotice = run.events.find(
367
+ (event) => event.type === "computer/sync" && event.message,
368
+ )?.message;
369
+ const notice = (primary?: string): string | undefined =>
370
+ [primary, syncNotice]
371
+ .filter((part): part is string => Boolean(part))
372
+ .join(" ") || undefined;
366
373
  if (run.status === "running") {
367
374
  // A streaming Turn carries only the text the model has produced. Until
368
375
  // there is any, the thread shows the animated avatar and no bubble.
@@ -375,6 +382,7 @@ function assistantMessage(
375
382
  // A Turn that has not started shows nothing of its own: the greyed user
376
383
  // message is the whole of what the thread says about it.
377
384
  ...(run.queued ? { pending: true } : {}),
385
+ ...(syncNotice ? { notice: syncNotice } : {}),
378
386
  tools: toolsFrom(run.events),
379
387
  sends: [],
380
388
  tasks: tasksFrom(run.events),
@@ -391,6 +399,7 @@ function assistantMessage(
391
399
  role: "assistant",
392
400
  text: visibleAssistantText(run),
393
401
  status: "aborted",
402
+ ...(syncNotice ? { notice: syncNotice } : {}),
394
403
  tools: toolsFrom(run.events),
395
404
  sends: [],
396
405
  tasks: tasksFrom(run.events),
@@ -409,7 +418,7 @@ function assistantMessage(
409
418
  * exactly like the Bot speaking.
410
419
  */
411
420
  text: visibleAssistantText(run),
412
- notice: "This reply stopped partway. Try again to continue it.",
421
+ notice: notice("This reply stopped partway. Try again to continue it."),
413
422
  status: "reconciliation-required",
414
423
  tools: toolsFrom(run.events),
415
424
  sends: [],
@@ -422,7 +431,7 @@ function assistantMessage(
422
431
  runId: run.runId,
423
432
  role: "assistant",
424
433
  text: visibleAssistantText(run),
425
- notice: "You stopped this.",
434
+ notice: notice("You stopped this."),
426
435
  status: "aborted",
427
436
  tools: toolsFrom(run.events),
428
437
  sends: [],
@@ -447,7 +456,7 @@ function assistantMessage(
447
456
  // wire, so this keeps whatever that chose — the model-deadline copy says
448
457
  // something the outcome alone cannot — and falls back to the same line a
449
458
  // reply-less failure gets.
450
- notice: knownFailureCopyV1(run.failure),
459
+ notice: notice(knownFailureCopyV1(run.failure)),
451
460
  status: "error",
452
461
  tools: toolsFrom(run.events),
453
462
  sends: [],
@@ -465,8 +474,10 @@ function assistantMessage(
465
474
  // Why the Turn ends there, under whatever it had already said — never as
466
475
  // the bubble's own text, which reads as the Bot saying it.
467
476
  ...(run.status === "failed"
468
- ? { notice: knownFailureCopyV1(run.failure) }
469
- : {}),
477
+ ? { notice: notice(knownFailureCopyV1(run.failure)) }
478
+ : syncNotice
479
+ ? { notice: syncNotice }
480
+ : {}),
470
481
  status: run.status === "failed" ? "error" : "completed",
471
482
  tools: toolsFrom(run.events),
472
483
  sends: [],
@@ -561,6 +572,7 @@ export function projectDurableRuns(
561
572
  ? { at: existingUser.at }
562
573
  : {}),
563
574
  status: "completed",
575
+ ...(run.via ? { via: run.via } : {}),
564
576
  // Greyed while its Turn waits, ordinary the moment it is running. The
565
577
  // flag comes from durable run state, so a reload draws the same thing.
566
578
  ...(run.queued ? { pending: true } : {}),
@@ -3070,6 +3082,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3070
3082
  * and the one-bubble-per-send contract is untouched.
3071
3083
  */
3072
3084
  let stopRunChannel: (() => void) | undefined;
3085
+ // The channel's own health, for the observation below: `fallback` means the
3086
+ // socket is gone and the transcript is flying blind, and each return to
3087
+ // `open` means it was gone for a while. Either is a reason to read the
3088
+ // running Turn from authority rather than trust a POST that may have died
3089
+ // with the same connection — which is how a phone kept drawing the working
3090
+ // trail for a Turn the server had already settled (2026-09-04).
3091
+ const channelFallback = ref(false);
3092
+ const channelReconnects = ref(0);
3073
3093
  const stopRunChannelWatch = watch(
3074
3094
  () => web.value.activeBotId,
3075
3095
  (botId) => {
@@ -3096,10 +3116,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3096
3116
  restoredWithoutRead = undefined;
3097
3117
  await deliverNotifications(botId, generation);
3098
3118
  },
3099
- status() {
3119
+ status(status) {
3100
3120
  // The channel's health is not the transcript's: an unavailable
3101
3121
  // socket falls back to the observation below, which is what a
3102
3122
  // client without one uses anyway.
3123
+ if (generation !== selectionGeneration) return;
3124
+ const wasDown = channelFallback.value;
3125
+ channelFallback.value = status === "fallback";
3126
+ if (status === "open" && wasDown) channelReconnects.value += 1;
3103
3127
  },
3104
3128
  });
3105
3129
  },
@@ -3107,12 +3131,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3107
3131
  );
3108
3132
 
3109
3133
  const stopRunObservation = watch(
3110
- () => [web.value.activeBotId, web.value.activeRunId] as const,
3111
- ([botId, runId]) => {
3134
+ () =>
3135
+ [
3136
+ web.value.activeBotId,
3137
+ web.value.activeRunId,
3138
+ channelFallback.value,
3139
+ channelReconnects.value,
3140
+ ] as const,
3141
+ ([botId, runId, fallback]) => {
3112
3142
  // The send path owns the run it started: its POST is the observation,
3113
3143
  // and `stopRun` starts its own. This is for every other way a client
3114
- // finds itself watching a Turn it is not holding open.
3115
- if (!botId || !runId || activeRequest || runObserver) return;
3144
+ // finds itself watching a Turn it is not holding open — and, once the
3145
+ // state channel has dropped or come back, for the Turn it *is* holding
3146
+ // open, because that POST shared the connection that just failed.
3147
+ if (!botId || !runId || runObserver) return;
3148
+ if (activeRequest && !fallback && channelReconnects.value === 0) return;
3116
3149
  const generation = selectionGeneration;
3117
3150
  const observer = new AbortController();
3118
3151
  runObserver = observer;
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const styles = await Bun.file(new URL("./styles.css", import.meta.url)).text();
4
+
5
+ describe("phone safe-area layout", () => {
6
+ test("keeps the conversation header and thread below the status bar", () => {
7
+ expect(styles).toContain(
8
+ "height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
9
+ );
10
+ expect(styles).toContain("padding-top: calc(var(--frock-safe-top) + 0px);");
11
+ expect(styles).toContain(
12
+ "top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
13
+ );
14
+ });
15
+
16
+ test("keeps drawers, Settings, and the composer clear of both bars", () => {
17
+ expect(styles).toContain(
18
+ "padding-bottom: calc(10px + var(--frock-safe-bottom));",
19
+ );
20
+ expect(styles).toContain(
21
+ "padding-bottom: calc(16px + var(--frock-safe-bottom));",
22
+ );
23
+ expect(styles).toContain("bottom: calc(10px + var(--frock-safe-bottom));");
24
+ });
25
+ });
@@ -499,6 +499,17 @@
499
499
  background: var(--frock-action-primary);
500
500
  }
501
501
 
502
+ .message-user-column {
503
+ display: grid;
504
+ justify-items: end;
505
+ gap: 4px;
506
+ }
507
+
508
+ .message-via {
509
+ color: var(--frock-text-muted);
510
+ font-size: var(--frock-text-xs);
511
+ }
512
+
502
513
  /*
503
514
  * User-facing sends. They stack under the Bot's avatar in the same column the
504
515
  * text bubble occupies, so a Turn that only sent a widget still reads as the
@@ -1118,6 +1129,7 @@
1118
1129
  /* `calc` so the declaration is a length the checker can type; `env` alone
1119
1130
  is not one it knows. */
1120
1131
  padding-top: calc(var(--frock-safe-top) + 0px);
1132
+ padding-bottom: calc(10px + var(--frock-safe-bottom));
1121
1133
  box-shadow: var(--frock-shadow-panel);
1122
1134
  transform: translateX(-100%);
1123
1135
  visibility: hidden;
@@ -1189,12 +1201,27 @@
1189
1201
  /* No window chrome to clear, but the panel toggle still sits at the
1190
1202
  trailing edge, so the row ends before it rather than under it. */
1191
1203
  .topbar {
1204
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1192
1205
  gap: 8px;
1193
1206
  padding: 0 52px 0 4px;
1207
+ padding-top: calc(var(--frock-safe-top) + 0px);
1194
1208
  }
1195
1209
 
1196
1210
  .window-actions {
1211
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1197
1212
  padding: 0 8px;
1213
+ padding-top: calc(var(--frock-safe-top) + 0px);
1214
+ }
1215
+
1216
+ .right-panel-header,
1217
+ .panel-surface-header {
1218
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1219
+ padding-top: calc(var(--frock-safe-top) + 0px);
1220
+ }
1221
+
1222
+ .right-panel-body,
1223
+ .panel-surface-content {
1224
+ padding-bottom: calc(16px + var(--frock-safe-bottom));
1198
1225
  }
1199
1226
 
1200
1227
  .brand-mark {
@@ -1211,6 +1238,7 @@
1211
1238
  * vertical anchors all clear the home indicator.
1212
1239
  */
1213
1240
  .thread {
1241
+ top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1214
1242
  bottom: calc(76px + var(--frock-safe-bottom));
1215
1243
  padding: 12px 12px 20px;
1216
1244
  }
@@ -15,6 +15,7 @@ import {
15
15
  parseCompactionSummaryV1,
16
16
  PRUNED_TOOL_RESULT_V1,
17
17
  pruneToolOutputsV1,
18
+ renderCompactionSummaryV1,
18
19
  runCompactionV1,
19
20
  COMPACTION_TRIGGER_RATIO_V1,
20
21
  } from "./compaction.js";
@@ -385,6 +386,31 @@ describe("injecting a compaction", () => {
385
386
  });
386
387
 
387
388
  describe("reading the summariser's answer", () => {
389
+ test("renders the validated fields into the durable heading format", () => {
390
+ expect(
391
+ renderCompactionSummaryV1({
392
+ summary: "Work on the Applet.",
393
+ decisions: ["Keep the first design."],
394
+ openItems: [],
395
+ identifiers: ["applet-9f2c"],
396
+ }),
397
+ ).toBe(
398
+ [
399
+ "## Summary",
400
+ "Work on the Applet.",
401
+ "",
402
+ "## Decisions",
403
+ "- Keep the first design.",
404
+ "",
405
+ "## Open items",
406
+ "- none",
407
+ "",
408
+ "## Identifiers mentioned",
409
+ "- applet-9f2c",
410
+ ].join("\n"),
411
+ );
412
+ });
413
+
388
414
  test("lifts the identifiers out of their heading", () => {
389
415
  const parsed = parseCompactionSummaryV1(
390
416
  [
package/src/compaction.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type ModelBindingSnapshot,
27
27
  type Session,
28
28
  type SessionEvent,
29
+ type StructuredOutputSchemaV1,
29
30
  } from "@frockbot/kernel-contracts";
30
31
 
31
32
  /**
@@ -262,27 +263,58 @@ export function assessCompactionV1(input: {
262
263
  * ids in passing, and the list is what the event stores.
263
264
  */
264
265
  export const COMPACTION_SYSTEM_PROMPT_V1 = [
265
- "You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt. Write the summary, and nothing else.",
266
+ "You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt.",
266
267
  "",
267
268
  "CRITICAL: You MUST preserve ALL opaque identifiers exactly as they appear. That includes UUIDs, hashes, full URLs with their query parameters, file and Workspace paths, Package ids, Applet ids, Bot ids, Session ids, tool call ids, model names and version strings. Do NOT paraphrase, abbreviate, or generalise an identifier. Copy it exactly.",
268
269
  "",
269
- "Use exactly these headings, in this order, and omit none of them:",
270
- "",
271
- "## Summary",
272
- "What the conversation is about and what has happened, in a few short paragraphs or bullets.",
273
- "",
274
- "## Decisions",
275
- "Decisions made and the reason for each. Where a decision was later changed, keep only the latest and say it superseded an earlier one.",
276
- "",
277
- "## Open items",
278
- "Work that is pending, promised, or unfinished. Be specific about what is owed and by whom.",
279
- "",
280
- "## Identifiers mentioned",
281
- "A bullet list of every opaque identifier that appeared, one per line, copied exactly. Write `- none` if there were none.",
270
+ "Put the gist in `summary`, decisions and their reasons in `decisions`, pending work in `openItems`, and every opaque identifier copied exactly in `identifiers`. Keep only the latest decision where one superseded another.",
282
271
  "",
283
272
  "Leave out pleasantries, repetition, and superseded detail. Do not invent anything that is not in the transcript. Do not address the user.",
284
273
  ].join("\n");
285
274
 
275
+ export interface CompactionSummaryPayloadV1 {
276
+ summary: string;
277
+ decisions: string[];
278
+ openItems: string[];
279
+ identifiers: string[];
280
+ }
281
+
282
+ /** The actual production consumer of the shared structured-output seam. */
283
+ export const COMPACTION_RESPONSE_SCHEMA_V1 = {
284
+ type: "object",
285
+ properties: {
286
+ summary: { type: "string" },
287
+ decisions: { type: "array", items: { type: "string" } },
288
+ openItems: { type: "array", items: { type: "string" } },
289
+ identifiers: { type: "array", items: { type: "string" } },
290
+ },
291
+ required: ["summary", "decisions", "openItems", "identifiers"],
292
+ additionalProperties: false,
293
+ } as const satisfies StructuredOutputSchemaV1;
294
+
295
+ /** Keeps the durable summary format readable while model I/O stays typed. */
296
+ export function renderCompactionSummaryV1(
297
+ payload: CompactionSummaryPayloadV1,
298
+ ): string {
299
+ const bullets = (values: readonly string[]) =>
300
+ values.length > 0
301
+ ? values.map((value) => `- ${value}`).join("\n")
302
+ : "- none";
303
+ return [
304
+ "## Summary",
305
+ payload.summary,
306
+ "",
307
+ "## Decisions",
308
+ bullets(payload.decisions),
309
+ "",
310
+ "## Open items",
311
+ bullets(payload.openItems),
312
+ "",
313
+ "## Identifiers mentioned",
314
+ bullets(payload.identifiers),
315
+ ].join("\n");
316
+ }
317
+
286
318
  /** The transcript one summariser call is given, flattened to plain text. */
287
319
  export function compactionTranscriptV1(
288
320
  messages: readonly LlmMessage[],
package/src/history.ts CHANGED
@@ -175,7 +175,10 @@ export function chatWindowV1(
175
175
  ): ChatWindowV1 {
176
176
  const turns = messageTurnsV1(events);
177
177
  const types = turnTypesByTurnV1(events);
178
- const chat = (turn: number) => (types.get(turn) ?? "chat") === "chat";
178
+ const chat = (turn: number) => {
179
+ const type = types.get(turn) ?? "chat";
180
+ return type === "chat" || type === "agent";
181
+ };
179
182
  const state = compactionStateV1(events);
180
183
  const current = currentTurnV1(events);
181
184
  // A compaction never covers the Turn being assembled, whatever the log says:
@@ -229,7 +232,10 @@ export function turnScopedMessagesV1(
229
232
  }
230
233
  const types = turnTypesByTurnV1(input.events);
231
234
  const current = currentTurnV1(input.events);
232
- const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
235
+ const chatTurn = (turn: number) => {
236
+ const type = types.get(turn) ?? "chat";
237
+ return type === "chat" || type === "agent";
238
+ };
233
239
  if (chatTurn(current)) {
234
240
  const window = chatWindowV1(input.events, input.messages);
235
241
  // Tier 1 of ADR 0030, and the only one that costs nothing: a tool result
@@ -1,3 +1,4 @@
1
+ import { STEP_LIMIT_REASON_V1 } from "@frockbot/kernel-agent-loop";
1
2
  import { describe, expect, test } from "bun:test";
2
3
  import type { SessionEvent } from "@frockbot/kernel-contracts";
3
4
  import { MODEL_FIRST_BYTE_DEADLINE_REASON_V1 } from "@frockbot/kernel-contracts";
@@ -157,3 +158,13 @@ describe("runFailureCopyV1", () => {
157
158
  expect(projected.outcome.message).toBe(RUN_FAILURE_COPY_V1.interrupted);
158
159
  });
159
160
  });
161
+
162
+ test("a reply that ran out of steps says so in plain words", () => {
163
+ // `kernel-do` wraps the loop's reason; the sentence survives the wrapper.
164
+ expect(
165
+ runFailureCopyV1({
166
+ failure: `Bot turn ended with outcome interrupted: ${STEP_LIMIT_REASON_V1}`,
167
+ }),
168
+ ).toBe(STEP_LIMIT_REASON_V1);
169
+ expect(knownFailureCopyV1(STEP_LIMIT_REASON_V1)).toBe(STEP_LIMIT_REASON_V1);
170
+ });