@frockbot/plugin-shell 0.3.12 → 0.3.14

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.
@@ -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", () => {
@@ -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
@@ -836,8 +844,11 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
836
844
  : status === "failed"
837
845
  ? ({
838
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`.
839
850
  message: truncateWireString(
840
- run.failure ?? "Agent request failed.",
851
+ runFailureCopyV1({ failure: run.failure, events: run.events }),
841
852
  MAX_FAILURE_BYTES,
842
853
  ),
843
854
  ...interruptedOutcomeTextV1(run),
@@ -859,11 +870,10 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
859
870
  status === "reconciliation-required"
860
871
  ? ({
861
872
  action: "resume",
862
- message: truncateWireString(
863
- run.failure ??
864
- "Provider reconciliation is required before this Turn can continue.",
865
- MAX_FAILURE_BYTES,
866
- ),
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,
867
877
  } satisfies ClientRunRecoveryV1)
868
878
  : undefined;
869
879
  return {
package/src/shared.ts CHANGED
@@ -22,6 +22,10 @@ import type {
22
22
  SendToUserPayloadV1,
23
23
  SkillRefV1,
24
24
  } from "@frockbot/kernel-contracts";
25
+ import type {
26
+ VoiceDictationObserverV1,
27
+ VoiceDictationSessionV1,
28
+ } from "@frockbot/client-core";
25
29
  import type { McpServerStatusViewV1 } from "@frockbot/plugin-mcp/records";
26
30
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
27
31
  import type { ClientSkillCatalogEntryV1 } from "./skill-protocol.js";
@@ -256,6 +260,12 @@ export interface FrockBotWebData {
256
260
  modelSource: "bot" | "default" | "none";
257
261
  settingsAvailable: boolean;
258
262
  connectionsAvailable: boolean;
263
+ /**
264
+ * False when this platform cannot dictate — no transport socket, or a
265
+ * browser with no microphone API. The composer's send button then never
266
+ * changes shape and nothing about it moves.
267
+ */
268
+ voiceAvailable: boolean;
259
269
  activeBotId?: string;
260
270
  composerContext?: unknown;
261
271
  messages: WebChatMessage[];
@@ -511,6 +521,13 @@ export interface FrockBotWebData {
511
521
  skills?: readonly SkillRefV1[],
512
522
  ): Promise<SendPromptResult>;
513
523
  resumeRun(runId: string): Promise<void>;
524
+ /**
525
+ * Opens one dictation session (voice plan D2). `undefined` on a platform
526
+ * whose transport cannot, which is what `voiceAvailable` reports up front.
527
+ */
528
+ openVoiceDictation(
529
+ observer: VoiceDictationObserverV1,
530
+ ): VoiceDictationSessionV1 | undefined;
514
531
  /** Sends the durable Stop command for the observed active run. */
515
532
  stopRun(): Promise<void>;
516
533
  /** Detaches the local observer only; admitted work stays durable. */