@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
3
+ import { MODEL_FIRST_BYTE_DEADLINE_REASON_V1 } from "@frockbot/kernel-contracts";
4
+ import {
5
+ knownFailureCopyV1,
6
+ RUN_FAILURE_COPY_V1,
7
+ RUN_FAILURE_FALLBACK_COPY_V1,
8
+ runFailureCopyV1,
9
+ USER_FACING_FAILURE_REASONS_V1,
10
+ } from "./run-failure-copy.js";
11
+ import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
12
+ import type { StoredRun } from "./backend-contracts.js";
13
+ import { projectClientRunV1 } from "./run-protocol.js";
14
+
15
+ /**
16
+ * Words that describe the machine. Every one of them reached a chat bubble
17
+ * before this: the verification run read "Reconciliation was explicitly
18
+ * abandoned: Bot turn ended with outcome model-error: Flock AI keeps no durable
19
+ * copy of an interrupted response, so it cannot be recovered".
20
+ */
21
+ const FORBIDDEN_V1 = [
22
+ "reconcil",
23
+ "outcome",
24
+ "durable",
25
+ "supersede",
26
+ "admission",
27
+ "provider",
28
+ "model-error",
29
+ "tool-error",
30
+ "turn/end",
31
+ "session event",
32
+ "run id",
33
+ "runid",
34
+ ];
35
+
36
+ function assertPlainV1(copy: string): void {
37
+ const lowered = copy.toLowerCase();
38
+ for (const word of FORBIDDEN_V1) {
39
+ expect(lowered.includes(word)).toBe(false);
40
+ }
41
+ // A bare " run " is jargon; "running" and the like are not, so the check is
42
+ // on the word rather than the substring.
43
+ expect(/\brun(s|id)?\b/.test(lowered)).toBe(false);
44
+ }
45
+
46
+ const TIMESTAMP = "2026-09-04T00:00:00.000Z";
47
+ let seq = 0;
48
+ const turnEnd = (outcome: string) =>
49
+ ({
50
+ type: "turn/end",
51
+ turn: 1,
52
+ outcome,
53
+ seq: (seq += 1),
54
+ timestamp: TIMESTAMP,
55
+ }) as unknown as SessionEvent;
56
+
57
+ function failedRun(failure: string, events: SessionEvent[]): StoredRun {
58
+ return {
59
+ runId: "run-1",
60
+ commandFingerprint: "fingerprint",
61
+ sessionId: "user:primary",
62
+ acceptedAt: TIMESTAMP,
63
+ input: "make me an applet",
64
+ events,
65
+ effectAdmissions: [],
66
+ status: "failed",
67
+ phase: "executing",
68
+ compositionGenerationId: "test-composition-generation",
69
+ configurationSnapshot: initializeBotSettingsV1("primary"),
70
+ previousEventCount: 0,
71
+ failure,
72
+ };
73
+ }
74
+
75
+ describe("runFailureCopyV1", () => {
76
+ test("every mapped sentence is written for a person", () => {
77
+ for (const copy of Object.values(RUN_FAILURE_COPY_V1)) assertPlainV1(copy);
78
+ assertPlainV1(RUN_FAILURE_FALLBACK_COPY_V1);
79
+ for (const reason of USER_FACING_FAILURE_REASONS_V1) assertPlainV1(reason);
80
+ });
81
+
82
+ test("the stored diagnostic never reaches the copy", () => {
83
+ const failure =
84
+ "Reconciliation was explicitly abandoned: Bot turn ended with outcome model-error: Flock AI keeps no durable copy of an interrupted response, so it cannot be recovered";
85
+ const copy = runFailureCopyV1({
86
+ failure,
87
+ events: [turnEnd("interrupted")],
88
+ });
89
+ expect(copy).toBe(RUN_FAILURE_COPY_V1.interrupted);
90
+ assertPlainV1(copy);
91
+ });
92
+
93
+ test("a kernel sentence written for a person survives its wrapper", () => {
94
+ const copy = runFailureCopyV1({
95
+ failure: `Model request "abc" has no durable provider outcome: Model response outcome is uncertain: ${MODEL_FIRST_BYTE_DEADLINE_REASON_V1}`,
96
+ events: [turnEnd("interrupted")],
97
+ });
98
+ expect(copy).toBe(MODEL_FIRST_BYTE_DEADLINE_REASON_V1);
99
+ });
100
+
101
+ test("a Turn with no terminal event still says something plain", () => {
102
+ expect(runFailureCopyV1({ failure: "boom" })).toBe(
103
+ RUN_FAILURE_FALLBACK_COPY_V1,
104
+ );
105
+ });
106
+
107
+ // The client's own guard, because a `ClientRun` can arrive from an older
108
+ // backend that forwarded the raw diagnostic, and the thread must not render
109
+ // a provider's words as though the Bot said them.
110
+ test("the thread accepts only sentences the product wrote", () => {
111
+ for (const written of [
112
+ ...Object.values(RUN_FAILURE_COPY_V1),
113
+ ...USER_FACING_FAILURE_REASONS_V1,
114
+ ]) {
115
+ expect(knownFailureCopyV1(written)).toBe(written);
116
+ }
117
+ for (const diagnostic of [
118
+ undefined,
119
+ "",
120
+ "Provider reconciliation is required",
121
+ "Bot turn ended with outcome model-error: Model request failed (401)",
122
+ 'Skill "bot/no-such-skill" is unknown',
123
+ ]) {
124
+ const copy = knownFailureCopyV1(diagnostic);
125
+ expect(copy).toBe(RUN_FAILURE_FALLBACK_COPY_V1);
126
+ assertPlainV1(copy);
127
+ }
128
+ });
129
+
130
+ test("the projection sends the copy, not the diagnostic", () => {
131
+ const projected = projectClientRunV1(
132
+ failedRun(
133
+ "Reconciliation was explicitly abandoned: Bot turn ended with outcome model-error",
134
+ [
135
+ {
136
+ type: "turn/start",
137
+ turn: 1,
138
+ seq: (seq += 1),
139
+ timestamp: TIMESTAMP,
140
+ } as unknown as SessionEvent,
141
+ turnEnd("interrupted"),
142
+ ],
143
+ ),
144
+ );
145
+ expect(projected.outcome?.type).toBe("failed");
146
+ if (projected.outcome?.type !== "failed") throw new Error("unreachable");
147
+ assertPlainV1(projected.outcome.message);
148
+ expect(projected.outcome.message).toBe(RUN_FAILURE_COPY_V1.interrupted);
149
+ });
150
+ });
@@ -0,0 +1,110 @@
1
+ import type { SessionEvent, TurnOutcome } from "@frockbot/kernel-contracts";
2
+ import {
3
+ MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
4
+ MODEL_IDLE_DEADLINE_REASON_V1,
5
+ } from "@frockbot/kernel-contracts";
6
+ import { TURN_DEADLINE_REASON_V1 } from "@frockbot/kernel-agent-loop";
7
+ import { UNRECONCILABLE_RUN_FAILURE_V1 } from "@frockbot/kernel-do";
8
+
9
+ /**
10
+ * The one place a Turn that did not finish is turned into a sentence for the
11
+ * person who was waiting on it.
12
+ *
13
+ * A run's stored `failure` is a diagnostic. It is composed by whoever settled
14
+ * the run, out of whatever the layer below handed up, and it reads like it:
15
+ * "Reconciliation was explicitly abandoned: Bot turn ended with outcome
16
+ * model-error: Flock AI keeps no durable copy of an interrupted response, so it
17
+ * cannot be recovered". Every word of that is useful — on the debug surface,
18
+ * where it stays, unchanged. None of it belongs in a chat bubble. A person
19
+ * asked for a countdown applet; they should not have to learn what
20
+ * reconciliation is to find out that the model gave up.
21
+ *
22
+ * So the projection stops forwarding the diagnostic and picks the sentence
23
+ * instead. Two inputs, in order:
24
+ *
25
+ * 1. The kernel's own user-facing reasons. A handful of failures already have
26
+ * a sentence written for a person — the model deadlines, the Turn deadline,
27
+ * the unretrievable settlement — and those say something the outcome alone
28
+ * cannot, so a stored failure that carries one hands it straight through.
29
+ * 2. Otherwise the Turn's terminal outcome, which is a closed set, mapped
30
+ * below. It says less, and it can never leak.
31
+ *
32
+ * The diagnostic is never the answer, not even as a fallback: an unmapped
33
+ * outcome gets the generic line rather than whatever prose happened to be
34
+ * stored.
35
+ */
36
+
37
+ /**
38
+ * Sentences the kernel writes for the person rather than for the log. They are
39
+ * matched as substrings because the layer that settles a run wraps the reason
40
+ * it was handed — the sentence survives the wrapping, the wrapper does not.
41
+ */
42
+ export const USER_FACING_FAILURE_REASONS_V1: readonly string[] = [
43
+ MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
44
+ MODEL_IDLE_DEADLINE_REASON_V1,
45
+ TURN_DEADLINE_REASON_V1,
46
+ UNRECONCILABLE_RUN_FAILURE_V1,
47
+ ];
48
+
49
+ /**
50
+ * What each terminal outcome says. Total over `TurnOutcome` so adding one to
51
+ * the kernel's union is a type error here rather than a silent generic line.
52
+ */
53
+ export const RUN_FAILURE_COPY_V1: Record<TurnOutcome, string> = {
54
+ completed: "This Bot couldn't finish its reply. Try again.",
55
+ blocked: "This Bot wouldn't do that. Try asking a different way.",
56
+ cancelled: "You stopped this.",
57
+ interrupted: "This reply stopped before it finished. Try again.",
58
+ "model-error": "The model couldn't finish its reply. Try again.",
59
+ "tool-error": "Something the Bot was using didn't work. Try again.",
60
+ };
61
+
62
+ /** What a Turn says when nothing more specific is known about how it ended. */
63
+ export const RUN_FAILURE_FALLBACK_COPY_V1 =
64
+ "This Bot couldn't finish its reply. Try again.";
65
+
66
+ /** The outcome the run's own log records, or `undefined` on an unclosed Turn. */
67
+ function terminalTurnOutcomeV1(
68
+ events: readonly SessionEvent[],
69
+ ): TurnOutcome | undefined {
70
+ const terminal = events.findLast((event) => event.type === "turn/end");
71
+ return terminal?.type === "turn/end" ? terminal.outcome : undefined;
72
+ }
73
+
74
+ export function runFailureCopyV1(input: {
75
+ failure?: string;
76
+ events?: readonly SessionEvent[];
77
+ }): string {
78
+ const failure = input.failure ?? "";
79
+ const written = USER_FACING_FAILURE_REASONS_V1.find((reason) =>
80
+ failure.includes(reason),
81
+ );
82
+ if (written) return written;
83
+ const outcome = terminalTurnOutcomeV1(input.events ?? []);
84
+ return outcome ? RUN_FAILURE_COPY_V1[outcome] : RUN_FAILURE_FALLBACK_COPY_V1;
85
+ }
86
+
87
+ /**
88
+ * The product's own sentences, as a set a client can check a string against.
89
+ *
90
+ * The projection maps every failure through {@link runFailureCopyV1} on the way
91
+ * to the wire, so what reaches a client is already copy. The client still must
92
+ * not *trust* that: a `ClientRun` can arrive from an older backend that
93
+ * forwarded the raw diagnostic, and a provider's words under a bubble read as
94
+ * part of what the Bot was saying. So the thread accepts a failure only when it
95
+ * recognises it as something the product wrote, and otherwise says the generic
96
+ * line — which keeps the specific sentences (the model deadlines say something
97
+ * the outcome alone cannot) without ever letting an unknown string through.
98
+ */
99
+ const KNOWN_FAILURE_COPY_V1 = new Set<string>([
100
+ ...Object.values(RUN_FAILURE_COPY_V1),
101
+ ...USER_FACING_FAILURE_REASONS_V1,
102
+ RUN_FAILURE_FALLBACK_COPY_V1,
103
+ ]);
104
+
105
+ /** The failure if the product wrote it, else the line every failure can use. */
106
+ export function knownFailureCopyV1(failure: string | undefined): string {
107
+ return failure && KNOWN_FAILURE_COPY_V1.has(failure)
108
+ ? failure
109
+ : RUN_FAILURE_FALLBACK_COPY_V1;
110
+ }
@@ -3,6 +3,7 @@ import { type SessionEvent } from "@frockbot/kernel-contracts";
3
3
  import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
