@vellumai/assistant 0.10.2-dev.202606242234.c9e9e1d → 0.10.2-dev.202606242332.3fa9b2b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bun.lock CHANGED
@@ -55,6 +55,7 @@
55
55
  "@types/node": "25.5.0",
56
56
  "@types/semver": "7.5.8",
57
57
  "@types/uuid": "10.0.0",
58
+ "@typescript/native-preview": "7.0.0-dev.20260624.1",
58
59
  "ajv": "8.18.0",
59
60
  "drizzle-kit": "0.31.10",
60
61
  "eslint": "10.0.3",
@@ -455,6 +456,22 @@
455
456
 
456
457
  "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
457
458
 
459
+ "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260624.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260624.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260624.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-ogwfNo1xuAutOF8RbTCo3Ut0q/65u2ucOeHizi6O14q+3vnelNS+u8qVC2QWXubMcwtuN5E9cbfPslvGC4kdwA=="],
460
+
461
+ "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260624.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g8CqDkYCHTCYdhBHXs5cMraBurOS+KrcMFxE0SsaKZoI6Tnp+le1aWvxUBbzNKJYyThHJqb/1mLopzEJxJCuKA=="],
462
+
463
+ "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260624.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-P00JVvSV90eioYDuINAKmOSA8yhFTWLq6RvS5lrCfUuDlcgr2kSOgZAfFHIksHBVz6ZXpAXpa0dHPmc5SJ3Ymw=="],
464
+
465
+ "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "arm" }, "sha512-eWHELvfQMkVRjafMd+3ATgM9p9yAergJaM4AOY8AekCNWnHFwUrp/ohh+ryyMUIqque5jjb/kuTiOiGj728I2Q=="],
466
+
467
+ "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cppM2yTZ/Gd1hOXy8NEJcUBxJ0O0zl9CU3OU1ZWZ/OHWWX/ukEzCCr94SUwJhjIWOylBCpIYkrvYoTwxNa94XQ=="],
468
+
469
+ "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FaB8rS+rKYz4nDrEsHsF3b4cn7eCKCYroMJReA375OuQ6PHcmCNQ6QlVetA0dfFBxTTgejmoKyfw9xgAA5P4Yw=="],
470
+
471
+ "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260624.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-BgkqbCmSHDb5UxqWaFlFFJ/DHNT3lEUO4W8627ap6+QthJZuXk2imiHAX3PgYXC6en9fLLyR6jjcseAa4CCshg=="],
472
+
473
+ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260624.1", "", { "os": "win32", "cpu": "x64" }, "sha512-WaZ+ue63NgB2j/lqjirfevh/TqcsCxSqnKhGGiRnlxHyYIBcoq+x7KngyEnyGIaywJE1PcFeXA+2EMSIPlSEiQ=="],
474
+
458
475
  "@vellumai/ces-client": ["@vellumai/ces-client@file:../packages/ces-client", { "dependencies": { "@vellumai/service-contracts": "file:../service-contracts" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
459
476
 
460
477
  "@vellumai/credential-storage": ["@vellumai/credential-storage@file:../packages/credential-storage", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
@@ -6,6 +6,7 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
+ "./channels": "./src/channels.ts",
9
10
  "./credential-rpc": "./src/credential-rpc.ts",
10
11
  "./ingress": "./src/ingress.ts",
11
12
  "./twilio-ingress": "./src/twilio-ingress.ts",
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { CHANNEL_IDS, isChannelId } from "../channels.js";
4
+
5
+ describe("isChannelId", () => {
6
+ test("accepts every canonical channel id", () => {
7
+ for (const id of CHANNEL_IDS) {
8
+ expect(isChannelId(id)).toBe(true);
9
+ }
10
+ });
11
+
12
+ test("includes the internal channels no external surface ingresses", () => {
13
+ // `platform` (control plane) and `vellum` (native app) are part of the
14
+ // canonical vocabulary even though the gateway never ingresses them. The
15
+ // gateway's narrower list is a compile-time-asserted subset of this set,
16
+ // so these must remain canonical for that assertion to mean anything.
17
+ expect(isChannelId("platform")).toBe(true);
18
+ expect(isChannelId("vellum")).toBe(true);
19
+ });
20
+
21
+ test("rejects unknown strings and non-string values", () => {
22
+ expect(isChannelId("discord")).toBe(false);
23
+ expect(isChannelId("")).toBe(false);
24
+ expect(isChannelId(undefined)).toBe(false);
25
+ expect(isChannelId(null)).toBe(false);
26
+ expect(isChannelId(42)).toBe(false);
27
+ });
28
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical channel-id vocabulary shared between the assistant daemon and the
3
+ * gateway.
4
+ *
5
+ * A "channel" is an external messaging surface an actor can reach the
6
+ * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
+ * internal ids (`vellum` for native app conversations, `platform` for the
8
+ * internal control plane). This is the single source of truth for that set:
9
+ * the assistant adopts it wholesale as its `ChannelId`, and the gateway
10
+ * asserts its own (narrower) inbound list is a subset of it so the two sides
11
+ * cannot silently drift.
12
+ *
13
+ * Both packages depend on `@vellumai/service-contracts`, so hoisting the set
14
+ * here (rather than maintaining a copy on each side) means adding or renaming
15
+ * a channel happens in exactly one place.
16
+ *
17
+ * Note that a consumer may legitimately handle only a *subset* of these — the
18
+ * gateway, for example, never ingresses `platform`. Use a local list guarded
19
+ * by `satisfies readonly ChannelId[]` for those cases rather than redefining
20
+ * the union.
21
+ */
22
+
23
+ export const CHANNEL_IDS = [
24
+ "telegram",
25
+ "phone",
26
+ "vellum",
27
+ "whatsapp",
28
+ "slack",
29
+ "email",
30
+ "platform",
31
+ "a2a",
32
+ ] as const;
33
+
34
+ export type ChannelId = (typeof CHANNEL_IDS)[number];
35
+
36
+ export function isChannelId(value: unknown): value is ChannelId {
37
+ return (
38
+ typeof value === "string" &&
39
+ (CHANNEL_IDS as readonly string[]).includes(value)
40
+ );
41
+ }
@@ -18,6 +18,7 @@
18
18
  * module so that both sides can depend on it without circular references.
19
19
  */
20
20
 
21
+ export * from "./channels.js";
21
22
  export * from "./transport.js";
22
23
  export * from "./error.js";
23
24
  export * from "./handles.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.2-dev.202606242234.c9e9e1d",
3
+ "version": "0.10.2-dev.202606242332.3fa9b2b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,6 +24,7 @@
24
24
  "lint:circular": "bun run scripts/check-circular-deps.ts",
25
25
  "lint:unused:production": "knip --production --include exports",
26
26
  "typecheck": "bunx tsc --noEmit",
27
+ "typecheck:fast": "bunx tsgo --noEmit",
27
28
  "test": "bash scripts/test.sh",
28
29
  "test:coverage": "COVERAGE=true bash scripts/test.sh",
29
30
  "test:stable": "EXCLUDE_EXPERIMENTAL=true bash scripts/test.sh",
@@ -106,6 +107,7 @@
106
107
  "@types/node": "25.5.0",
107
108
  "@types/semver": "7.5.8",
108
109
  "@types/uuid": "10.0.0",
110
+ "@typescript/native-preview": "7.0.0-dev.20260624.1",
109
111
  "ajv": "8.18.0",
110
112
  "drizzle-kit": "0.31.10",
111
113
  "eslint": "10.0.3",
@@ -336,6 +336,35 @@ describe("task_progress surface compatibility", () => {
336
336
  });
337
337
  });
338
338
 
339
+ test("ui_show file_upload normalizes a comma-joined acceptedTypes string", async () => {
340
+ const sent: ServerMessage[] = [];
341
+ const ctx = makeContext(sent);
342
+
343
+ // The model may emit acceptedTypes as a comma-joined string; the renderer
344
+ // calls `.join`/`.some` on it, so the daemon hands the client a clean array.
345
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
346
+ surface_type: "file_upload",
347
+ title: "Upload a receipt",
348
+ data: {
349
+ prompt: "Share the receipt",
350
+ acceptedTypes: "image/*, application/pdf",
351
+ },
352
+ });
353
+
354
+ expect(result.isError).toBe(false);
355
+
356
+ const showMessage = sent.find(
357
+ (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
358
+ );
359
+ expect(showMessage).toBeDefined();
360
+ if (!showMessage || showMessage.surfaceType !== "file_upload") return;
361
+
362
+ expect(showMessage.data.acceptedTypes).toEqual([
363
+ "image/*",
364
+ "application/pdf",
365
+ ]);
366
+ });
367
+
339
368
  test("ui_show dynamic_page uses data.html when properly nested", async () => {
340
369
  const sent: ServerMessage[] = [];
341
370
  const ctx = makeContext(sent);
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Tests for guardian request expiry side effects:
3
+ *
4
+ * 1. notifyExpiredGuardianRequest — per-kind behavior (requester notice for
5
+ * access_request / tool_grant_request, interaction release for tool_approval,
6
+ * no-op for pending_question), Slack DM routing, non-deliverable channels,
7
+ * and best-effort (non-throwing) delivery.
8
+ * 2. Sweep integration — an expired request is transitioned to `expired` and the
9
+ * requester is notified through the wired-in notifier.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ // Silence logging.
15
+ mock.module("../util/logger.js", () => ({
16
+ getLogger: () =>
17
+ new Proxy({} as Record<string, unknown>, {
18
+ get: () => () => {},
19
+ }),
20
+ truncateForLog: (value: string) => value,
21
+ }));
22
+
23
+ // Capture requester channel deliveries; optionally fail to exercise the
24
+ // best-effort path.
25
+ const deliveredReplies: Array<{
26
+ url: string;
27
+ payload: { chatId: string; text: string; assistantId?: string };
28
+ }> = [];
29
+ let deliveryError: Error | null = null;
30
+ mock.module("../runtime/gateway-client.js", () => ({
31
+ deliverChannelReply: async (
32
+ url: string,
33
+ payload: { chatId: string; text: string; assistantId?: string },
34
+ ) => {
35
+ if (deliveryError) throw deliveryError;
36
+ deliveredReplies.push({ url, payload });
37
+ },
38
+ }));
39
+
40
+ // Capture hub broadcasts (interaction_resolved) emitted by pendingInteractions.
41
+ const broadcasts: Array<Record<string, unknown>> = [];
42
+ mock.module("../runtime/assistant-event-hub.js", () => ({
43
+ broadcastMessage: (msg: Record<string, unknown>) => {
44
+ broadcasts.push(msg);
45
+ },
46
+ }));
47
+
48
+ // The sweep withdraws cards via this module; we only assert it is invoked, and
49
+ // mocking it keeps the sweep import light (no surface/slack transitive deps).
50
+ let withdrawCalls = 0;
51
+ mock.module("../approvals/guardian-card-withdrawal.js", () => ({
52
+ withdrawGuardianRequestCards: async () => {
53
+ withdrawCalls++;
54
+ },
55
+ }));
56
+
57
+ import { notifyExpiredGuardianRequest } from "../approvals/guardian-expiry-notifier.js";
58
+ import type { CanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
59
+ import {
60
+ createCanonicalGuardianRequest,
61
+ getCanonicalGuardianRequest,
62
+ } from "../memory/canonical-guardian-store.js";
63
+ import { getDb } from "../memory/db-connection.js";
64
+ import { initializeDb } from "../memory/db-init.js";
65
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
66
+ import { sweepExpiredCanonicalGuardianRequests } from "../runtime/routes/canonical-guardian-expiry-sweep.js";
67
+
68
+ await initializeDb();
69
+
70
+ /** Build a fully-populated canonical request, overriding the interesting bits. */
71
+ function makeRequest(
72
+ overrides: Partial<CanonicalGuardianRequest> & { kind: string },
73
+ ): CanonicalGuardianRequest {
74
+ return {
75
+ id: "req-1",
76
+ sourceType: "channel",
77
+ sourceChannel: "telegram",
78
+ conversationId: "conv-1",
79
+ requesterExternalUserId: "req-user",
80
+ requesterChatId: "req-chat",
81
+ guardianExternalUserId: "guardian-user",
82
+ guardianPrincipalId: "guardian-principal",
83
+ callSessionId: null,
84
+ pendingQuestionId: null,
85
+ questionText: null,
86
+ requestCode: "ABC123",
87
+ toolName: null,
88
+ inputDigest: null,
89
+ commandPreview: null,
90
+ riskLevel: null,
91
+ activityText: null,
92
+ executionTarget: null,
93
+ status: "expired",
94
+ answerText: null,
95
+ decidedByExternalUserId: null,
96
+ decidedByPrincipalId: null,
97
+ followupState: null,
98
+ expiresAt: 1000,
99
+ createdAt: 1000,
100
+ updatedAt: 2000,
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ beforeEach(() => {
106
+ deliveredReplies.length = 0;
107
+ broadcasts.length = 0;
108
+ deliveryError = null;
109
+ withdrawCalls = 0;
110
+ pendingInteractions.clear();
111
+ });
112
+
113
+ describe("notifyExpiredGuardianRequest", () => {
114
+ test("access_request: notifies the requester on their channel", async () => {
115
+ await notifyExpiredGuardianRequest(
116
+ makeRequest({
117
+ kind: "access_request",
118
+ sourceChannel: "telegram",
119
+ requesterChatId: "tg-chat",
120
+ requesterExternalUserId: "tg-user",
121
+ }),
122
+ );
123
+
124
+ expect(deliveredReplies).toHaveLength(1);
125
+ expect(deliveredReplies[0].url).toBe("/deliver/telegram");
126
+ expect(deliveredReplies[0].payload.chatId).toBe("tg-chat");
127
+ expect(deliveredReplies[0].payload.text).toContain(
128
+ "access request expired",
129
+ );
130
+ });
131
+
132
+ test("access_request on Slack: routes to the requester DM, not the channel", async () => {
133
+ await notifyExpiredGuardianRequest(
134
+ makeRequest({
135
+ kind: "access_request",
136
+ sourceChannel: "slack",
137
+ requesterChatId: "C0SHARED",
138
+ requesterExternalUserId: "U123",
139
+ }),
140
+ );
141
+
142
+ expect(deliveredReplies).toHaveLength(1);
143
+ expect(deliveredReplies[0].url).toBe("/deliver/slack");
144
+ // DM via the user id, never the shared channel id.
145
+ expect(deliveredReplies[0].payload.chatId).toBe("U123");
146
+ });
147
+
148
+ test("tool_grant_request: notice names the tool", async () => {
149
+ await notifyExpiredGuardianRequest(
150
+ makeRequest({
151
+ kind: "tool_grant_request",
152
+ sourceChannel: "telegram",
153
+ requesterChatId: "tg-chat",
154
+ toolName: "bash",
155
+ }),
156
+ );
157
+
158
+ expect(deliveredReplies).toHaveLength(1);
159
+ expect(deliveredReplies[0].payload.text).toContain('"bash"');
160
+ expect(deliveredReplies[0].payload.text).toContain("expired");
161
+ });
162
+
163
+ test("tool_approval: releases the pending interaction, sends no channel notice", async () => {
164
+ pendingInteractions.register("req-ta", {
165
+ conversationId: "conv-1",
166
+ kind: "confirmation",
167
+ });
168
+
169
+ await notifyExpiredGuardianRequest(
170
+ makeRequest({ id: "req-ta", kind: "tool_approval" }),
171
+ );
172
+
173
+ expect(pendingInteractions.get("req-ta")).toBeUndefined();
174
+ const resolvedEvent = broadcasts.find(
175
+ (b) => b.type === "interaction_resolved",
176
+ );
177
+ expect(resolvedEvent).toMatchObject({
178
+ requestId: "req-ta",
179
+ state: "cancelled",
180
+ });
181
+ expect(deliveredReplies).toHaveLength(0);
182
+ });
183
+
184
+ test("tool_approval: no registered interaction is a safe no-op", async () => {
185
+ await notifyExpiredGuardianRequest(
186
+ makeRequest({ id: "req-none", kind: "tool_approval" }),
187
+ );
188
+
189
+ expect(deliveredReplies).toHaveLength(0);
190
+ expect(broadcasts).toHaveLength(0);
191
+ });
192
+
193
+ test("pending_question: no notice (voice owns its lifecycle)", async () => {
194
+ await notifyExpiredGuardianRequest(
195
+ makeRequest({ kind: "pending_question", sourceChannel: "phone" }),
196
+ );
197
+
198
+ expect(deliveredReplies).toHaveLength(0);
199
+ });
200
+
201
+ test("non-deliverable channel: no notice", async () => {
202
+ await notifyExpiredGuardianRequest(
203
+ makeRequest({ kind: "access_request", sourceChannel: "vellum" }),
204
+ );
205
+
206
+ expect(deliveredReplies).toHaveLength(0);
207
+ });
208
+
209
+ test("missing requester chat: no notice", async () => {
210
+ await notifyExpiredGuardianRequest(
211
+ makeRequest({
212
+ kind: "access_request",
213
+ sourceChannel: "telegram",
214
+ requesterChatId: null,
215
+ requesterExternalUserId: null,
216
+ }),
217
+ );
218
+
219
+ expect(deliveredReplies).toHaveLength(0);
220
+ });
221
+
222
+ test("delivery failure is swallowed (best-effort)", async () => {
223
+ deliveryError = new Error("gateway down");
224
+
225
+ await expect(
226
+ notifyExpiredGuardianRequest(
227
+ makeRequest({
228
+ kind: "access_request",
229
+ sourceChannel: "telegram",
230
+ requesterChatId: "tg-chat",
231
+ }),
232
+ ),
233
+ ).resolves.toBeUndefined();
234
+ });
235
+ });
236
+
237
+ describe("sweep integration", () => {
238
+ beforeEach(() => {
239
+ const db = getDb();
240
+ db.run("DELETE FROM canonical_guardian_requests");
241
+ db.run("DELETE FROM canonical_guardian_deliveries");
242
+ });
243
+
244
+ test("expired access_request is transitioned and the requester is notified", async () => {
245
+ const request = createCanonicalGuardianRequest({
246
+ kind: "access_request",
247
+ sourceType: "channel",
248
+ sourceChannel: "telegram",
249
+ requesterChatId: "tg-chat",
250
+ requesterExternalUserId: "tg-user",
251
+ guardianPrincipalId: "guardian-principal",
252
+ expiresAt: Date.now() - 1000, // already past
253
+ });
254
+
255
+ const expiredCount = await sweepExpiredCanonicalGuardianRequests();
256
+
257
+ expect(expiredCount).toBe(1);
258
+ expect(getCanonicalGuardianRequest(request.id)?.status).toBe("expired");
259
+ expect(withdrawCalls).toBe(1);
260
+ expect(deliveredReplies).toHaveLength(1);
261
+ expect(deliveredReplies[0].payload.text).toContain(
262
+ "access request expired",
263
+ );
264
+ });
265
+
266
+ test("not-yet-expired requests are left pending and unnotified", async () => {
267
+ const request = createCanonicalGuardianRequest({
268
+ kind: "access_request",
269
+ sourceType: "channel",
270
+ sourceChannel: "telegram",
271
+ requesterChatId: "tg-chat",
272
+ guardianPrincipalId: "guardian-principal",
273
+ expiresAt: Date.now() + 60_000, // still in the future
274
+ });
275
+
276
+ const expiredCount = await sweepExpiredCanonicalGuardianRequests();
277
+
278
+ expect(expiredCount).toBe(0);
279
+ expect(getCanonicalGuardianRequest(request.id)?.status).toBe("pending");
280
+ expect(deliveredReplies).toHaveLength(0);
281
+ });
282
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { FileUploadSurfaceDataSchema } from "../api/surfaces.js";
3
4
  import type {
4
5
  FileUploadSurfaceData,
5
6
  UiSurfaceShowFileUpload,
@@ -103,3 +104,88 @@ describe("UI surface tool registration", () => {
103
104
  ]);
104
105
  });
105
106
  });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // FileUploadSurfaceDataSchema coercion
110
+ // ---------------------------------------------------------------------------
111
+ //
112
+ // `acceptedTypes` is contractually a string[], but the model frequently emits a
113
+ // comma-joined string or a bare string. The renderer calls `.join`/`.some` on
114
+ // it, so the schema coerces every shape to the array contract.
115
+
116
+ describe("FileUploadSurfaceDataSchema coercion", () => {
117
+ test("passes a well-formed payload through unchanged", () => {
118
+ expect(
119
+ FileUploadSurfaceDataSchema.parse({
120
+ prompt: "Share the receipt PDF",
121
+ acceptedTypes: ["image/*", "application/pdf"],
122
+ maxFiles: 3,
123
+ }),
124
+ ).toEqual({
125
+ prompt: "Share the receipt PDF",
126
+ acceptedTypes: ["image/*", "application/pdf"],
127
+ maxFiles: 3,
128
+ });
129
+ });
130
+
131
+ test("coerces a comma-joined acceptedTypes string into an array", () => {
132
+ expect(
133
+ FileUploadSurfaceDataSchema.parse({
134
+ prompt: "p",
135
+ acceptedTypes: "image/*, application/pdf",
136
+ }).acceptedTypes,
137
+ ).toEqual(["image/*", "application/pdf"]);
138
+ });
139
+
140
+ test("wraps a single acceptedTypes string into a one-element array", () => {
141
+ expect(
142
+ FileUploadSurfaceDataSchema.parse({ acceptedTypes: "application/pdf" })
143
+ .acceptedTypes,
144
+ ).toEqual(["application/pdf"]);
145
+ });
146
+
147
+ test("the parsed acceptedTypes always supports .join", () => {
148
+ const parsed = FileUploadSurfaceDataSchema.parse({
149
+ acceptedTypes: "image/*,application/pdf",
150
+ });
151
+ // `.join` is the call the renderer makes on `acceptedTypes`.
152
+ expect(parsed.acceptedTypes?.join(",")).toBe("image/*,application/pdf");
153
+ });
154
+
155
+ test("drops blanks and non-string entries from an array", () => {
156
+ expect(
157
+ FileUploadSurfaceDataSchema.parse({
158
+ acceptedTypes: [" application/pdf ", "", null, 42, {}],
159
+ }).acceptedTypes,
160
+ ).toEqual(["application/pdf", "42"]);
161
+ });
162
+
163
+ test("treats a non-string, non-array acceptedTypes as absent", () => {
164
+ expect(
165
+ FileUploadSurfaceDataSchema.parse({ acceptedTypes: { pdf: true } })
166
+ .acceptedTypes,
167
+ ).toBeUndefined();
168
+ expect(
169
+ FileUploadSurfaceDataSchema.parse({ acceptedTypes: null }).acceptedTypes,
170
+ ).toBeUndefined();
171
+ });
172
+
173
+ test("coerces numeric-string maxFiles and drops invalid numbers", () => {
174
+ const parsed = FileUploadSurfaceDataSchema.parse({
175
+ maxFiles: "2",
176
+ maxSizeBytes: "not-a-number",
177
+ });
178
+ expect(parsed.maxFiles).toBe(2);
179
+ expect(parsed.maxSizeBytes).toBeUndefined();
180
+ });
181
+
182
+ test("never rejects a fully malformed payload", () => {
183
+ expect(
184
+ FileUploadSurfaceDataSchema.safeParse({
185
+ prompt: 5,
186
+ acceptedTypes: 99,
187
+ maxFiles: -1,
188
+ }).success,
189
+ ).toBe(true);
190
+ });
191
+ });
package/src/api/index.ts CHANGED
@@ -477,7 +477,12 @@ export {
477
477
  type WorkflowLeaf,
478
478
  WorkflowLeafSchema,
479
479
  } from "./responses/workflow-journal.js";
480
- export { type CardSurfaceData, CardSurfaceDataSchema } from "./surfaces.js";
480
+ export {
481
+ type CardSurfaceData,
482
+ CardSurfaceDataSchema,
483
+ type FileUploadSurfaceData,
484
+ FileUploadSurfaceDataSchema,
485
+ } from "./surfaces.js";
481
486
 
482
487
  /**
483
488
  * Canonical SSE event schema for the assistant runtime.
@@ -11,9 +11,9 @@
11
11
  * normalizer *supports* — anything the model sends outside these fields is
12
12
  * dropped (and logged) there, which is how we learn the shapes to recover.
13
13
  *
14
- * Card is the first surface type migrated to a canonical schema; the remaining
15
- * types still live as hand-written interfaces in
16
- * `daemon/message-types/surfaces.ts` pending migration.
14
+ * Card and file_upload use canonical schemas; the remaining types are
15
+ * hand-written interfaces in `daemon/message-types/surfaces.ts` pending
16
+ * migration.
17
17
  */
18
18
 
19
19
  import { z } from "zod";
@@ -31,3 +31,39 @@ export const CardSurfaceDataSchema = z.object({
31
31
  templateData: z.record(z.string(), z.unknown()).optional(),
32
32
  });
33
33
  export type CardSurfaceData = z.infer<typeof CardSurfaceDataSchema>;
34
+
35
+ /**
36
+ * Accepted MIME-type / extension patterns for a `file_upload` surface.
37
+ *
38
+ * The renderer consumes this as a `string[]` — it calls `.join`/`.some`/
39
+ * `.length` on the value — but the model may emit a single comma-joined string
40
+ * ("image/*, application/pdf") or a bare string. Coercing every shape to a
41
+ * clean `string[]` keeps that array invariant intact: a string is split on
42
+ * commas; array entries are stringified and trimmed; blanks and any non-array
43
+ * value collapse to `undefined` (no restriction).
44
+ */
45
+ const FileUploadAcceptedTypesSchema = z.preprocess((value) => {
46
+ const items =
47
+ typeof value === "string"
48
+ ? value.split(",")
49
+ : Array.isArray(value)
50
+ ? value
51
+ : [];
52
+ const cleaned = items
53
+ .map((item) =>
54
+ typeof item === "string" || typeof item === "number"
55
+ ? String(item).trim()
56
+ : "",
57
+ )
58
+ .filter((item) => item.length > 0);
59
+ return cleaned.length > 0 ? cleaned : undefined;
60
+ }, z.array(z.string()).optional());
61
+
62
+ export const FileUploadSurfaceDataSchema = z.object({
63
+ prompt: z.coerce.string().optional(),
64
+ acceptedTypes: FileUploadAcceptedTypesSchema,
65
+ /** A non-positive or non-numeric value is dropped rather than rejecting the surface. */
66
+ maxFiles: z.coerce.number().int().positive().optional().catch(undefined),
67
+ maxSizeBytes: z.coerce.number().positive().optional().catch(undefined),
68
+ });
69
+ export type FileUploadSurfaceData = z.infer<typeof FileUploadSurfaceDataSchema>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared addressing helpers for guardian requester-facing channel notices.
3
+ *
4
+ * Requester notices (approval, denial, expiry) are delivered straight to the
5
+ * requester's chat via `deliverChannelReply` — independent of the
6
+ * guardian-facing notification pipeline. Centralizing the addressing rules here
7
+ * keeps the decision resolvers and the timer-driven expiry sweep from drifting
8
+ * apart on how a requester is reached.
9
+ */
10
+
11
+ /**
12
+ * Resolve the callback-less delivery route for a channel (e.g. `/deliver/slack`).
13
+ *
14
+ * Used when there is no inbound reply callback URL to post back to — the
15
+ * guardian decided off-channel (desktop), or the expiry sweep fired on a timer
16
+ * with no originating request in hand. Returns null for channels that have no
17
+ * deliverable route (e.g. email, the in-app vellum surface).
18
+ */
19
+ export function resolveDeliverCallbackUrlForChannel(
20
+ channel: string,
21
+ ): string | null {
22
+ switch (channel) {
23
+ case "telegram":
24
+ case "whatsapp":
25
+ case "slack":
26
+ return `/deliver/${channel}`;
27
+ default:
28
+ return null;
29
+ }
30
+ }