@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 +17 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +28 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/package.json +3 -1
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +29 -0
- package/src/__tests__/guardian-expiry-notifier.test.ts +282 -0
- package/src/__tests__/ui-file-upload-surface.test.ts +86 -0
- package/src/api/index.ts +6 -1
- package/src/api/surfaces.ts +39 -3
- package/src/approvals/guardian-channel-delivery.ts +30 -0
- package/src/approvals/guardian-expiry-notifier.ts +148 -0
- package/src/approvals/guardian-request-resolvers.ts +1 -11
- package/src/channels/types.ts +10 -20
- package/src/daemon/conversation-surfaces.ts +26 -2
- package/src/daemon/message-types/surfaces.ts +12 -12
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +13 -5
- package/src/workflows/library.test.ts +140 -0
- package/src/workflows/library.ts +82 -28
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" } }],
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/assistant",
|
|
3
|
-
"version": "0.10.2-dev.
|
|
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 {
|
|
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.
|
package/src/api/surfaces.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
+
}
|