4
4
  import type { StoredRun } from "./backend-contracts.js";
5
5
  import { planBotRunRecovery } from "./backend-recovery.js";
6
+ import { RUN_FAILURE_COPY_V1 } from "./run-failure-copy.js";
6
7
  import {
7
8
  createClientRunStopReceiptV1,
8
9
  decodeClientNotificationAcknowledgementCommandV1,
@@ -10,6 +11,7 @@ import {
10
11
  decodeClientRunLookupQueryV1,
11
12
  decodeClientRunReconciliationCommandV1,
12
13
  decodeClientRunStopCommandV1,
14
+ RESUMABLE_RUN_MESSAGE_V1,
13
15
  decodeClientRunStopReceiptV1,
14
16
  decodeClientTurnCommandV1,
15
17
  decodeClientRunLookupV1,
@@ -285,7 +287,7 @@ describe("client run protocol v1", () => {
285
287
  stopRequestedAt: "2026-08-28T00:00:05.000Z",
286
288
  outcome: {
287
289
  type: "cancelled",
288
- message: "Stopped by an authenticated Stop command.",
290
+ message: "You stopped this.",
289
291
  },
290
292
  });
291
293
  expect(projectClientRunLookupV1(storedRun([], "cancelled"))).toMatchObject({
@@ -297,7 +299,7 @@ describe("client run protocol v1", () => {
297
299
  ).run,
298
300
  ).toMatchObject({
299
301
  status: "cancelled",
300
- failure: "Stopped by an authenticated Stop command.",
302
+ failure: "You stopped this.",
301
303
  });
302
304
 
303
305
  expect(() =>
@@ -745,7 +747,7 @@ describe("client run protocol v1", () => {
745
747
  ],
746
748
  recovery: {
747
749
  action: "resume",
748
- message: "Provider confirmation required",
750
+ message: RESUMABLE_RUN_MESSAGE_V1,
749
751
  },
750
752
  },
751
753
  ],
@@ -758,10 +760,10 @@ describe("client run protocol v1", () => {
758
760
  input: "continue",
759
761
  status: "reconciliation-required",
760
762
  events: projected.runs[0]?.events,
761
- failure: "Provider confirmation required",
763
+ failure: RESUMABLE_RUN_MESSAGE_V1,
762
764
  recovery: {
763
765
  action: "resume",
764
- message: "Provider confirmation required",
766
+ message: RESUMABLE_RUN_MESSAGE_V1,
765
767
  },
766
768
  },
767
769
  ]);
@@ -1278,14 +1280,18 @@ describe("client run protocol v1", () => {
1278
1280
  events,
1279
1281
  failure: plan.failure,
1280
1282
  });
1283
+ // The provider's own words — its name, its status code — stay on the
1284
+ // stored record, which is what the debug surface reads. What crosses to a
1285
+ // chat bubble is the sentence for the outcome, and nothing else.
1281
1286
  expect(decodeClientRunLookupV1(structuredClone(lookup))).toMatchObject({
1282
1287
  state: "terminal",
1283
1288
  run: {
1284
1289
  status: "failed",
1285
- failure:
1286
- "Bot turn ended with outcome model-error: Ollama Cloud responded 401: invalid api key",
1290
+ failure: RUN_FAILURE_COPY_V1["model-error"],
1287
1291
  },
1288
1292
  });
1293
+ expect(JSON.stringify(lookup)).not.toContain("Ollama Cloud");
1294
+ expect(JSON.stringify(lookup)).not.toContain("model-error");
1289
1295
  });
1290
1296
 
1291
1297
  test("carries rename announcements beside the Turns, and refuses a bad one", () => {
@@ -1335,6 +1341,43 @@ describe("client run protocol v1", () => {
1335
1341
  }),
1336
1342
  ).toThrow("run list.announcement.at is invalid");
1337
1343
  });
