@frockbot/plugin-shell 0.0.0 → 0.1.1

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/agent.ts ADDED
@@ -0,0 +1,335 @@
1
+ // The Shell's runtime Contribution: the Bot's voice to its User, and a child
2
+ // Turn's hand-off to its parent.
3
+ //
4
+ // Two tools, and no authority of its own:
5
+ //
6
+ // 1. `send_to_user` (legacy alias `send_message`) — parity register row 57b.
7
+ // One tool carrying the typed payload union, admitted on chat turns only,
8
+ // recording each send as `send/to-user` on the durable log. Row 57c: a
9
+ // `widget` payload ends the Turn; row 53's `approval` payload is the only
10
+ // other one that does, and for the same reason — the Bot has nothing left
11
+ // to do until a person answers.
12
+ // 3. One `agent/request` handler, which is where the transcript seam is: a
13
+ // chat Turn's request carries only chat Turns, and an automation Turn's
14
+ // carries its own Turn and a pointer to the parent it may not read. See
15
+ // `history.ts`.
16
+ //
17
+ // 2. `wake_parent` — row 40 / §2.13. One required `message`, a complete
18
+ // hand-off, admitted on automation and subagent turns only, and always
19
+ // ending the Turn. Delivering the hand-off into the parent's next
20
+ // conversational Turn is a later slice; this records it durably.
21
+ //
22
+ // It lives in `plugin-shell` because the Shell already owns the run DTO and
23
+ // the WebUI that renders a send, so there is no cross-Package seam to cross.
24
+ // Nothing here reaches the kernel: admission is a declaration the tool
25
+ // registry enforces, and `endsTurn` is a boolean the Agent loop carries.
26
+ import {
27
+ decodeSendToUserPayloadV1,
28
+ decodeTurnTypeV1,
29
+ type SendToUserPayloadV1,
30
+ type Session,
31
+ type ToolDefinition,
32
+ type ToolExecutionContext,
33
+ type ToolExecutionResult,
34
+ type TurnTypeV1,
35
+ } from "@frockbot/kernel-contracts";
36
+ // Merges the Agent loop's event declarations into the cordis Context type.
37
+ import type {} from "@frockbot/kernel-agent-loop/agent";
38
+ import { automationParentPointerV1, turnScopedMessagesV1 } from "./history.js";
39
+ import type { Plugin } from "cordis";
40
+ import manifest from "../frockbot.json" with { type: "json" };
41
+
42
+ export const SEND_TO_USER_TOOL_V1 = "send_to_user";
43
+ /** `SAND_LEGACY_SEND_MESSAGE_TOOL_NAME`: an alias, not a second tool. */
44
+ export const SEND_MESSAGE_ALIAS_V1 = "send_message";
45
+ export const WAKE_PARENT_TOOL_V1 = "wake_parent";
46
+
47
+ /** The manifest Capability each tool is contributed under. */
48
+ export const USER_VOICE_CAPABILITY_V1 = "user-voice";
49
+ export const PARENT_HANDOFF_CAPABILITY_V1 = "parent-handoff";
50
+
51
+ /**
52
+ * The durable ceiling the Shell's own manifest puts on a Capability, read back
53
+ * out of the manifest rather than restated here. A registration that drifts
54
+ * from the manifest is narrowed to the manifest, so the two cannot disagree
55
+ * about what a turn type admits.
56
+ */
57
+ export function shellAdmissionCeilingV1(
58
+ capabilityId: string,
59
+ ): readonly TurnTypeV1[] | undefined {
60
+ const capabilities = (
61
+ manifest as {
62
+ configuration?: {
63
+ capabilities?: Array<{
64
+ id: string;
65
+ admission?: { turnTypes: string[] };
66
+ }>;
67
+ };
68
+ }
69
+ ).configuration?.capabilities;
70
+ const capability = capabilities?.find(
71
+ (candidate) => candidate.id === capabilityId,
72
+ );
73
+ const turnTypes = capability?.admission?.turnTypes;
74
+ if (!turnTypes) return undefined;
75
+ return turnTypes.map((turnType) =>
76
+ decodeTurnTypeV1(turnType, `shell capability "${capabilityId}" admission`),
77
+ );
78
+ }
79
+
80
+ function refusal(reason: string): ToolExecutionResult {
81
+ return { content: reason, isError: true };
82
+ }
83
+
84
+ /**
85
+ * The open step a Shell event belongs to. The session log is the
86
+ * reconstruction surface, so a send without its turn and step would not
87
+ * replay in place.
88
+ */
89
+ function openStepPositionV1(
90
+ session: Session,
91
+ tool: string,
92
+ ): { turn: number; step: number } {
93
+ const started = session.events.findLast(
94
+ (event) => event.type === "step/start",
95
+ );
96
+ const ended = session.events.findLast((event) => event.type === "step/end");
97
+ if (started?.type !== "step/start") {
98
+ throw new Error(`${tool} has no open step to record against`);
99
+ }
100
+ if (
101
+ ended?.type === "step/end" &&
102
+ ended.turn === started.turn &&
103
+ ended.step === started.step
104
+ ) {
105
+ throw new Error(`${tool} has no open step to record against`);
106
+ }
107
+ return { turn: started.turn, step: started.step };
108
+ }
109
+
110
+ /** What a recorded send tells the model it did. */
111
+ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
112
+ switch (payload.type) {
113
+ case "text":
114
+ return "Sent to the user.";
115
+ case "attachment":
116
+ return "Attachment sent to the user.";
117
+ case "widget":
118
+ return "Question sent to the user. This Turn is over; their answer arrives as a new Turn.";
119
+ case "secret-request":
120
+ return "Secret request sent to the user.";
121
+ case "agent-card":
122
+ return "Agent card sent to the user.";
123
+ case "connect-card":
124
+ return "Connect card sent to the user. Only they can complete the authorization; you have recorded the request, not granted it.";
125
+ case "approval":
126
+ // Deliberately not "requested permission": nothing has been granted, and
127
+ // the Turn is over whatever the answer turns out to be.
128
+ return "Approval requested. This Turn is over; the decision reaches you as durable input on a later Turn.";
129
+ }
130
+ }
131
+
132
+ const SEND_TO_USER_DESCRIPTION = [
133
+ "Speak to the user. This is the only way to say anything the user sees.",
134
+ "The payload is one of:",
135
+ '{"type":"text","text":"…"}',
136
+ '{"type":"attachment","url":"https://…","name":"…","mediaType":"…"}',
137
+ '{"type":"widget","widget":{"prompt":"…","helpText":"…","options":["…"],"allowCustom":false,"dismissOnMoveOn":false}}',
138
+ '{"type":"secret-request","prompt":"…","secretName":"…"}',
139
+ '{"type":"agent-card","agentId":"…","title":"…","body":"…"}',
140
+ '{"type":"connect-card","connectionId":"…","title":"…","body":"…"}',
141
+ '{"type":"approval","approvalId":"…","action":"…","rationale":"…","risk":"low|medium|high","expiresInSeconds":86400}',
142
+ "A widget asks the user a question with 1 to 6 options and ends your Turn;",
143
+ "their answer arrives as a new Turn. An approval asks the user to allow one",
144
+ "action you must not take without them; it also ends your Turn, and their",
145
+ "decision — or its expiry — reaches you as input on a later Turn.",
146
+ "Every other payload leaves the Turn running.",
147
+ ].join(" ");
148
+
149
+ const SEND_TO_USER_INPUT_SCHEMA = {
150
+ type: "object",
151
+ properties: {
152
+ payload: {
153
+ type: "object",
154
+ description: "One typed send payload, as described by this tool.",
155
+ },
156
+ },
157
+ required: ["payload"],
158
+ additionalProperties: false,
159
+ } as const;
160
+
161
+ function createSendToUserTool(
162
+ name: string,
163
+ sessions: { get(sessionId: string): Session | undefined },
164
+ ): ToolDefinition {
165
+ return {
166
+ name,
167
+ description: SEND_TO_USER_DESCRIPTION,
168
+ inputSchema: structuredClone(SEND_TO_USER_INPUT_SCHEMA) as Record<
169
+ string,
170
+ unknown
171
+ >,
172
+ admission: { turnTypes: ["chat"] },
173
+ validate: (input: unknown) =>
174
+ typeof input === "object" && input !== null && !Array.isArray(input),
175
+ execute: async (
176
+ input: unknown,
177
+ context: ToolExecutionContext,
178
+ ): Promise<ToolExecutionResult> => {
179
+ const record = input as Record<string, unknown>;
180
+ let payload: SendToUserPayloadV1;
181
+ try {
182
+ payload = decodeSendToUserPayloadV1(record.payload, `${name}.payload`);
183
+ } catch (error) {
184
+ return refusal(
185
+ `${name} was refused: ${error instanceof Error ? error.message : String(error)}`,
186
+ );
187
+ }
188
+ const session = sessions.get(context.sessionId);
189
+ if (!session) {
190
+ return refusal(
191
+ `${name} was refused: session "${context.sessionId}" is unavailable, so the send cannot be recorded`,
192
+ );
193
+ }
194
+ let position: { turn: number; step: number };
195
+ try {
196
+ position = openStepPositionV1(session, name);
197
+ } catch (error) {
198
+ return refusal(
199
+ `${name} was refused: ${error instanceof Error ? error.message : String(error)}`,
200
+ );
201
+ }
202
+ session.append({
203
+ type: "send/to-user",
204
+ ...position,
205
+ occurrenceId: context.effectId,
206
+ payload,
207
+ });
208
+ await session.flush();
209
+ return {
210
+ content: sendAcknowledgement(payload),
211
+ isError: false,
212
+ // Row 57c: a widget ends the Turn, and row 53's approval card is the
213
+ // only other payload that does. The decision is per result, so the
214
+ // same tool leaves a text send running.
215
+ ...(payload.type === "widget" || payload.type === "approval"
216
+ ? { endsTurn: true }
217
+ : {}),
218
+ };
219
+ },
220
+ };
221
+ }
222
+
223
+ function createWakeParentTool(sessions: {
224
+ get(sessionId: string): Session | undefined;
225
+ }): ToolDefinition {
226
+ return {
227
+ name: WAKE_PARENT_TOOL_V1,
228
+ description:
229
+ "Hand off to your parent conversation and end this Turn. `message` must be a complete hand-off: the parent sees only what you write here.",
230
+ inputSchema: {
231
+ type: "object",
232
+ properties: {
233
+ message: {
234
+ type: "string",
235
+ description: "The complete hand-off the parent Turn receives.",
236
+ },
237
+ },
238
+ required: ["message"],
239
+ additionalProperties: false,
240
+ },
241
+ admission: { turnTypes: ["automation", "subagent"] },
242
+ validate: (input: unknown) =>
243
+ typeof input === "object" && input !== null && !Array.isArray(input),
244
+ execute: async (
245
+ input: unknown,
246
+ context: ToolExecutionContext,
247
+ ): Promise<ToolExecutionResult> => {
248
+ const message = (input as Record<string, unknown>).message;
249
+ if (typeof message !== "string" || message.trim().length === 0) {
250
+ return refusal(
251
+ `${WAKE_PARENT_TOOL_V1} was refused: message must be a non-empty string`,
252
+ );
253
+ }
254
+ if (message.length > WAKE_PARENT_MESSAGE_LIMIT_V1) {
255
+ return refusal(
256
+ `${WAKE_PARENT_TOOL_V1} was refused: message exceeds ${WAKE_PARENT_MESSAGE_LIMIT_V1} characters`,
257
+ );
258
+ }
259
+ const session = sessions.get(context.sessionId);
260
+ if (!session) {
261
+ return refusal(
262
+ `${WAKE_PARENT_TOOL_V1} was refused: session "${context.sessionId}" is unavailable, so the hand-off cannot be recorded`,
263
+ );
264
+ }
265
+ let position: { turn: number; step: number };
266
+ try {
267
+ position = openStepPositionV1(session, WAKE_PARENT_TOOL_V1);
268
+ } catch (error) {
269
+ return refusal(
270
+ `${WAKE_PARENT_TOOL_V1} was refused: ${error instanceof Error ? error.message : String(error)}`,
271
+ );
272
+ }
273
+ session.append({
274
+ type: "wake/parent",
275
+ ...position,
276
+ occurrenceId: context.effectId,
277
+ message,
278
+ });
279
+ await session.flush();
280
+ // §2.13: calling it ends the turn, whatever the parent later does with it.
281
+ return {
282
+ content: "Handed off to the parent conversation. This Turn is over.",
283
+ isError: false,
284
+ endsTurn: true,
285
+ };
286
+ },
287
+ };
288
+ }
289
+
290
+ export const WAKE_PARENT_MESSAGE_LIMIT_V1 = 32_000;
291
+
292
+ /**
293
+ * The Shell's runtime Contribution. Registers the user-facing send tool, its
294
+ * legacy alias, and the parent hand-off, each bounded by the turn types its
295
+ * manifest Capability declares.
296
+ */
297
+ export const shellAgentPlugin: Plugin.Function = (ctx) => {
298
+ const userVoice = shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1);
299
+ const parentHandoff = shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1);
300
+ const disposers = [
301
+ ctx.tools.register(
302
+ createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
303
+ userVoice ? { admissionCeiling: userVoice } : undefined,
304
+ ),
305
+ ctx.tools.register(
306
+ createSendToUserTool(SEND_MESSAGE_ALIAS_V1, ctx.sessions),
307
+ userVoice ? { admissionCeiling: userVoice } : undefined,
308
+ ),
309
+ ctx.tools.register(
310
+ createWakeParentTool(ctx.sessions),
311
+ parentHandoff ? { admissionCeiling: parentHandoff } : undefined,
312
+ ),
313
+ // Applied after the rest of the chain, so this Package has the last word on
314
+ // what history a request carries — the one rule the visible transcript
315
+ // rests on.
316
+ ctx.on("agent/request", async (agent, _request, _signal, next) => {
317
+ const proposed = await next();
318
+ return {
319
+ ...proposed,
320
+ messages: turnScopedMessagesV1({
321
+ events: agent.session.events,
322
+ messages: proposed.messages,
323
+ pointer: automationParentPointerV1,
324
+ sessionId: agent.session.id,
325
+ }),
326
+ };
327
+ }),
328
+ ];
329
+ return () => {
330
+ for (const dispose of disposers.toReversed()) dispose();
331
+ };
332
+ };
333
+ shellAgentPlugin.inject = ["tools", "sessions"];
334
+
335
+ export default shellAgentPlugin;
@@ -0,0 +1,224 @@
1
+ // The approval record: what a Turn writes, what a decision may say, and how
2
+ // long a question may go unanswered.
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ approvalExpiresAtV1,
6
+ approvalKeyV1,
7
+ approvalNotificationBodyV1,
8
+ approvalSendsV1,
9
+ approvalTerminalRecordsV1,
10
+ decodeApprovalDecisionCommandV1,
11
+ decodeApprovalListViewV1,
12
+ decodeApprovalRecordV1,
13
+ projectApprovalCardV1,
14
+ trimmableApprovalKeysV1,
15
+ APPROVAL_DEFAULT_EXPIRY_SECONDS,
16
+ APPROVAL_MAX_EXPIRY_SECONDS,
17
+ APPROVAL_MIN_EXPIRY_SECONDS,
18
+ APPROVAL_RETENTION_LIMIT,
19
+ type ApprovalRecordV1,
20
+ } from "./approvals.js";
21
+
22
+ const NOW = "2026-08-31T00:00:00.000Z";
23
+
24
+ function send(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ type: "send/to-user",
27
+ payload: {
28
+ type: "approval",
29
+ approvalId: "ap-1",
30
+ action: "Delete the staging database",
31
+ risk: "high",
32
+ ...overrides,
33
+ },
34
+ };
35
+ }
36
+
37
+ function record(overrides: Partial<ApprovalRecordV1> = {}): ApprovalRecordV1 {
38
+ return {
39
+ schemaVersion: 1,
40
+ approvalId: "ap-1",
41
+ runId: "run-1",
42
+ sessionId: "user-1:bot-1",
43
+ action: "Delete the staging database",
44
+ risk: "high",
45
+ createdAt: NOW,
46
+ expiresAt: approvalExpiresAtV1(NOW),
47
+ decision: "pending",
48
+ decidedBy: "pending",
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("the expiry window", () => {
54
+ test("defaults to a day and clamps a Bot's request to five minutes and a week", () => {
55
+ const day = Date.parse(approvalExpiresAtV1(NOW)) - Date.parse(NOW);
56
+ expect(day).toBe(APPROVAL_DEFAULT_EXPIRY_SECONDS * 1_000);
57
+ // A card that expires before anyone could reach it is a refusal dressed as
58
+ // a question, so a one-second window becomes the floor.
59
+ expect(Date.parse(approvalExpiresAtV1(NOW, 1)) - Date.parse(NOW)).toBe(
60
+ APPROVAL_MIN_EXPIRY_SECONDS * 1_000,
61
+ );
62
+ expect(
63
+ Date.parse(approvalExpiresAtV1(NOW, 400 * 24 * 60 * 60)) -
64
+ Date.parse(NOW),
65
+ ).toBe(APPROVAL_MAX_EXPIRY_SECONDS * 1_000);
66
+ // Inside the range the Bot's own number survives untouched.
67
+ expect(Date.parse(approvalExpiresAtV1(NOW, 3_600)) - Date.parse(NOW)).toBe(
68
+ 3_600_000,
69
+ );
70
+ });
71
+
72
+ test("refuses a createdAt that is not a timestamp", () => {
73
+ expect(() => approvalExpiresAtV1("whenever")).toThrow("not a timestamp");
74
+ });
75
+ });
76
+
77
+ describe("the records a settled Turn writes", () => {
78
+ test("one pending record per approval send, keyed by the Bot's own id", async () => {
79
+ const records = await approvalTerminalRecordsV1({
80
+ run: {
81
+ runId: "run-1",
82
+ sessionId: "user-1:bot-1",
83
+ events: [
84
+ { type: "assistant/message" },
85
+ send(),
86
+ send({ approvalId: "ap-2", action: "Restart the host", risk: "low" }),
87
+ ],
88
+ },
89
+ now: NOW,
90
+ read: () => Promise.resolve(undefined),
91
+ });
92
+
93
+ expect(Object.keys(records).sort()).toEqual([
94
+ approvalKeyV1("ap-1"),
95
+ approvalKeyV1("ap-2"),
96
+ ]);
97
+ const first = decodeApprovalRecordV1(records[approvalKeyV1("ap-1")]);
98
+ expect(first).toMatchObject({
99
+ approvalId: "ap-1",
100
+ runId: "run-1",
101
+ sessionId: "user-1:bot-1",
102
+ decision: "pending",
103
+ decidedBy: "pending",
104
+ risk: "high",
105
+ });
106
+ expect(first.expiresAt).toBe(approvalExpiresAtV1(NOW));
107
+ });
108
+
109
+ test("a Turn with no approval send writes nothing", async () => {
110
+ expect(
111
+ await approvalTerminalRecordsV1({
112
+ run: {
113
+ runId: "run-1",
114
+ sessionId: "s",
115
+ events: [
116
+ { type: "send/to-user", payload: { type: "text" } } as never,
117
+ ],
118
+ },
119
+ now: NOW,
120
+ read: () => Promise.resolve(undefined),
121
+ }),
122
+ ).toEqual({});
123
+ });
124
+
125
+ test("a re-settled Turn leaves a decision somebody already made alone", async () => {
126
+ const decided = record({ decision: "approved", decidedBy: "user" });
127
+ const records = await approvalTerminalRecordsV1({
128
+ run: { runId: "run-1", sessionId: "s", events: [send()] },
129
+ now: NOW,
130
+ // A recovered Turn re-reads its own log; the record it would write is
131
+ // already there, and overwriting it would erase the answer.
132
+ read: <T>(key: string) =>
133
+ Promise.resolve(
134
+ (key === approvalKeyV1("ap-1") ? decided : undefined) as
135
+ T | undefined,
136
+ ),
137
+ });
138
+
139
+ expect(records).toEqual({});
140
+ });
141
+
142
+ test("the same card sent twice in one Turn is one decision", () => {
143
+ expect(
144
+ approvalSendsV1([send(), send({ action: "Delete it, really" })]).map(
145
+ (asked) => asked.action,
146
+ ),
147
+ ).toEqual(["Delete the staging database"]);
148
+ });
149
+ });
150
+
151
+ describe("the codecs", () => {
152
+ test("a record round-trips and refuses an unexpected key", () => {
153
+ const stored = record();
154
+ expect(decodeApprovalRecordV1(stored)).toEqual(stored);
155
+ expect(() =>
156
+ decodeApprovalRecordV1({ ...stored, decidedBy: "somebody" }),
157
+ ).toThrow("decidedBy");
158
+ expect(() =>
159
+ decodeApprovalRecordV1({ ...stored, decision: "maybe" }),
160
+ ).toThrow("decision");
161
+ expect(() => decodeApprovalRecordV1({ ...stored, extra: 1 })).toThrow(
162
+ 'unexpected key "extra"',
163
+ );
164
+ const { expiresAt: _expiresAt, ...withoutExpiry } = stored;
165
+ expect(() => decodeApprovalRecordV1(withoutExpiry)).toThrow(
166
+ 'missing "expiresAt"',
167
+ );
168
+ });
169
+
170
+ test("a decision command carries exactly one of two answers", () => {
171
+ expect(
172
+ decodeApprovalDecisionCommandV1({ schemaVersion: 1, decision: "denied" }),
173
+ ).toEqual({ schemaVersion: 1, decision: "denied" });
174
+ // Expiry is what the clock does, never what a person submits.
175
+ expect(() =>
176
+ decodeApprovalDecisionCommandV1({
177
+ schemaVersion: 1,
178
+ decision: "expired",
179
+ }),
180
+ ).toThrow("approved or denied");
181
+ expect(() =>
182
+ decodeApprovalDecisionCommandV1({
183
+ schemaVersion: 1,
184
+ decision: "approved",
185
+ approvalId: "ap-1",
186
+ }),
187
+ ).toThrow('unexpected key "approvalId"');
188
+ });
189
+
190
+ test("a listing round-trips through its own decoder", () => {
191
+ const view = {
192
+ schemaVersion: 1 as const,
193
+ botId: "bot-1",
194
+ approvals: [projectApprovalCardV1(record())],
195
+ pending: 1,
196
+ };
197
+
198
+ expect(decodeApprovalListViewV1(view)).toEqual(view);
199
+ // The projection never carries the session the Turn ran in.
200
+ expect(Object.keys(view.approvals[0]!).includes("sessionId")).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe("retention", () => {
205
+ test("nothing is trimmed under the bound, and the oldest go over it", () => {
206
+ const keys = Array.from({ length: APPROVAL_RETENTION_LIMIT + 3 }, (_, i) =>
207
+ approvalKeyV1(`ap-${String(i).padStart(4, "0")}`),
208
+ );
209
+
210
+ expect(trimmableApprovalKeysV1(keys.slice(0, 3))).toEqual([]);
211
+ expect(trimmableApprovalKeysV1(keys)).toEqual(keys.slice(0, 3));
212
+ });
213
+
214
+ test("a high-risk notification body says so before it says what", () => {
215
+ expect(approvalNotificationBodyV1(send().payload as never)).toBe(
216
+ "High risk. Delete the staging database",
217
+ );
218
+ expect(
219
+ approvalNotificationBodyV1(
220
+ send({ risk: "low", action: "Rename a file" }).payload as never,
221
+ ),
222
+ ).toBe("Rename a file");
223
+ });
224
+ });