@frockbot/plugin-shell 0.0.0 → 0.1.0

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 (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
package/src/shared.ts ADDED
@@ -0,0 +1,338 @@
1
+ export { decodeExternalAuthorizationUrl } from "@frockbot/protocol";
2
+
3
+ import type {
4
+ JsonValue,
5
+ BotNameProvenanceV1,
6
+ BotNotificationPolicy,
7
+ BotProfile,
8
+ BotProfilePatchV1,
9
+ BotSettingsViewV1,
10
+ CapabilityAssignmentView,
11
+ ModelAssignment,
12
+ UserSettingsViewV1,
13
+ } from "@frockbot/configuration-core";
14
+ import type {
15
+ CatalogEntryV1,
16
+ CatalogIndexEntryV1,
17
+ } from "@frockbot/catalog-core";
18
+ import type {
19
+ SendToUserPayloadV1,
20
+ SkillRefV1,
21
+ } from "@frockbot/kernel-contracts";
22
+ import type { McpServerStatusViewV1 } from "@frockbot/plugin-mcp/records";
23
+ import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
24
+ import type { ClientSkillCatalogEntryV1 } from "./skill-protocol.js";
25
+ import type { ApprovalCardViewV1 } from "./approvals.js";
26
+ import type { TaskViewV1 } from "@frockbot/plugin-subagents/shared";
27
+ import type { InjectionKey, Ref } from "vue";
28
+
29
+ export type { CatalogEntryV1, CatalogIndexEntryV1 };
30
+
31
+ export type WebConnection = "starting" | "ready" | "disconnected" | "error";
32
+
33
+ /** One binary a tool filed, as the thread draws it: a reference, not bytes. */
34
+ export interface WebToolAttachment {
35
+ kind: "image";
36
+ mediaType: string;
37
+ contentHash: string;
38
+ /** The encoded `WorkspacePathV1` the Workspace read route takes. */
39
+ path: string;
40
+ }
41
+
42
+ export interface WebToolActivity {
43
+ id: string;
44
+ name: string;
45
+ status: "running" | "completed" | "failed";
46
+ text?: string;
47
+ attachments?: WebToolAttachment[];
48
+ }
49
+
50
+ /**
51
+ * One user-facing send, as the thread draws it. An `unsupported` entry is a
52
+ * payload this client cannot draw — a newer payload shape, or a malformed one.
53
+ * The thread says so rather than throwing, because a Turn's history has to
54
+ * render on a client older than the Bot that produced it.
55
+ */
56
+ export type WebSendPayload =
57
+ { kind: "payload"; payload: SendToUserPayloadV1 } | { kind: "unsupported" };
58
+
59
+ /**
60
+ * One dispatched subagent, as the thread draws it.
61
+ *
62
+ * The chip carries what the durable dispatch said; its live status and summary
63
+ * are read from {@link FrockBotWebData.tasks}, because a background task
64
+ * settles long after the Turn that dispatched it and the run's own events
65
+ * never change again.
66
+ */
67
+ export interface WebTaskChip {
68
+ taskId: string;
69
+ taskType: string;
70
+ description: string;
71
+ model: string;
72
+ background: boolean;
73
+ }
74
+
75
+ export interface WebChatMessage {
76
+ id: string;
77
+ runId: string;
78
+ /**
79
+ * `system` is the Session speaking rather than either party: a rename
80
+ * announcement, for instance. It carries no avatar and no tools.
81
+ */
82
+ role: "user" | "assistant" | "system";
83
+ text: string;
84
+ /** When the line happened, so system lines sort into the conversation. */
85
+ at?: string;
86
+ status:
87
+ | "streaming"
88
+ | "completed"
89
+ | "aborted"
90
+ | "error"
91
+ | "interrupted"
92
+ | "reconciliation-required";
93
+ tools: WebToolActivity[];
94
+ /** The typed payloads this Turn sent to the user, oldest first. */
95
+ sends: WebSendPayload[];
96
+ /**
97
+ * The subagents this Turn dispatched, oldest first. Optional so every
98
+ * existing message literal — and every stored projection — still reads.
99
+ */
100
+ tasks?: WebTaskChip[];
101
+ }
102
+
103
+ export interface WebActiveRun {
104
+ runId: string;
105
+ status: "running" | "interrupted" | "reconciliation-required";
106
+ message: string;
107
+ canResume: boolean;
108
+ }
109
+
110
+ export interface SendPromptResult {
111
+ accepted: boolean;
112
+ runId?: string;
113
+ error?: string;
114
+ }
115
+
116
+ export interface PluginCatalogItem {
117
+ packageId: string;
118
+ displayName: string;
119
+ version: string;
120
+ capabilities: Array<{
121
+ id: string;
122
+ kind: "model" | "tool" | "memory" | "notification" | "computer";
123
+ connectionTypes: string[];
124
+ }>;
125
+ connectionTypes: Array<{
126
+ id: string;
127
+ displayName: string;
128
+ allowMultiple: boolean;
129
+ authorizationKind: "none" | "api-key" | "ambient-native" | "grant";
130
+ capabilities: string[];
131
+ }>;
132
+ /**
133
+ * The Package-level settings this Package declares at User scope — the form
134
+ * the Plugins surface generates for it. Connection-scoped settings are not
135
+ * here: they belong to one Connection and are edited with it.
136
+ *
137
+ * Optional, and read as `[]` when absent: the decoder always fills it, so
138
+ * absence means a catalog payload projected before this field existed, and a
139
+ * Package with no declared settings is the same thing as one whose settings
140
+ * a client cannot see — no form.
141
+ */
142
+ settings?: PackageSettingDefinition[];
143
+ }
144
+
145
+ export interface FrockBotWebData {
146
+ connection: WebConnection;
147
+ modelLabel: string;
148
+ modelReady: boolean;
149
+ /**
150
+ * Where the effective model comes from: the Bot's own override, the User's
151
+ * default, or nothing at all.
152
+ */
153
+ modelSource: "bot" | "default" | "none";
154
+ settingsAvailable: boolean;
155
+ connectionsAvailable: boolean;
156
+ activeBotId?: string;
157
+ composerContext?: unknown;
158
+ messages: WebChatMessage[];
159
+ activeRunId?: string;
160
+ activeRun?: WebActiveRun;
161
+ error?: string;
162
+ botSettings?: BotSettingsViewV1;
163
+ userSettings?: UserSettingsViewV1;
164
+ pluginCatalog: PluginCatalogItem[];
165
+ /**
166
+ * The remote Catalog index, read through `/catalog/v1/index`. Separate from
167
+ * `pluginCatalog`, which projects the compiled-in application manifest: the
168
+ * two answer different questions — what this deployment can execute, and what
169
+ * the Catalog offers to install.
170
+ */
171
+ packageCatalog: CatalogIndexEntryV1[];
172
+ /** The generation `packageCatalog` was read from; every install names it. */
173
+ packageCatalogGeneration?: string;
174
+ /**
175
+ * The Bot's invocable Skills, for the composer's `/` and `@` popover. Refs,
176
+ * names and descriptions — never a body.
177
+ */
178
+ skillCatalog: ClientSkillCatalogEntryV1[];
179
+ /**
180
+ * The Bot's approval cards, newest first — pending and already decided
181
+ * alike, so the card in the transcript can say what was decided rather than
182
+ * going quiet the moment somebody answers it. Loaded for the selected Bot.
183
+ */
184
+ approvals: ApprovalCardViewV1[];
185
+ /**
186
+ * The Bot's subagent tasks, newest first. The chip in the transcript is the
187
+ * durable dispatch; this is what it currently *is* — status, summary, and
188
+ * failure — so a chip drawn before the child settled says so afterwards
189
+ * without the run's own events being rewritten.
190
+ */
191
+ tasks: TaskViewV1[];
192
+ /**
193
+ * The User's MCP servers: state, tool count, last handshake, instructions,
194
+ * failure, and the durable refusal ledger. Absent until it is loaded, and
195
+ * absent for a deployment with no MCP route — the Plugins surface then
196
+ * shows the servers as Connections and nothing more.
197
+ */
198
+ mcpServers?: McpServerStatusViewV1;
199
+ settingsError?: string;
200
+ selectBot(botId: string): Promise<void>;
201
+ loadBotSettings(): Promise<void>;
202
+ saveBotProfile(profile: BotProfile): Promise<void>;
203
+ /** Partial profile update: only the fields it carries change. */
204
+ setBotProfile(
205
+ profile: BotProfilePatchV1,
206
+ namedBy?: BotNameProvenanceV1,
207
+ ): Promise<void>;
208
+ saveBotNotifications(notifications: BotNotificationPolicy): Promise<void>;
209
+ assignCapability(
210
+ assignment: Omit<CapabilityAssignmentView, "state">,
211
+ ): Promise<void>;
212
+ replaceCapability(
213
+ assignment: Omit<CapabilityAssignmentView, "state">,
214
+ ): Promise<void>;
215
+ unassignCapability(assignmentId: string): Promise<void>;
216
+ saveBotModel(model: ModelAssignment): Promise<void>;
217
+ clearBotModel(): Promise<void>;
218
+ loadUserSettings(): Promise<void>;
219
+ saveUserProfile(profile: { name: string; email?: string }): Promise<void>;
220
+ /** The model every Bot uses unless it overrides it. */
221
+ saveDefaultModel(model: ModelAssignment | undefined): Promise<void>;
222
+ loadPluginCatalog(): Promise<void>;
223
+ /** Refreshes {@link FrockBotWebData.mcpServers}. */
224
+ loadMcpServers(): Promise<void>;
225
+ /**
226
+ * The instructions attached to one MCP server, which become the description
227
+ * its tools carry in the next Turn's model request. An empty string clears
228
+ * them.
229
+ */
230
+ setMcpInstructions(serverId: string, instructions: string): Promise<void>;
231
+ /**
232
+ * Restarts one MCP server: its epoch is bumped, so the next admitted Turn
233
+ * re-handshakes and re-lists its tools.
234
+ */
235
+ restartMcpServer(serverId: string): Promise<void>;
236
+ /**
237
+ * Connect or reconnect an OAuth MCP server, returning the host-authored
238
+ * redirect the User is about to follow. `connectionId` reconnects an
239
+ * existing Connection — the connect card's *Reconnect* — and its absence
240
+ * creates one from `settings`.
241
+ */
242
+ startMcpAuthorization(input: {
243
+ connectionId?: string;
244
+ label?: string;
245
+ settings?: Record<string, unknown>;
246
+ }): Promise<string | undefined>;
247
+ loadPackageCatalog(): Promise<void>;
248
+ /** One entry detail, for the panel a User opens before installing. */
249
+ loadCatalogEntry(catalogId: string): Promise<CatalogEntryV1 | undefined>;
250
+ /** Refreshes {@link FrockBotWebData.skillCatalog} for the active Bot. */
251
+ loadSkillCatalog(): Promise<void>;
252
+ /** Refreshes {@link FrockBotWebData.approvals} for the active Bot. */
253
+ loadApprovals(): Promise<void>;
254
+ /** Refreshes {@link FrockBotWebData.tasks} for the active Bot. */
255
+ loadTasks(): Promise<void>;
256
+ /**
257
+ * Cancels one subagent, explicitly and with the User's authentication. The
258
+ * backend is the authority: the task this replaces in the list is the record
259
+ * it answered with, never what the click assumed.
260
+ */
261
+ stopTask(taskId: string): Promise<void>;
262
+ /**
263
+ * Records one decision on one approval card. The backend is the authority:
264
+ * this submits the command and re-reads what was recorded, so a card already
265
+ * answered elsewhere shows that answer rather than this client's guess.
266
+ */
267
+ decideApproval(
268
+ approvalId: string,
269
+ decision: "approved" | "denied",
270
+ ): Promise<void>;
271
+ installPackage(packageId: string, version: string): Promise<void>;
272
+ /**
273
+ * Enables or disables one installed Package for this User. Enablement is the
274
+ * whole of what the Plugins surface does: a disabled Package keeps its
275
+ * installation, its settings, and its Connections, and stops being available
276
+ * to any Bot until it is enabled again.
277
+ */
278
+ setPackageEnabled(packageId: string, enabled: boolean): Promise<void>;
279
+ /**
280
+ * A partial update of one installed Package's setting values. Only the ids
281
+ * it carries change; the rest keep the values they had.
282
+ */
283
+ savePackageSettings(
284
+ packageId: string,
285
+ values: Record<string, string | number | boolean>,
286
+ ): Promise<void>;
287
+ installCatalogPackage(
288
+ entry: CatalogIndexEntryV1,
289
+ /** The entry's `setupFields`, as the User filled them in. */
290
+ values?: Record<string, JsonValue>,
291
+ ): Promise<void>;
292
+ uninstallPackage(packageId: string): Promise<void>;
293
+ startConnection(
294
+ packageId: string,
295
+ connectionTypeId: string,
296
+ ): Promise<string | undefined>;
297
+ openConnectionAuthorization(url: string): Promise<void>;
298
+ revokeConnection(packageId: string, connectionId: string): Promise<void>;
299
+ createApiKeyConnection(input: {
300
+ packageId: string;
301
+ connectionTypeId: string;
302
+ label: string;
303
+ apiKey: string;
304
+ /** Connection-scoped settings the Connection Type's manifest declares. */
305
+ settings?: Record<string, string | number | boolean | null>;
306
+ }): Promise<void>;
307
+ /**
308
+ * A Connection of a Connection Type whose authorization kind is `none`: it
309
+ * has no credential, so its settings are the whole of its configuration.
310
+ */
311
+ createConnection(input: {
312
+ packageId: string;
313
+ connectionTypeId: string;
314
+ label: string;
315
+ settings?: Record<string, string | number | boolean | null>;
316
+ }): Promise<void>;
317
+ rotateApiKeyConnection(connectionId: string, apiKey: string): Promise<void>;
318
+ updateConnectionLabel(connectionId: string, label: string): Promise<void>;
319
+ refreshConnectionModels(connectionId: string): Promise<void>;
320
+ setConnectionEnabled(connectionId: string, enabled: boolean): Promise<void>;
321
+ disconnectConnection(
322
+ connectionId: string,
323
+ revokeUpstream?: boolean,
324
+ ): Promise<void>;
325
+ /** `skills` are the refs the composer attached; absent means none. */
326
+ sendPrompt(
327
+ text: string,
328
+ skills?: readonly SkillRefV1[],
329
+ ): Promise<SendPromptResult>;
330
+ resumeRun(runId: string): Promise<void>;
331
+ /** Sends the durable Stop command for the observed active run. */
332
+ stopRun(): Promise<void>;
333
+ /** Detaches the local observer only; admitted work stays durable. */
334
+ abort(): Promise<void>;
335
+ }
336
+
337
+ export const frockBotWebDataKey: InjectionKey<Ref<FrockBotWebData>> =
338
+ Symbol("frockbot-web-data");
@@ -0,0 +1,117 @@
1
+ // The Skill catalog the composer's `/` and `@` popover reads.
2
+ //
3
+ // The Bot already loads its catalog once per admitted Turn and records it in
4
+ // `skill/injected`, but a composer needs the list *before* the Turn exists, so
5
+ // this is a read of the same loader over the same instruction root, projected
6
+ // as a DTO. It carries a name, a description and a ref — never a body. A body
7
+ // reaches the model only by invocation or `skill_load`, so the client can show
8
+ // what a Skill is for without ever holding what it says.
9
+ //
10
+ // The hosted client renders backend state and submits commands. It does not
11
+ // become an alternate authority: nothing here is writable, and an entry's
12
+ // `ref` is the only part a turn command may echo back.
13
+ import {
14
+ decodeSkillRefV1,
15
+ formatSkillRefV1,
16
+ type SkillRefV1,
17
+ } from "@frockbot/kernel-contracts";
18
+
19
+ const MAX_CATALOG_ENTRIES = 200;
20
+ const MAX_NAME_LENGTH = 64;
21
+ const MAX_DESCRIPTION_LENGTH = 1_024;
22
+ const MAX_PATH_LENGTH = 512;
23
+
24
+ /** One invocable Skill, as a client sees it. */
25
+ export interface ClientSkillCatalogEntryV1 {
26
+ /** The canonical string form, for display and for stable list keys. */
27
+ ref: string;
28
+ /** The structured ref a turn command carries. */
29
+ skill: SkillRefV1;
30
+ name: string;
31
+ description: string;
32
+ /** Where the Skill lives, so a User can tell two same-named ones apart. */
33
+ path: string;
34
+ }
35
+
36
+ export interface ClientSkillCatalogV1 {
37
+ schemaVersion: 1;
38
+ skills: ClientSkillCatalogEntryV1[];
39
+ }
40
+
41
+ /** Builds one entry from a loaded Skill and the ref that names it. */
42
+ export function clientSkillCatalogEntryV1(input: {
43
+ skill: SkillRefV1;
44
+ name: string;
45
+ description: string;
46
+ path: string;
47
+ }): ClientSkillCatalogEntryV1 {
48
+ return {
49
+ ref: formatSkillRefV1(input.skill),
50
+ skill: input.skill,
51
+ name: input.name.slice(0, MAX_NAME_LENGTH),
52
+ description: input.description.slice(0, MAX_DESCRIPTION_LENGTH),
53
+ path: input.path.slice(0, MAX_PATH_LENGTH),
54
+ };
55
+ }
56
+
57
+ function record(value: unknown, label: string): Record<string, unknown> {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
59
+ throw new Error(`${label} must be an object`);
60
+ }
61
+ return value as Record<string, unknown>;
62
+ }
63
+
64
+ function boundedString(value: unknown, maximum: number, label: string): string {
65
+ if (typeof value !== "string" || value.length > maximum) {
66
+ throw new Error(`${label} must be a bounded string`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /** The strict decoder for the catalog crossing the client seam. */
72
+ export function decodeClientSkillCatalogV1(
73
+ input: unknown,
74
+ ): ClientSkillCatalogV1 {
75
+ const catalog = record(input, "skill catalog");
76
+ const allowed = new Set(["schemaVersion", "skills"]);
77
+ for (const key of Object.keys(catalog)) {
78
+ if (!allowed.has(key))
79
+ throw new Error(`skill catalog.${key} is not allowed`);
80
+ }
81
+ if (catalog.schemaVersion !== 1) {
82
+ throw new Error("skill catalog.schemaVersion is invalid");
83
+ }
84
+ if (!Array.isArray(catalog.skills)) {
85
+ throw new Error("skill catalog.skills must be an array");
86
+ }
87
+ if (catalog.skills.length > MAX_CATALOG_ENTRIES) {
88
+ throw new Error("skill catalog.skills is too long");
89
+ }
90
+ const skills = catalog.skills.map((value, index) => {
91
+ const label = `skill catalog.skills[${index}]`;
92
+ const entry = record(value, label);
93
+ const keys = new Set(["ref", "skill", "name", "description", "path"]);
94
+ for (const key of Object.keys(entry)) {
95
+ if (!keys.has(key)) throw new Error(`${label}.${key} is not allowed`);
96
+ }
97
+ const skill = decodeSkillRefV1(entry.skill, `${label}.skill`);
98
+ const ref = boundedString(entry.ref, 256, `${label}.ref`);
99
+ if (ref !== formatSkillRefV1(skill)) {
100
+ // The two forms are one fact. A projection whose halves disagree is a
101
+ // refusal, not a value the popover renders under the wrong name.
102
+ throw new Error(`${label}.ref does not match its skill`);
103
+ }
104
+ return {
105
+ ref,
106
+ skill,
107
+ name: boundedString(entry.name, MAX_NAME_LENGTH, `${label}.name`),
108
+ description: boundedString(
109
+ entry.description,
110
+ MAX_DESCRIPTION_LENGTH,
111
+ `${label}.description`,
112
+ ),
113
+ path: boundedString(entry.path, MAX_PATH_LENGTH, `${label}.path`),
114
+ } satisfies ClientSkillCatalogEntryV1;
115
+ });
116
+ return { schemaVersion: 1, skills };
117
+ }
@@ -0,0 +1,217 @@
1
+ // The one transaction that settles a Turn, and the three producers sharing it.
2
+ //
3
+ // The property under test is composition, not any producer's own rules: each
4
+ // producer runs exactly once per settlement, and no producer may silently
5
+ // overwrite another. A settlement that ran a producer twice would write two
6
+ // records where the Turn earned one — a firing with two inbox entries — and
7
+ // nobody would notice until they counted.
8
+ import { describe, expect, test } from "bun:test";
9
+ import { shellTerminalRecordsV1 } from "./terminal-records.js";
10
+ import { SIDEBAR_PREVIEW_KEY, UNREAD_STATE_KEY } from "./unread.js";
11
+ import { approvalKeyV1, decodeApprovalRecordV1 } from "./approvals.js";
12
+ import { decodeRoutineInboxEntryV1 } from "@frockbot/plugin-routines/inbox";
13
+ import {
14
+ ROUTINE_INBOX_PREFIX,
15
+ ROUTINE_WAKE_PREFIX,
16
+ } from "@frockbot/plugin-routines/storage-keys";
17
+
18
+ const NOW = "2026-09-01T00:00:00.000Z";
19
+ const CURSOR = "run-index:2026-09-01T00:00:00.000Z:run-1";
20
+
21
+ /** A durable store the settling transaction reads through. */
22
+ function store(initial: Record<string, unknown> = {}) {
23
+ const state = new Map(Object.entries(initial));
24
+ return {
25
+ state,
26
+ read: <T>(key: string) => Promise.resolve(state.get(key) as T | undefined),
27
+ /** Apply what the settlement returned, the way the kernel writes it. */
28
+ apply(records: Record<string, unknown>) {
29
+ for (const [key, value] of Object.entries(records)) state.set(key, value);
30
+ },
31
+ };
32
+ }
33
+
34
+ const APPROVAL_SEND = {
35
+ type: "send/to-user",
36
+ payload: {
37
+ type: "approval",
38
+ approvalId: "ap-1",
39
+ action: "Delete the staging database",
40
+ risk: "high",
41
+ },
42
+ } as const;
43
+
44
+ function keysUnder(records: Record<string, unknown>, prefix: string): string[] {
45
+ return Object.keys(records).filter((key) => key.startsWith(prefix));
46
+ }
47
+
48
+ describe("the settling transaction's records", () => {
49
+ test("a chat Turn that asked for approval writes unread and the decision, once each", async () => {
50
+ const durable = store();
51
+
52
+ const records = await shellTerminalRecordsV1({
53
+ run: {
54
+ runId: "run-1",
55
+ sessionId: "user-1:bot-1",
56
+ acceptedAt: NOW,
57
+ input: "Please delete it",
58
+ admission: { turnType: "chat" },
59
+ events: [{ type: "turn/start" }, APPROVAL_SEND],
60
+ },
61
+ cursor: CURSOR,
62
+ now: NOW,
63
+ read: durable.read,
64
+ });
65
+
66
+ expect(Object.keys(records).sort()).toEqual([
67
+ approvalKeyV1("ap-1"),
68
+ SIDEBAR_PREVIEW_KEY,
69
+ UNREAD_STATE_KEY,
70
+ ]);
71
+ expect(
72
+ decodeApprovalRecordV1(records[approvalKeyV1("ap-1")]),
73
+ ).toMatchObject({ decision: "pending", runId: "run-1", createdAt: NOW });
74
+ // One settlement, one instant: the unread record is stamped with the same
75
+ // `now` the approval is.
76
+ expect(records[UNREAD_STATE_KEY]).toMatchObject({
77
+ lastActivityCursor: CURSOR,
78
+ lastActivityAt: NOW,
79
+ });
80
+ expect(records[SIDEBAR_PREVIEW_KEY]).toEqual({
81
+ schemaVersion: 1,
82
+ text: "Please delete it",
83
+ at: NOW,
84
+ role: "user",
85
+ });
86
+ });
87
+
88
+ test("one automation Turn contributes exactly one inbox entry and one wake", async () => {
89
+ const durable = store({
90
+ "routine:brief": {
91
+ schemaVersion: 1,
92
+ routineId: "brief",
93
+ name: "Morning brief",
94
+ prompt: "look",
95
+ timezone: "UTC",
96
+ enabled: true,
97
+ createdBy: { kind: "user" },
98
+ updatedBy: { kind: "user" },
99
+ createdAt: NOW,
100
+ updatedAt: NOW,
101
+ schedule: "* * * * *",
102
+ },
103
+ });
104
+ const run = {
105
+ runId: "rf-brief-1",
106
+ sessionId: "user-1:bot-1",
107
+ acceptedAt: NOW,
108
+ input: "look",
109
+ admission: {
110
+ turnType: "automation",
111
+ origin: { kind: "routine", routineId: "brief" },
112
+ },
113
+ events: [
114
+ { type: "turn/start" },
115
+ { type: "wake/parent", message: "Two emails need you." },
116
+ ],
117
+ };
118
+
119
+ const records = await shellTerminalRecordsV1({
120
+ run,
121
+ cursor: CURSOR,
122
+ now: NOW,
123
+ read: durable.read,
124
+ });
125
+
126
+ expect(keysUnder(records, ROUTINE_INBOX_PREFIX)).toHaveLength(1);
127
+ expect(keysUnder(records, ROUTINE_WAKE_PREFIX)).toHaveLength(1);
128
+ // An automation Turn reaches its User through the inbox, never the badge.
129
+ expect(records[UNREAD_STATE_KEY]).toBeUndefined();
130
+ const entry = decodeRoutineInboxEntryV1(
131
+ records[keysUnder(records, ROUTINE_INBOX_PREFIX)[0]!],
132
+ );
133
+ expect(entry).toMatchObject({ entryId: "ri-rf-brief-1", runId: run.runId });
134
+ });
135
+
136
+ test("re-settling the same Turn writes the same records, and no second entry", async () => {
137
+ const durable = store({
138
+ "routine:brief": {
139
+ schemaVersion: 1,
140
+ routineId: "brief",
141
+ name: "Morning brief",
142
+ prompt: "look",
143
+ timezone: "UTC",
144
+ enabled: true,
145
+ createdBy: { kind: "user" },
146
+ updatedBy: { kind: "user" },
147
+ createdAt: NOW,
148
+ updatedAt: NOW,
149
+ schedule: "* * * * *",
150
+ },
151
+ });
152
+ const run = {
153
+ runId: "rf-brief-1",
154
+ sessionId: "user-1:bot-1",
155
+ acceptedAt: NOW,
156
+ input: "look",
157
+ admission: {
158
+ turnType: "automation",
159
+ origin: { kind: "routine", routineId: "brief" },
160
+ },
161
+ events: [
162
+ { type: "turn/start" },
163
+ { type: "wake/parent", message: "Two emails need you." },
164
+ APPROVAL_SEND,
165
+ ],
166
+ };
167
+ const settle = () =>
168
+ shellTerminalRecordsV1({
169
+ run,
170
+ cursor: CURSOR,
171
+ now: NOW,
172
+ read: durable.read,
173
+ });
174
+
175
+ const first = await settle();
176
+ durable.apply(first);
177
+ // Recovery re-settles an interrupted Turn through the same seam. The
178
+ // approval it already wrote is left exactly as it stands — a decision a
179
+ // person made in between must not be reset to `pending`.
180
+ const second = await settle();
181
+
182
+ expect(keysUnder(first, ROUTINE_INBOX_PREFIX)).toHaveLength(1);
183
+ expect(second[approvalKeyV1("ap-1")]).toBeUndefined();
184
+ durable.apply(second);
185
+ expect(
186
+ [...durable.state.keys()].filter((key) =>
187
+ key.startsWith(approvalKeyV1("ap-1")),
188
+ ),
189
+ ).toHaveLength(1);
190
+ });
191
+
192
+ test("a producer that would overwrite another's key is a loud failure", async () => {
193
+ // The unread key is the Shell's own; a Package record landing on it would
194
+ // be a key-space collision, and picking a winner silently is the one
195
+ // outcome the composition refuses.
196
+ const durable = store();
197
+ const records = await shellTerminalRecordsV1({
198
+ run: {
199
+ runId: "run-1",
200
+ sessionId: "user-1:bot-1",
201
+ acceptedAt: NOW,
202
+ input: "Please delete it",
203
+ admission: { turnType: "chat" },
204
+ events: [APPROVAL_SEND],
205
+ },
206
+ cursor: CURSOR,
207
+ now: NOW,
208
+ read: durable.read,
209
+ });
210
+
211
+ // No collision today, and the guard is what keeps that true as producers
212
+ // are added: every key is written by exactly one of them.
213
+ expect(new Set(Object.keys(records)).size).toBe(
214
+ Object.keys(records).length,
215
+ );
216
+ });
217
+ });