1344
+
1345
+ test("announces a compaction without putting its summary on the wire", () => {
1346
+ const announcements = projectClientAnnouncementsV1([
1347
+ { type: "turn/start", seq: 0, timestamp, turn: 1 },
1348
+ {
1349
+ type: "conversation/compacted",
1350
+ seq: 4,
1351
+ timestamp,
1352
+ effectId: "compaction-1",
1353
+ fromTurn: 1,
1354
+ throughTurn: 6,
1355
+ summary: "## Summary\nsomething private to the model",
1356
+ identifiers: ["applet-9f2c"],
1357
+ provider: "ollama-cloud",
1358
+ model: "kimi-k2",
1359
+ },
1360
+ ]);
1361
+ expect(announcements).toEqual([
1362
+ {
1363
+ type: "conversation/compacted",
1364
+ announcementId: "compaction-4",
1365
+ at: timestamp,
1366
+ throughTurn: 6,
1367
+ },
1368
+ ]);
1369
+ const page = createClientRunListV1([], { truncated: false }, announcements);
1370
+ expect(JSON.stringify(page)).not.toContain("something private");
1371
+ expect(decodeClientRunPageV1(structuredClone(page)).announcements).toEqual(
1372
+ announcements,
1373
+ );
1374
+ expect(() =>
1375
+ decodeClientRunPageV1({
1376
+ ...page,
1377
+ announcements: [{ ...announcements[0], throughTurn: 0 }],
1378
+ }),
1379
+ ).toThrow("run list.announcement.throughTurn is invalid");
1380
+ });
1338
1381
  });
