@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.
@@ -1,17 +1,17 @@
1
1
  // The Bot Durable Object's half of the Bot self-management seam.
2
2
  //
3
- // The Flock Package offers a Bot two tools over its own identity and its
4
- // User's flock. This module decides, for one admitted Turn, what provenance
5
- // those writes record and which authorities they may reach. It implements
6
- // neither: the profile write is the Bot Durable Object's own configuration
7
- // command, and the create is the User Durable Object's `bot/create` the
8
- // same two paths the hosted client drives.
3
+ // The Flock Package offers a Bot self-management and direct messaging over its
4
+ // own identity and its User's flock. This module decides, for one admitted
5
+ // Turn, what provenance those effects record and which authorities they may
6
+ // reach. The profile write is the Bot Durable Object's own configuration
7
+ // command, create is the User Durable Object's `bot/create`, and messaging
8
+ // admits an agent Turn through the target Bot's Durable Object.
9
9
  //
10
10
  // AUTHORITY. "Self-modification never widens authority." The host handed to
11
- // the Package exposes exactly four calls, all of them things the User's own
12
- // surfaces already do, and the `botId` on every one of them is fixed here
13
- // rather than taken from the model's arguments. A Bot cannot address another
14
- // Bot's settings through this seam because the seam never accepts a target.
11
+ // the Package exposes only narrow authority calls. The `botId` on every
12
+ // mutation is fixed here rather than taken from the model's arguments. A Bot
13
+ // cannot address another Bot's settings through this seam; a message target is
14
+ // instead resolved from the same User's Flock directory before admission.
15
15
  //
16
16
  // HIBERNATION. Nothing here reaches the Computer registry, a Computer
17
17
  // provider, or a Sprite: identity is Durable Object state, so self-management
@@ -26,7 +26,9 @@ import type {
26
26
  CreateBotCommandV1,
27
27
  FlockReceiptV1,
28
28
  FlockSelfRuntimeHostV1,
29
+ BotMessageOutcomeV1,
29
30
  } from "@frockbot/plugin-flock/agent";
