@frockbot/plugin-shell 0.3.16 → 0.3.18

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/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
+ });
@@ -7,7 +7,10 @@ import {
7
7
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
8
8
  MODEL_IDLE_DEADLINE_REASON_V1,
9
9
  } from "@frockbot/kernel-contracts";
10
- import { TURN_DEADLINE_REASON_V1 } from "@frockbot/kernel-agent-loop";
10
+ import {
11
+ STEP_LIMIT_REASON_V1,
12
+ TURN_DEADLINE_REASON_V1,
13
+ } from "@frockbot/kernel-agent-loop";
11
14
  import { UNRECONCILABLE_RUN_FAILURE_V1 } from "@frockbot/kernel-do";
12
15
 
13
16
  /**
@@ -47,6 +50,7 @@ export const USER_FACING_FAILURE_REASONS_V1: readonly string[] = [
47
50
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
48
51
  MODEL_IDLE_DEADLINE_REASON_V1,
49
52
  TURN_DEADLINE_REASON_V1,
53
+ STEP_LIMIT_REASON_V1,
50
54
  UNRECONCILABLE_RUN_FAILURE_V1,
51
55
  ];
52
56
 
@@ -24,6 +24,7 @@ import {
24
24
  projectClientRunLookupV1,
25
25
  projectClientRunListV1,
26
26
  projectClientRunV1,
27
+ isVisibleRunV1,
27
28
  projectClientRunOrDegradedV1,
28
29
  projectClientTurnV1,
29
30
  UNRECORDED_TOOL_RESULT_TEXT_V1,
@@ -88,6 +89,37 @@ function storedRun(
88
89
  }
89
90
 
90
91
  describe("client run protocol v1", () => {
92
+ test("projects an agent Turn with its Bot origin marker", () => {
93
+ const agent = {
94
+ ...storedRun([]),
95
+ admission: {
96
+ schemaVersion: 1 as const,
97
+ turnType: "agent" as const,
98
+ origin: {
99
+ kind: "bot" as const,
100
+ fromBotId: "researcher",
101
+ fromBotName: "Researcher",
102
+ messageId: "message-1",
103
+ },
104
+ },
105
+ };
106
+
107
+ expect(isVisibleRunV1(agent)).toBe(true);
108
+ const projected = projectClientRunV1(agent);
109
+ expect(projected).toMatchObject({
110
+ schemaVersion: 3,
111
+ input: "continue",
112
+ via: { kind: "bot", name: "Researcher", botId: "researcher" },
113
+ });
114
+ expect(
115
+ decodeClientRunListV1({
116
+ schemaVersion: 1,
117
+ runs: [projected],
118
+ page: { truncated: false },
119
+ })[0]?.via,
120
+ ).toEqual(projected.via);
121
+ });
122
+
91
123
  test("rejects durable runs missing current admission fields", () => {
92
124
  const complete = storedRun([], "running");
93
125
  for (const field of [
@@ -728,7 +760,7 @@ describe("client run protocol v1", () => {
728
760
  schemaVersion: 1,
729
761
  runs: [
730
762
  {
731
- schemaVersion: 2,
763
+ schemaVersion: 3,
732
764
  runId: "run-1",
733
765
  admittedAt: timestamp,
734
766
  input: "continue",
@@ -977,9 +1009,8 @@ describe("client run protocol v1", () => {
977
1009
  ]),
978
1010
  );
979
1011
 
980
- // Version 2 is what carries the two new event types; a client pinned to 1
981
- // still decodes the body it produces.
982
- expect(projected.schemaVersion).toBe(2);
1012
+ // Version 3 carries agent-origin markers; older bodies still decode.
1013
+ expect(projected.schemaVersion).toBe(3);
983
1014
  expect(projected.events).toEqual([
984
1015
  { type: "send/to-user", payload: { type: "text", text: "On it." } },
985
1016
  { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
@@ -1614,3 +1645,41 @@ describe("dispatched subagents in the run projection", () => {
1614
1645
  );
1615
1646
  });
1616
1647
  });
1648
+
1649
+ describe("Computer sync degradation in the run projection", () => {
1650
+ const degraded: SessionEvent = {
1651
+ type: "computer/sync",
1652
+ seq: 4,
1653
+ timestamp,
1654
+ turn: 1,
1655
+ reason: "open",
1656
+ status: "degraded",
1657
+ detail: "Excluded 2 reproducible Workspace items from sync.",
1658
+ pulled: 0,
1659
+ pushed: 1,
1660
+ restored: 0,
1661
+ removed: 0,
1662
+ adopted: 0,
1663
+ conflicts: 0,
1664
+ failures: 0,
1665
+ ignored: 2,
1666
+ omitted: 0,
1667
+ };
1668
+
1669
+ test("projects one plain-language event and round-trips it", () => {
1670
+ const projected = projectClientRunV1(
1671
+ storedRun([degraded, { ...degraded, seq: 5, reason: "turn-end" }]),
1672
+ );
1673
+ expect(projected.events).toEqual([
1674
+ {
1675
+ type: "computer/sync",
1676
+ status: "degraded",
1677
+ message: "Excluded 2 reproducible Workspace items from sync.",
1678
+ },
1679
+ ]);
1680
+ const page = createClientRunListV1([projected], { truncated: false });
1681
+ expect(
1682
+ decodeClientRunPageV1(structuredClone(page)).runs[0]?.events,
1683
+ ).toEqual(projected.events);
1684
+ });
1685
+ });
@@ -165,6 +165,11 @@ export type ClientRunEventV1 =
165
165
  type: "wake/parent";
166
166
  message: string;
167
167
  }
168
+ | {
169
+ type: "computer/sync";
170
+ status: "degraded" | "unavailable" | "refused" | "skipped";
171
+ message: string;
172
+ }
168
173
  /**
169
174
  * A subagent this Turn dispatched (ADR 0017). The child's Session never
170
175
  * enters the visible transcript, so this chip is the whole of what the
@@ -212,12 +217,12 @@ export interface ClientRunRecoveryV1 {
212
217
  }
213
218
 
214
219
  /**
215
- * The run projection. Version 2 adds `send/to-user` and `wake/parent` to
216
- * `events`; a version 1 body is a version 2 body that carries neither, so the
217
- * decoder accepts both and the projection emits 2.
220
+ * The run projection. Version 2 added structured `send/to-user` and
221
+ * `wake/parent` events; version 3 adds the bounded `via` marker for agent and
222
+ * Voice Turns without exposing the internal origin record.
218
223
  */