1339
1382
 
1340
1383
  describe("dispatched subagents in the run projection", () => {
@@ -24,6 +24,7 @@ import {
24
24
  type BotTurnCompletion,
25
25
  type StoredRun,
26
26
  } from "./backend-contracts.js";
27
+ import { runFailureCopyV1 } from "./run-failure-copy.js";
27
28
 
28
29
  const MAX_RUN_ID_LENGTH = 128;
29
30
  const MAX_TIMESTAMP_LENGTH = 64;
@@ -54,8 +55,15 @@ export type ClientRunStatusV1 =
54
55
  | "superseded"
55
56
  | "reconciliation-required";
56
57
 
57
- const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
58
+ // Both are sentences for the person, not descriptions of the mechanism: the
59
+ // wire outcome is what a client with no copy of its own renders verbatim, and
60
+ // "Stopped by an authenticated Stop command" told somebody who pressed Stop
61
+ // about the authentication of their own button press.
62
+ const CANCELLED_RUN_MESSAGE = "You stopped this.";
58
63
  const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
64
+ /** What a Turn waiting on a person's "Try again" says while it waits. */
65
+ export const RESUMABLE_RUN_MESSAGE_V1 =
66
+ "This reply stopped partway. Try again to continue it.";
59
67
 
60
68
  /**
61
69
  * Why the Bot declined to admit a Turn. A refusal is an ordinary answer — the
@@ -244,14 +252,30 @@ export interface ClientRunPageV1 {
244
252
  * A durable Session event that belongs to no Turn. The WebUI renders it as a
245
253
  * system line in the conversation.
246
254
  */
247
- export interface ClientAnnouncementV1 {
248
- type: "bot/renamed";
249
- announcementId: string;
250
- at: string;
251
- from: string;
252
- to: string;
253
- namedBy: "user" | "bot";
254
- }
255
+ /**
256
+ * A session-level line in the transcript that belongs to neither party.
257
+ *
258
+ * `conversation/compacted` is ADR 0030's one user-visible surface: the earlier
259
+ * Turns are still there and still readable, and this says plainly that the
260
+ * model now carries a summary of them instead of the Turns themselves. ADR
261
+ * 0027's "not summarised" notice still stands where Turns were genuinely
262
+ * evicted, so the two never claim each other's ground.
263
+ */
264
+ export type ClientAnnouncementV1 =
265
+ | {
266
+ type: "bot/renamed";
267
+ announcementId: string;
268
+ at: string;
269
+ from: string;
270
+ to: string;
271
+ namedBy: "user" | "bot";
272
+ }
273
+ | {
274
+ type: "conversation/compacted";
275
+ announcementId: string;
276
+ at: string;
277
+ throughTurn: number;
278
+ };
255
279
 
256
280
  export interface ClientRunListV1 {
257
281
  schemaVersion: 1;
@@ -286,6 +310,19 @@ export interface ClientConversationListV1 {
286
310
  conversations: ClientConversationV1[];
287
311
  }
288
312
 
313
+ /**
314
+ * The answer to "start a new conversation": the list, or the reason not now.
315
+ *
316
+ * A refusal is a value, not an exception. The request crosses a Durable Object
317
+ * boundary and a Worker boundary to get here, and an exception that crosses
318
+ * either is logged by workerd as `Uncaught Error` — the log then showed the
319
+ * isolate going down with a broken pipe behind it. Carrying the refusal as
320
+ * data means the only thing that reaches the client is the 409 it expects.
321
+ */
322
+ export type ClientConversationOutcomeV1 =
323
+ | ({ status: "started" } & ClientConversationListV1)
324
+ | { status: "refused"; schemaVersion: 1; reason: string };
325
+
289
326
  export function decodeClientConversationListV1(
290
327
  input: unknown,
291
328
  ): ClientConversationListV1 {
@@ -807,8 +844,11 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
807
844
  : status === "failed"
808
845
  ? ({
809
846
  type: "failed",
847
+ // The stored `failure` is a diagnostic and stays one: it is what
848
+ // the debug surface reads. What crosses to a chat bubble is the
849
+ // sentence written for the person — see `runFailureCopyV1`.
810
850
  message: truncateWireString(
811
- run.failure ?? "Agent request failed.",
851
+ runFailureCopyV1({ failure: run.failure, events: run.events }),
812
852
  MAX_FAILURE_BYTES,
813
853
  ),
814
854
  ...interruptedOutcomeTextV1(run),
@@ -830,11 +870,10 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
830
870
  status === "reconciliation-required"
831
871
  ? ({
832
872
  action: "resume",
833
- message: truncateWireString(
834
- run.failure ??
835
- "Provider reconciliation is required before this Turn can continue.",
836
- MAX_FAILURE_BYTES,
837
- ),
873
+ // The stored failure is the diagnostic the debug surface reads; a
874
+ // person offered a "Try again" needs the sentence, not the reason
875
+ // the Bot cannot answer it on its own.
876
+ message: RESUMABLE_RUN_MESSAGE_V1,
838
877
  } satisfies ClientRunRecoveryV1)
839
878
  : undefined;
840
879
  return {
@@ -983,24 +1022,74 @@ export function createClientRunListV1(
983
1022
  const MAX_ANNOUNCEMENTS = 64;
984
1023
  const MAX_ANNOUNCEMENT_NAME_BYTES = 400;
985
1024
 
986
- /** Projects the Bot's durable announcement events onto the wire. */
1025
+ /**
1026
+ * Where each Turn ended, by Turn number.
1027
+ *
1028
+ * A compaction is written at the end of the Turn that crossed the threshold,
1029
+ * which is the *newest* Turn — so its own timestamp would place its marker at
1030
+ * the bottom of the thread, far from the range it describes. The boundary it
1031
+ * actually names is the end of `throughTurn`, and that is what the marker is
1032
+ * dated with.
1033
+ */
1034
+ function turnEndTimestampsV1(
1035
+ session: readonly SessionEvent[],
1036
+ ): Map<number, string> {
1037
+ const ends = new Map<number, string>();
1038
+ for (const event of session) {
1039
+ if (event.type === "turn/end") ends.set(event.turn, event.timestamp);
1040
+ }
1041
+ return ends;
1042
+ }
1043
+
1044
+ /**
1045
+ * Projects the Bot's durable announcement events onto the wire.
1046
+ *
1047
+ * `session` is the conversation's own log, used only to date a compaction
1048
+ * marker at the boundary it covers. Omitting it dates the marker by when the
1049
+ * compaction was written, which is where it used to sit.
1050
+ */
987
1051
  export function projectClientAnnouncementsV1(
988
1052
  events: readonly SessionEvent[],
1053
+ session: readonly SessionEvent[] = events,
989
1054
  ): ClientAnnouncementV1[] {
990
- return events.flatMap((event) =>
991
- event.type === "bot/renamed"
992
- ? [
993
- {
994
- type: "bot/renamed" as const,
995
- announcementId: `announcement-${event.seq}`,
996
- at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
997
- from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
998
- to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
999
- namedBy: event.namedBy,
1000
- },
1001
- ]
1002
- : [],
1003
- );
1055
+ const turnEnds = turnEndTimestampsV1(session);
1056
+ return events.flatMap((event): ClientAnnouncementV1[] => {
1057
+ if (event.type === "bot/renamed") {
1058
+ return [
1059
+ {
1060
+ type: "bot/renamed" as const,
1061
+ announcementId: `announcement-${event.seq}`,
1062
+ at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
1063
+ from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
1064
+ to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
1065
+ namedBy: event.namedBy,
1066
+ },
1067
+ ];
1068
+ }
1069
+ if (event.type === "conversation/compacted") {
1070
+ // The summary itself is deliberately not on the wire. A person can read
1071
+ // every Turn it covers, unchanged, immediately above this line; the
1072
+ // summary is what the model carries, and it belongs to the audit view.
1073
+ return [
1074
+ {
1075
+ type: "conversation/compacted" as const,
1076
+ // A distinct prefix: a compaction is numbered by the session log and
1077
+ // a rename by this Bot's announcement log, and the two counters would
1078
+ // otherwise collide on an id the client upserts by.
1079
+ announcementId: `compaction-${event.seq}`,
1080
+ // Dated where the covered range ends, not when the summariser ran,
1081
+ // so the marker sits between the last compacted Turn and the first
1082
+ // verbatim one and stays there as newer Turns arrive.
1083
+ at: truncate(
1084
+ turnEnds.get(event.throughTurn) ?? event.timestamp,
1085
+ MAX_TIMESTAMP_LENGTH,
1086
+ ),
1087
+ throughTurn: event.throughTurn,
1088
+ },
1089
+ ];
1090
+ }
1091
+ return [];
1092
+ });
1004
1093
  }
1005
1094
 
1006
1095
  export function clientRunListWireBytes(value: ClientRunListV1): number {
@@ -1828,14 +1917,19 @@ export function decodeClientRunLookupV1(input: unknown): ClientRunLookup {
1828
1917
 
1829
1918
  function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
1830
1919
  const announcement = record(value, "run list.announcement");
1920
+ if (
1921
+ announcement.type !== "bot/renamed" &&
1922
+ announcement.type !== "conversation/compacted"
1923
+ ) {
1924
+ throw new Error("run list.announcement.type is invalid");
1925
+ }
1831
1926
  exactKeys(
1832
1927
  announcement,
1833
- ["type", "announcementId", "at", "from", "to", "namedBy"],
1928
+ announcement.type === "conversation/compacted"
1929
+ ? ["type", "announcementId", "at", "throughTurn"]
1930
+ : ["type", "announcementId", "at", "from", "to", "namedBy"],
1834
1931
  "run list.announcement",
1835
1932
  );
1836
- if (announcement.type !== "bot/renamed") {
1837
- throw new Error("run list.announcement.type is invalid");
1838
- }
1839
1933
  const at = string(
1840
1934
  announcement,
1841
1935
  "at",
@@ -1845,20 +1939,35 @@ function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
1845
1939
  if (!Number.isFinite(Date.parse(at))) {
1846
1940
  throw new Error("run list.announcement.at is invalid");
1847
1941
  }
1942
+ const announcementId = publicEventId(
1943
+ string(
1944
+ announcement,
1945
+ "announcementId",
1946
+ MAX_EVENT_ID_LENGTH,
1947
+ "run list.announcement",
1948
+ ),
1949
+ "run list.announcement.announcementId",
1950
+ );
1951
+ if (announcement.type === "conversation/compacted") {
1952
+ if (
1953
+ !Number.isSafeInteger(announcement.throughTurn) ||
1954
+ (announcement.throughTurn as number) < 1
1955
+ ) {
1956
+ throw new Error("run list.announcement.throughTurn is invalid");
1957
+ }
1958
+ return {
1959
+ type: "conversation/compacted",
1960
+ announcementId,
1961
+ at,
1962
+ throughTurn: announcement.throughTurn as number,
1963
+ };
1964
+ }
1848
1965
  if (announcement.namedBy !== "user" && announcement.namedBy !== "bot") {
1849
1966
  throw new Error("run list.announcement.namedBy is invalid");
1850
1967
  }
1851
1968
  return {
1852
1969
  type: "bot/renamed",
1853
- announcementId: publicEventId(
1854
- string(
1855
- announcement,
1856
- "announcementId",
1857
- MAX_EVENT_ID_LENGTH,
1858
- "run list.announcement",
1859
- ),
1860
- "run list.announcement.announcementId",
1861
- ),
1970
+ announcementId,
1862
1971
  at,
1863
1972
  from: wireString(
1864
1973
  announcement,
@@ -62,10 +62,16 @@ describe("settings link scheme", () => {
62
62
  });
63
63
  });
64
64
 
65
- test("drops a fragment belonging to another surface", () => {
65
+ test("a fragment belonging to another surface opens that surface", () => {
66
+ // The row is the specific request; the query parameter is where the link
67
+ // was written from, and rows move between surfaces.
66
68
  expect(
67
69
  decodeSettingsLinkV1("/?settings=bot-settings#bot-info-computer"),
68
- ).toEqual({ surface: "bot-settings" });
70
+ ).toEqual({ surface: "bot-panel", anchor: "bot-info-computer" });
71
+ expect(decodeSettingsLinkV1("/?settings=bot-panel#bot-routines")).toEqual({
72
+ surface: "bot-settings",
73
+ anchor: "bot-routines",
74
+ });
69
75
  });
70
76
 
71
77
  test("drops an anchor nobody registered", () => {