31
+ import type { AgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
30
32
 
31
33
  /** The Bot and User whose identity a Turn may change. */
32
34
  export interface BotSelfManagementIdentity {
@@ -39,6 +41,9 @@ export interface BotSelfManagementTurn {
39
41
  runId: string;
40
42
  turnId: string;
41
43
  sessionId: string;
44
+ /** This Turn's pinned profile name, stable across effect recovery. */
45
+ fromBotName: string;
46
+ inboundAgent?: FlockSelfRuntimeHostV1["inboundAgent"];
42
47
  }
43
48
 
44
49
  /**
@@ -57,6 +62,51 @@ export interface BotSelfManagementAuthorities {
57
62
  userId: string,
58
63
  command: CreateBotCommandV1,
59
64
  ): Promise<FlockReceiptV1>;
65
+ reserveAgentTurn(request: {
66
+ schemaVersion: 1;
67
+ userId: string;
68
+ requesterId: string;
69
+ runId: string;
70
+ reservedAt: string;
71
+ }): Promise<AgentTurnSlotReceiptV1>;
72
+ releaseAgentTurn(request: {
73
+ schemaVersion: 1;
74
+ userId: string;
75
+ requesterId: string;
76
+ runId: string;
77
+ }): Promise<void>;
78
+ runAgent(request: {
79
+ schemaVersion: 1;
80
+ userId: string;
81
+ botId: string;
82
+ command: {
83
+ runId: string;
84
+ sessionId: string;
85
+ acceptedAt: string;
86
+ text: string;
87
+ source: {
88
+ kind: "bot";
89
+ fromBotId: string;
90
+ fromBotName: string;
91
+ messageId: string;
92
+ };
93
+ };
94
+ }): Promise<{ text: string }>;
95
+ }
96
+
97
+ async function agentRunIdV1(
98
+ identity: BotSelfManagementIdentity,
99
+ targetBotId: string,
100
+ effectId: string,
101
+ ): Promise<string> {
102
+ const bytes = new TextEncoder().encode(
103
+ `${identity.userId}\u0000${identity.botId}\u0000${targetBotId}\u0000${effectId}`,
104
+ );
105
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
106
+ const hex = [...new Uint8Array(digest)]
107
+ .map((byte) => byte.toString(16).padStart(2, "0"))
108
+ .join("");
109
+ return `agent-${hex.slice(0, 32)}`;
60
110
  }
61
111
 
62
112
  /**
@@ -92,5 +142,64 @@ export function createBotSelfManagementHost(
92
142
  },
93
143
  listBots: () => authorities.listBots(identity.userId),
94
144
  createBot: (command) => authorities.createBot(identity.userId, command),
145
+ ...(turn.inboundAgent ? { inboundAgent: turn.inboundAgent } : {}),
146
+ messageBot: async (request): Promise<BotMessageOutcomeV1> => {
147
+ if (request.targetBotId === identity.botId) {
148
+ throw new Error("a Bot cannot message itself");
149
+ }
150
+ const directory = await authorities.listBots(identity.userId);
151
+ const target = directory.bots.find(
152
+ (bot) => bot.botId === request.targetBotId,
153
+ );
154
+ if (!target)
155
+ throw new Error("the target Bot is not in this User's flock");
156
+ const runId = await agentRunIdV1(
157
+ identity,
158
+ request.targetBotId,
159
+ request.effectId,
160
+ );
161
+ const reservation = await authorities.reserveAgentTurn({
162
+ schemaVersion: 1,
163
+ userId: identity.userId,
164
+ requesterId: identity.botId,
165
+ runId,
166
+ reservedAt: new Date().toISOString(),
167
+ });
168
+ if (reservation.status === "refused") {
169
+ throw new Error(reservation.reason);
170
+ }
171
+ try {
172
+ const turnResult = await authorities.runAgent({
173
+ schemaVersion: 1,
174
+ userId: identity.userId,
175
+ botId: request.targetBotId,
176
+ command: {
177
+ runId,
178
+ sessionId: `${identity.userId}:${request.targetBotId}`,
179
+ acceptedAt: new Date().toISOString(),
180
+ text: request.message,
181
+ source: {
182
+ kind: "bot",
183
+ fromBotId: identity.botId,
184
+ fromBotName: turn.fromBotName,
185
+ messageId: runId,
186
+ },
187
+ },
188
+ });
189
+ return {
190
+ targetBotId: request.targetBotId,
191
+ targetBotName: target.initialName,
192
+ runId,
193
+ text: turnResult.text,
194
+ };
195
+ } finally {
196
+ await authorities.releaseAgentTurn({
197
+ schemaVersion: 1,
198
+ userId: identity.userId,
199
+ requesterId: identity.botId,
200
+ runId,
201
+ });
202
+ }
203
+ },
95
204
  };
96
205
  }
@@ -9,6 +9,7 @@ import {
9
9
  type UserSettingsViewV1,
10
10
  } from "@frockbot/configuration-core";
11
11
  import { compileFoundationApplication } from "@frockbot/application-foundation/runtime";
12
+ import { SessionEventLog } from "@frockbot/kernel-do";
12
13
  import { createShellBotBackendContribution } from "./backend.js";
13
14
  import {
14
15
  botTurnCommandFingerprintV1,
@@ -291,10 +292,23 @@ describe("Bot recovery", () => {
291
292
  },
292
293
  ]);
293
294
  expect(settledEffects).toHaveLength(2);
295
+ const eventLog = new SessionEventLog(storage);
294
296
  for (const runId of ["ollama-run-1", "ollama-run-2"]) {
295
- const run = await storage.get<StoredRun>(`run:${runId}`);
297
+ const run = await storage.get<
298
+ Omit<StoredRun, "events"> & {
299
+ eventRange: { startSeq: number; endSeq: number };
300
+ }
301
+ >(`run:${runId}`);
302
+ expect(run).not.toHaveProperty("events");
303
+ const events = run
304
+ ? await eventLog.readRange(
305
+ "user-1:primary",
306
+ run.eventRange.startSeq,
307
+ run.eventRange.endSeq,
308
+ )
309
+ : [];
296
310
  expect(
297
- run?.events.find((event) => event.type === "model/request"),
311
+ events.find((event) => event.type === "model/request"),
298
312
  ).toMatchObject({
299
313
  request: {
300
314
  provider: "ollama-cloud",
@@ -594,7 +608,7 @@ describe("Bot recovery", () => {
594
608
  schemaVersion: 1,
595
609
  runs: [
596
610
  expect.objectContaining({
597
- schemaVersion: 2,
611
+ schemaVersion: 3,
598
612
  runId: "run-1",
599
613
  status: "completed",
600
614
  outcome: { type: "completed", text: "Durable reply" },
@@ -91,6 +91,7 @@ function settleBotTurn(
91
91
  events.some(
92
92
  (event) =>
93
93
  (event.type === "assistant/message" ||
94
+ event.type === "model/response-failed" ||
94
95
  event.type === "model/effect-not-started") &&
95
96
  event.requestId === latestRequest.request.requestId,
96
97
  );
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,
@@ -46,12 +47,13 @@ import {
46
47
  ACTIVE_RUN_KEY,
47
48
  BotDurableAuthority,
48
49
  IDENTITY_KEY,
49
- LATEST_EVENTS_KEY,
50
50
  NOTIFICATION_PREFIX,
51
51
  RECOVERY_ALARM_DELAY_MS,
52
52
  RUN_ADMISSION_FENCE_PREFIX,
53
53
  RUN_INDEX_PREFIX,
54
54
  RUN_PREFIX,
55
+ SessionEventLog,
56
+ storedRunRecordV2,
55
57
  CONVERSATION_BUSY_MESSAGE_V1,
56
58
  isConversationBusyV1,
57
59
  type BotIdentity,
@@ -133,6 +135,7 @@ import {
133
135
  import {
134
136
  createAppletCapabilityHostV1,
135
137
  createAppletInstanceBindingV1,
138
+ APPLET_DIST_FILES_V1,
136
139
  appletRpcSnapshotV1 as rpcJsonSnapshotV1,
137
140
  resolveAppletCompositionV1,
138
141
  type AppletCapabilityHostV1,
@@ -252,6 +255,7 @@ import {
252
255
  decodeSubagentSlotReceiptV1,
253
256
  type SubagentSlotBinding,
254
257
  } from "@frockbot/plugin-subagents/quota";
258
+ import { decodeAgentTurnSlotReceiptV1 } from "@frockbot/plugin-flock/quota";
255
259
  import {
256
260
  subagentModelCatalogV1,
257
261
  type SubagentModelOptionV1,
@@ -405,6 +409,7 @@ import {
405
409
  decodeClientRunLookupQueryV1,
406
410
  decodeClientRunListQueryV1,
407
411
  decodeClientRunStopCommandV1,
412
+ decodeClientTurnV1,
408
413
  isVisibleRunV1,
409
414
  projectClientRunLookupV1,
410
415
  projectClientRunV1,
@@ -666,6 +671,16 @@ export interface ShellBotBackendHost {
666
671
  scheduledWorkInFlight?(): boolean;
667
672
  deferScheduledWork?(transaction: DurableObjectTransaction): Promise<void>;
668
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>;
669
684
  }
670
685
 
671
686
  /** The narrow storage seam the Bot's announcement log is written through. */
@@ -768,6 +783,7 @@ export class ShellBotBackendContribution {
768
783
  private readonly hostScheduledWorkInFlight?: ShellBotBackendHost["scheduledWorkInFlight"];
769
784
  private readonly hostDeferScheduledWork?: ShellBotBackendHost["deferScheduledWork"];
770
785
  private readonly hostSettleScheduledWork?: ShellBotBackendHost["settleScheduledWork"];
786
+ private readonly recordSettledUsage?: ShellBotBackendHost["recordSettledUsage"];
771
787
 
772
788
  constructor(host: ShellBotBackendHost) {
773
789
  this.ctx = host.state;
@@ -783,6 +799,7 @@ export class ShellBotBackendContribution {
783
799
  this.hostScheduledWorkInFlight = host.scheduledWorkInFlight;
784
800
  this.hostDeferScheduledWork = host.deferScheduledWork;
785
801
  this.hostSettleScheduledWork = host.settleScheduledWork;
802
+ this.recordSettledUsage = host.recordSettledUsage;
786
803
  const routines = createBotRoutines(
787
804
  host.state.storage,
788
805
  createBotRoutineHookMinter(
@@ -1156,8 +1173,10 @@ export class ShellBotBackendContribution {
1156
1173
  const announcements = [...stored.entries()]
1157
1174
  .sort(([left], [right]) => left.localeCompare(right))
1158
1175
  .map(([, value]) => decodeSessionEvent(value));
1159
- const session =
1160
- (await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
1176
+ const sessionId = await this.authority.readConversationSessionId();
1177
+ const session = sessionId
1178
+ ? await this.authority.readSessionEvents(sessionId)
1179
+ : [];
1161
1180
  for (const event of session) {
1162
1181
  if (event.type === "conversation/compacted") announcements.push(event);
1163
1182
  }
@@ -1171,8 +1190,10 @@ export class ShellBotBackendContribution {
1171
1190
  * timestamp of the place it belongs rather than the moment it was written.
1172
1191
  */
1173
1192
  private async projectAnnouncementPage() {
1174
- const session =
1175
- (await this.ctx.storage.get<SessionEvent[]>(LATEST_EVENTS_KEY)) ?? [];
1193
+ const sessionId = await this.authority.readConversationSessionId();
1194
+ const session = sessionId
1195
+ ? await this.authority.readSessionEvents(sessionId)
1196
+ : [];
1176
1197
  return projectClientAnnouncementsV1(
1177
1198
  await this.listAnnouncements(),
1178
1199
  session,
@@ -1640,6 +1661,9 @@ export class ShellBotBackendContribution {
1640
1661
  // One admitted Turn is one run; the Turn ordinal lives in the session log.
1641
1662
  turnId: input.command.runId,
1642
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,
1643
1667
  // The pin this Turn was admitted under, and the type it was admitted as.
1644
1668
  // A subagent dispatched from here runs on this generation, and the model
1645
1669
  // catalog it is offered is narrowed by this turn type.
@@ -1655,6 +1679,17 @@ export class ShellBotBackendContribution {
1655
1679
  ...(input.command.origin?.kind === "subagent"
1656
1680
  ? { subagentTaskId: input.command.origin.taskId }
1657
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
+ : {}),
1658
1693
  };
1659
1694
  let mountedRoot: ShellMountedComposition["root"] | undefined;
1660
1695
  let mountedGeneration: CompositionGenerationV1 | undefined;
@@ -1724,6 +1759,16 @@ export class ShellBotBackendContribution {
1724
1759
  input.command.sessionId,
1725
1760
  effect,
1726
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
+ : {}),
1727
1772
  ...(isolate ? { isolate } : {}),
1728
1773
  ...(appletRouting ? { applets: appletRouting } : {}),
1729
1774
  }).mount(mounting, signal);
@@ -2409,7 +2454,7 @@ export class ShellBotBackendContribution {
2409
2454
  // User with no Computer assignment has no root to pull, and the Bot that
2410
2455
  // just built on its Computer has it open already.
2411
2456
  syncSourceRootNow: active
2412
- ? async () => {
2457
+ ? async (appletId) => {
2413
2458
  const root = active.mounted.runtime.root as unknown as {
2414
2459
  computers?: ComputerRegistry;
2415
2460
  sessions: typeof active.mounted.runtime.root.sessions;
@@ -2432,6 +2477,9 @@ export class ShellBotBackendContribution {
2432
2477
  sessionId: active.sessionId,
2433
2478
  turn,
2434
2479
  root: appletsSourceRootV1(identity.userId),
2480
+ requiredPaths: APPLET_DIST_FILES_V1.map(
2481
+ (path) => `${appletId}/${path}`,
2482
+ ),
2435
2483
  signal: active.signal,
2436
2484
  });
2437
2485
  }
@@ -3246,6 +3294,22 @@ export class ShellBotBackendContribution {
3246
3294
  };
3247
3295
  }
3248
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
+
3249
3313
  /**
3250
3314
  * The User-wide `desktop-gui` lease, held at the Computer host.
3251
3315
  *
@@ -4328,6 +4392,7 @@ export class ShellBotBackendContribution {
4328
4392
  runId: string;
4329
4393
  turnId: string;
4330
4394
  sessionId: string;
4395
+ fromBotName: string;
4331
4396
  /**
4332
4397
  * The generation this Turn pinned, and the type it was admitted as. A
4333
4398
  * dispatched subagent runs on the generation its parent pinned, and the
@@ -4517,6 +4582,39 @@ export class ShellBotBackendContribution {
4517
4582
  this.userConfiguration(identity).listBots(userId),
4518
4583
  createBot: (userId, command) =>
4519
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
+ },
4520
4618
  }),
4521
4619
  }
4522
4620
  : {}),
@@ -5951,10 +6049,13 @@ export class ShellBotBackendContribution {
5951
6049
  let budget = BOT_DEBUG_EVENT_BYTES_V1;
5952
6050
  const runs: BotDebugRunV1[] = [];
5953
6051
  for (const candidate of candidates) {
5954
- const stored = await this.authority.readStoredRun(candidate.runId);
5955
- if (!stored) continue;
6052
+ const projected = await this.authority.readRunEventProjections(
6053
+ candidate.runId,
6054
+ );
6055
+ if (!projected) continue;
6056
+ const { run: stored } = projected;
5956
6057
  const bounded = includeEvents
5957
- ? boundDebugEventsV1(stored.events, budget)
6058
+ ? boundDebugEventsV1(projected.events, budget)
5958
6059
  : undefined;
5959
6060
  if (bounded) budget = Math.max(0, budget - bounded.spent);
5960
6061
  runs.push({
@@ -5967,7 +6068,7 @@ export class ShellBotBackendContribution {
5967
6068
  commandFingerprint: stored.commandFingerprint,
5968
6069
  compositionGenerationId: stored.compositionGenerationId,
5969
6070
  previousEventCount: stored.previousEventCount,
5970
- eventCount: stored.events.length,
6071
+ eventCount: projected.eventCount,
5971
6072
  ...(stored.responseText === undefined
5972
6073
  ? {}
5973
6074
  : { responseText: stored.responseText }),
@@ -6328,7 +6429,24 @@ export class ShellBotBackendContribution {
6328
6429
  transaction.get<BotIdentity>(IDENTITY_KEY),
6329
6430
  transaction.get<unknown>(`${RUN_PREFIX}${runId}`),
6330
6431
  ]);
6331
- const run = optionalStoredRun(candidate);
6432
+ const storedRun = optionalStoredRun(candidate);
6433
+ let run = storedRun;
6434
+ if (storedRun?.eventRange) {
6435
+ const events = await new SessionEventLog(transaction).readRange(
6436
+ storedRun.sessionId,
6437
+ storedRun.eventRange.startSeq,
6438
+ storedRun.eventRange.endSeq,
6439
+ );
6440
+ if (
6441
+ events.length !==
6442
+ storedRun.eventRange.endSeq - storedRun.eventRange.startSeq
6443
+ ) {
6444
+ throw new Error(
6445
+ `run "${storedRun.runId}" has an incomplete event range`,
6446
+ );
6447
+ }
6448
+ run = requireStoredRunV1({ ...storedRun, events });
6449
+ }
6332
6450
  if (
6333
6451
  activeRunId !== runId ||
6334
6452
  !run ||
@@ -6388,7 +6506,10 @@ export class ShellBotBackendContribution {
6388
6506
  { kind: effect.kind, effectId: effect.effectId, outcome },
6389
6507
  ],
6390
6508
  } satisfies StoredRun);
6391
- await transaction.put(`${RUN_PREFIX}${runId}`, structuredClone(next));
6509
+ await transaction.put(
6510
+ `${RUN_PREFIX}${runId}`,
6511
+ structuredClone(storedRunRecordV2(next)),
6512
+ );
6392
6513
  return outcome === "admitted";
6393
6514
  });
6394
6515
  }
@@ -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,