219
224
  export interface ClientRunV1 {
220
- schemaVersion: 1 | 2;
225
+ schemaVersion: 1 | 2 | 3;
221
226
  runId: string;
222
227
  admittedAt: string;
223
228
  input: string;
@@ -241,6 +246,10 @@ export interface ClientRunV1 {
241
246
  partialText?: string;
242
247
  outcome?: ClientRunOutcomeV1;
243
248
  recovery?: ClientRunRecoveryV1;
249
+ /** Where an agent-lane question entered this Bot's transcript. */
250
+ via?:
251
+ | { kind: "bot"; name: string; botId: string }
252
+ | { kind: "voice"; name: "Voice" };
244
253
  }
245
254
 
246
255
  export interface ClientRunPageV1 {
@@ -624,6 +633,7 @@ function projectionUnits(
624
633
  const units: ProjectionUnitV1[] = [];
625
634
  const byOccurrence = new Map<string, ProjectionUnitV1>();
626
635
  let callCount = 0;
636
+ let projectedIncompleteSync = false;
627
637
  for (const event of events) {
628
638
  if (event.type === "tool/call") {
629
639
  if (byOccurrence.has(event.occurrenceId)) {
@@ -694,6 +704,22 @@ function projectionUnits(
694
704
  ],
695
705
  droppable: true,
696
706
  });
707
+ } else if (
708
+ event.type === "computer/sync" &&
709
+ event.status !== "ok" &&
710
+ !projectedIncompleteSync
711
+ ) {
712
+ projectedIncompleteSync = true;
713
+ units.push({
714
+ events: [
715
+ {
716
+ type: "computer/sync",
717
+ status: event.status,
718
+ message: computerSyncCopyV1(event),
719
+ },
720
+ ],
721
+ droppable: true,
722
+ });
697
723
  } else if (event.type === "task/dispatched") {
698
724
  units.push({
699
725
  events: [
@@ -736,6 +762,24 @@ function projectionUnits(
736
762
  return units;
737
763
  }
738
764
 
765
+ function computerSyncCopyV1(
766
+ event: Extract<SessionEvent, { type: "computer/sync" }>,
767
+ ): string {
768
+ if (event.status === "degraded") {
769
+ return (
770
+ event.detail.trim() ||
771
+ "Some Workspace files did not sync during this turn."
772
+ );
773
+ }
774
+ if (event.status === "unavailable") {
775
+ return "Workspace files could not sync during this turn. Sync will retry the next time the Computer is used.";
776
+ }
777
+ if (event.status === "refused") {
778
+ return "Workspace sync could not run for this turn.";
779
+ }
780
+ return "Workspace sync was skipped for this turn.";
781
+ }
782
+
739
783
  function visibleEvents(
740
784
  events: readonly SessionEvent[],
741
785
  status: ClientRunStatusV1,
@@ -876,10 +920,20 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
876
920
  message: RESUMABLE_RUN_MESSAGE_V1,
877
921
  } satisfies ClientRunRecoveryV1)
878
922
  : undefined;
923
+ const origin = run.admission?.origin;
924
+ const via =
925
+ origin?.kind === "bot"
926
+ ? {
927
+ kind: "bot" as const,
928
+ name: truncateWireString(origin.fromBotName, 100),
929
+ botId: truncate(origin.fromBotId, 128),
930
+ }
931
+ : origin?.kind === "voice"
932
+ ? { kind: "voice" as const, name: "Voice" as const }
933
+ : undefined;
879
934
  return {
880
- // Version 2: the projection may now carry `send/to-user` and
881
- // `wake/parent`. A client pinned to 1 keeps decoding a stored list.
882
- schemaVersion: 2,
935
+ // Version 3 adds the origin marker for an agent-lane message.
936
+ schemaVersion: 3,
883
937
  runId: truncate(run.runId, MAX_RUN_ID_LENGTH),
884
938
  admittedAt: truncate(run.acceptedAt, MAX_TIMESTAMP_LENGTH),
885
939
  input: truncateWireString(run.input, MAX_INPUT_BYTES),
@@ -896,6 +950,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
896
950
  : {}),
897
951
  ...(outcome ? { outcome } : {}),
898
952
  ...(recovery ? { recovery } : {}),
953
+ ...(via ? { via } : {}),
899
954
  };
900
955
  }
901
956
 
@@ -921,7 +976,8 @@ function lookupState(
921
976
  export function isVisibleRunV1(run: {
922
977
  admission?: { turnType?: string };
923
978
  }): boolean {
924
- return (run.admission?.turnType ?? "chat") === "chat";
979
+ const type = run.admission?.turnType ?? "chat";
980
+ return type === "chat" || type === "agent";
925
981
  }
926
982
 
927
983
  export function projectClientRunLookupV1(
@@ -1248,6 +1304,27 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
1248
1304
  ),
1249
1305
  };
1250
1306
  }
1307
+ if (event.type === "computer/sync") {
1308
+ exactKeys(event, ["type", "status", "message"], "run event");
1309
+ if (
1310
+ event.status !== "degraded" &&
1311
+ event.status !== "unavailable" &&
1312
+ event.status !== "refused" &&
1313
+ event.status !== "skipped"
1314
+ ) {
1315
+ throw new Error("run event.status is invalid");
1316
+ }
1317
+ return {
1318
+ type: "computer/sync",
1319
+ status: event.status,
1320
+ message: wireString(
1321
+ event,
1322
+ "message",
1323
+ MAX_EVENT_CONTENT_BYTES,
1324
+ "run event",
1325
+ ),
1326
+ };
1327
+ }
1251
1328
  if (event.type === "task/dispatched") {
1252
1329
  exactKeys(
1253
1330
  event,
@@ -1299,12 +1376,13 @@ function decodeEvents(values: unknown[]): ClientTurnEvent[] {
1299
1376
  const callIds = new Set<string>();
1300
1377
  while (index < events.length) {
1301
1378
  const call = events[index];
1302
- // A send and a hand-off stand alone: they pair with nothing, so the
1303
- // call/result walk steps straight over them.
1379
+ // Sends, hand-offs, task chips, and sync notices stand alone: they pair
1380
+ // with nothing, so the call/result walk steps straight over them.
1304
1381
  if (
1305
1382
  call?.type === "send/to-user" ||
1306
1383
  call?.type === "wake/parent" ||
1307
- call?.type === "task/dispatched"
1384
+ call?.type === "task/dispatched" ||
1385
+ call?.type === "computer/sync"
1308
1386
  ) {
1309
1387
  index += 1;
1310
1388
  continue;
@@ -1429,14 +1507,41 @@ function decodeRun(value: unknown): ClientRun {
1429
1507
  "partialText",
1430
1508
  "outcome",
1431
1509
  "recovery",
1510
+ "via",
1432
1511
  ],
1433
1512
  "run",
1434
1513
  );
1435
1514
  // 1 and 2 differ only by the event types a version 2 body may carry, and a
1436
1515
  // version 1 body carries a subset of them, so one walk decodes both.
1437
- if (run.schemaVersion !== 1 && run.schemaVersion !== 2) {
1516
+ if (
1517
+ run.schemaVersion !== 1 &&
1518
+ run.schemaVersion !== 2 &&
1519
+ run.schemaVersion !== 3
1520
+ ) {
1438
1521
  throw new Error("run.schemaVersion is invalid");
1439
1522
  }
1523
+ let via: ClientRunV1["via"];
1524
+ if (run.via !== undefined) {
1525
+ if (run.schemaVersion !== 3) {
1526
+ throw new Error("run.via requires schemaVersion 3");
1527
+ }
1528
+ const candidate = record(run.via, "run.via");
1529
+ if (candidate.kind === "bot") {
1530
+ exactKeys(candidate, ["kind", "name", "botId"], "run.via");
1531
+ via = {
1532
+ kind: "bot",
1533
+ name: wireString(candidate, "name", 100, "run.via"),
1534
+ botId: string(candidate, "botId", 128, "run.via"),
1535
+ };
1536
+ } else if (candidate.kind === "voice") {
1537
+ exactKeys(candidate, ["kind", "name"], "run.via");
1538
+ if (candidate.name !== "Voice")
1539
+ throw new Error("run.via.name is invalid");
1540
+ via = { kind: "voice", name: "Voice" };
1541
+ } else {
1542
+ throw new Error("run.via.kind is invalid");
1543
+ }
1544
+ }
1440
1545
  let runId: string;
1441
1546
  try {
1442
1547
  runId = decodeRunIdV1(string(run, "runId", MAX_RUN_ID_LENGTH, "run"));
@@ -1507,6 +1612,7 @@ function decodeRun(value: unknown): ClientRun {
1507
1612
  }
1508
1613
  : {}),
1509
1614
  ...(recovery ? { failure: recovery.message, recovery } : {}),
1615
+ ...(via ? { via } : {}),
1510
1616
  };
1511
1617
  }
1512
1618
 
package/src/shared.ts CHANGED
@@ -98,6 +98,9 @@ export interface WebChatMessage {
98
98
  | "error"
99
99
  | "interrupted"
100
100
  | "reconciliation-required";
101
+ via?:
102
+ | { kind: "bot"; name: string; botId: string }
103
+ | { kind: "voice"; name: "Voice" };
101
104
  /**
102
105
  * True while this line's Turn is admitted but has not started, because the
103
106
  * User sent it while the Bot was still on the previous one. The thread