@vellumai/assistant 0.11.5 → 0.11.6-staging.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.
- package/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
listAppPins,
|
|
5
|
+
removeAppPin,
|
|
6
|
+
updateAppPin,
|
|
7
|
+
} from "../apps/app-pin-store.js";
|
|
8
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
9
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
10
|
+
import { appPins } from "../persistence/schema/index.js";
|
|
11
|
+
|
|
12
|
+
await initializeDb();
|
|
13
|
+
|
|
14
|
+
function pin(appId: string): void {
|
|
15
|
+
updateAppPin(appId, { pinned: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function order(): string[] {
|
|
19
|
+
return listAppPins().map((entry) => entry.appId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function positionOf(appId: string): number | undefined {
|
|
23
|
+
return listAppPins().find((entry) => entry.appId === appId)?.sortPosition;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
getDb().delete(appPins).run();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("pinning", () => {
|
|
31
|
+
test("appends each new pin after the last", () => {
|
|
32
|
+
pin("a");
|
|
33
|
+
pin("b");
|
|
34
|
+
pin("c");
|
|
35
|
+
|
|
36
|
+
expect(order()).toEqual(["a", "b", "c"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("pinning an already-pinned app leaves its position alone", () => {
|
|
40
|
+
pin("a");
|
|
41
|
+
pin("b");
|
|
42
|
+
const before = positionOf("a");
|
|
43
|
+
|
|
44
|
+
pin("a");
|
|
45
|
+
|
|
46
|
+
expect(positionOf("a")).toBe(before!);
|
|
47
|
+
expect(order()).toEqual(["a", "b"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("reports the resulting pin, and null once unpinned", () => {
|
|
51
|
+
expect(updateAppPin("a", { pinned: true })).toMatchObject({ appId: "a" });
|
|
52
|
+
expect(updateAppPin("a", { pinned: false })).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an unpin with nothing pinned is a no-op, not a throw", () => {
|
|
56
|
+
expect(updateAppPin("ghost", { pinned: false })).toBeNull();
|
|
57
|
+
expect(listAppPins()).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* Positions are a fractional index, so removing one leaves the others exactly
|
|
63
|
+
* where they were. Asserted by value rather than by relative order, which would
|
|
64
|
+
* hold either way and would not catch a rewrite that renumbers the survivors.
|
|
65
|
+
*/
|
|
66
|
+
describe("unpinning", () => {
|
|
67
|
+
test("leaves the surviving positions untouched", () => {
|
|
68
|
+
pin("a");
|
|
69
|
+
pin("b");
|
|
70
|
+
pin("c");
|
|
71
|
+
const positions = { a: positionOf("a"), c: positionOf("c") };
|
|
72
|
+
|
|
73
|
+
updateAppPin("b", { pinned: false });
|
|
74
|
+
|
|
75
|
+
expect(order()).toEqual(["a", "c"]);
|
|
76
|
+
expect(positionOf("a")).toBe(positions.a!);
|
|
77
|
+
expect(positionOf("c")).toBe(positions.c!);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a pin taken after an unpin lands last, not on a survivor", () => {
|
|
81
|
+
pin("a");
|
|
82
|
+
pin("b");
|
|
83
|
+
updateAppPin("a", { pinned: false });
|
|
84
|
+
|
|
85
|
+
pin("c");
|
|
86
|
+
|
|
87
|
+
expect(order()).toEqual(["b", "c"]);
|
|
88
|
+
expect(positionOf("c")).toBeGreaterThan(positionOf("b")!);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("removeAppPin drops the pin", () => {
|
|
92
|
+
pin("a");
|
|
93
|
+
pin("b");
|
|
94
|
+
|
|
95
|
+
removeAppPin("a");
|
|
96
|
+
|
|
97
|
+
expect(order()).toEqual(["b"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("removeAppPin for an unpinned app changes nothing", () => {
|
|
101
|
+
pin("a");
|
|
102
|
+
|
|
103
|
+
removeAppPin("ghost");
|
|
104
|
+
|
|
105
|
+
expect(order()).toEqual(["a"]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("colour", () => {
|
|
110
|
+
test("sets and clears without disturbing the position", () => {
|
|
111
|
+
pin("a");
|
|
112
|
+
pin("b");
|
|
113
|
+
const before = positionOf("b");
|
|
114
|
+
|
|
115
|
+
updateAppPin("b", { color: "teal" });
|
|
116
|
+
expect(listAppPins()[1]?.color).toBe("teal");
|
|
117
|
+
expect(positionOf("b")).toBe(before!);
|
|
118
|
+
|
|
119
|
+
updateAppPin("b", { color: null });
|
|
120
|
+
expect(listAppPins()[1]?.color).toBeUndefined();
|
|
121
|
+
expect(positionOf("b")).toBe(before!);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("survives a later pin call that says nothing about colour", () => {
|
|
125
|
+
pin("a");
|
|
126
|
+
updateAppPin("a", { color: "teal" });
|
|
127
|
+
|
|
128
|
+
updateAppPin("a", { pinned: true });
|
|
129
|
+
|
|
130
|
+
expect(listAppPins()[0]?.color).toBe("teal");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/* Otherwise a colour set on an app the user just unpinned would put the pin
|
|
134
|
+
back, and the sidebar would grow a row nobody asked for. */
|
|
135
|
+
test("a colour for an unpinned app creates no pin", () => {
|
|
136
|
+
expect(updateAppPin("ghost", { color: "teal" })).toBeNull();
|
|
137
|
+
expect(listAppPins()).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("unpinning takes the colour with it", () => {
|
|
141
|
+
pin("a");
|
|
142
|
+
updateAppPin("a", { color: "teal" });
|
|
143
|
+
|
|
144
|
+
updateAppPin("a", { pinned: false });
|
|
145
|
+
pin("a");
|
|
146
|
+
|
|
147
|
+
expect(listAppPins()[0]?.color).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for `assistant/src/runtime/routes/channel-availability-routes.ts`.
|
|
3
3
|
*
|
|
4
|
-
* The handler returns a fixed base list (`slack`, `telegram`, `
|
|
4
|
+
* The handler returns a fixed base list (`slack`, `telegram`, `discord`,
|
|
5
|
+
* `phone`) and
|
|
5
6
|
* appends `email` when the platform reports at least one registered email
|
|
6
7
|
* address for this assistant. Platform failures fall back to base-only.
|
|
7
8
|
*
|
|
@@ -90,6 +91,7 @@ describe("channels/available", () => {
|
|
|
90
91
|
expect(result.channels.map((c) => c.id)).toEqual([
|
|
91
92
|
"slack",
|
|
92
93
|
"telegram",
|
|
94
|
+
"discord",
|
|
93
95
|
"phone",
|
|
94
96
|
]);
|
|
95
97
|
});
|
|
@@ -106,6 +108,7 @@ describe("channels/available", () => {
|
|
|
106
108
|
expect(result.channels.map((c) => c.id)).toEqual([
|
|
107
109
|
"slack",
|
|
108
110
|
"telegram",
|
|
111
|
+
"discord",
|
|
109
112
|
"phone",
|
|
110
113
|
"email",
|
|
111
114
|
]);
|
|
@@ -135,6 +138,7 @@ describe("channels/available", () => {
|
|
|
135
138
|
expect(result.channels.map((c) => c.id)).toEqual([
|
|
136
139
|
"slack",
|
|
137
140
|
"telegram",
|
|
141
|
+
"discord",
|
|
138
142
|
"phone",
|
|
139
143
|
]);
|
|
140
144
|
});
|
|
@@ -147,6 +151,7 @@ describe("channels/available", () => {
|
|
|
147
151
|
expect(result.channels.map((c) => c.id)).toEqual([
|
|
148
152
|
"slack",
|
|
149
153
|
"telegram",
|
|
154
|
+
"discord",
|
|
150
155
|
"phone",
|
|
151
156
|
]);
|
|
152
157
|
});
|
|
@@ -159,6 +164,7 @@ describe("channels/available", () => {
|
|
|
159
164
|
expect(result.channels.map((c) => c.id)).toEqual([
|
|
160
165
|
"slack",
|
|
161
166
|
"telegram",
|
|
167
|
+
"discord",
|
|
162
168
|
"phone",
|
|
163
169
|
]);
|
|
164
170
|
});
|
|
@@ -216,4 +222,20 @@ describe("channels/available", () => {
|
|
|
216
222
|
expect(ch!.setupMessages.contact.length).toBeGreaterThan(0);
|
|
217
223
|
}
|
|
218
224
|
});
|
|
225
|
+
|
|
226
|
+
test("offers Discord for verification, like the other text channels", async () => {
|
|
227
|
+
const result = (await handler({})) as HandlerResult;
|
|
228
|
+
|
|
229
|
+
const discord = result.channels.find((c) => c.id === "discord");
|
|
230
|
+
// Availability is what a client reads to decide whether to offer setup at
|
|
231
|
+
// all, so a channel absent here can never be verified however complete its
|
|
232
|
+
// transport is.
|
|
233
|
+
expect(discord).toBeDefined();
|
|
234
|
+
expect(discord!.supportsVerification).toBe(true);
|
|
235
|
+
expect(discord!.source).toBe("default");
|
|
236
|
+
expect(discord!.setupMessages.guardian).toContain("Discord");
|
|
237
|
+
// This field carries a Lucide name, which a brand mark is not. A client
|
|
238
|
+
// with room for one picks it by channel; every client can draw this.
|
|
239
|
+
expect(discord!.icon).toBe("hash");
|
|
240
|
+
});
|
|
219
241
|
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { credentialKey } from "../security/credential-key.js";
|
|
4
|
+
|
|
5
|
+
let mockSecureKeys: Record<string, string>;
|
|
6
|
+
|
|
7
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
8
|
+
getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
|
|
9
|
+
setSecureKeyAsync: async (key: string, value: string) => {
|
|
10
|
+
mockSecureKeys[key] = value;
|
|
11
|
+
return true;
|
|
12
|
+
},
|
|
13
|
+
deleteSecureKeyAsync: async (key: string) => {
|
|
14
|
+
delete mockSecureKeys[key];
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
mock.module("../calls/twilio-rest.js", () => ({
|
|
20
|
+
hasTwilioCredentials: () => false,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module("../calls/twilio-config.js", () => ({
|
|
24
|
+
resolveTwilioPhoneNumber: () => undefined,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
mock.module("./channel-invite-transports/whatsapp.js", () => ({
|
|
28
|
+
resolveWhatsAppDisplayNumber: () => undefined,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
type SocketHealth = {
|
|
32
|
+
channel: string;
|
|
33
|
+
status: "connected" | "disconnected" | "not_configured" | "unsupported";
|
|
34
|
+
lastLivenessAt?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every channel the socket-health reader was asked about, in order.
|
|
39
|
+
*
|
|
40
|
+
* The reader takes the channel as an argument and one function serves every
|
|
41
|
+
* socket-backed channel, so a probe naming the wrong one reports a different
|
|
42
|
+
* channel's connection as its own and no type notices.
|
|
43
|
+
*/
|
|
44
|
+
let askedChannels: string[];
|
|
45
|
+
let socketHealth: SocketHealth | Error;
|
|
46
|
+
|
|
47
|
+
/** How many channels the gateway reports on Discord's allow-list. */
|
|
48
|
+
let admittedChannelCount: number | Error;
|
|
49
|
+
|
|
50
|
+
mock.module("../channels/gateway-discord-admission.js", () => ({
|
|
51
|
+
readDiscordAdmission: async () => {
|
|
52
|
+
if (admittedChannelCount instanceof Error) {
|
|
53
|
+
throw admittedChannelCount;
|
|
54
|
+
}
|
|
55
|
+
return { admittedChannelCount };
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
mock.module("../channels/gateway-channel-socket-health.js", () => ({
|
|
60
|
+
readChannelSocketHealth: async (channel: string) => {
|
|
61
|
+
askedChannels.push(channel);
|
|
62
|
+
if (socketHealth instanceof Error) {
|
|
63
|
+
throw socketHealth;
|
|
64
|
+
}
|
|
65
|
+
return socketHealth;
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
mockSecureKeys = {};
|
|
71
|
+
askedChannels = [];
|
|
72
|
+
socketHealth = { channel: "discord", status: "connected" };
|
|
73
|
+
// The default install state: fail-closed and empty, admitting nothing until
|
|
74
|
+
// someone lists channel ids. Tests that need admission say so.
|
|
75
|
+
admittedChannelCount = 0;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function runDiscordProbe() {
|
|
79
|
+
const { createReadinessService } =
|
|
80
|
+
await import("../runtime/channel-readiness-service.js");
|
|
81
|
+
return createReadinessService().getReadiness("discord", false);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findCheck(
|
|
85
|
+
snapshot: Awaited<ReturnType<typeof runDiscordProbe>>[number],
|
|
86
|
+
name: string,
|
|
87
|
+
) {
|
|
88
|
+
return snapshot.localChecks.find((c) => c.name === name)!;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("discord readiness", () => {
|
|
92
|
+
test("asks the gateway about Discord's own socket", async () => {
|
|
93
|
+
await runDiscordProbe();
|
|
94
|
+
|
|
95
|
+
expect(askedChannels).toEqual(["discord"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a fresh install has neither a token nor an allow-list", async () => {
|
|
99
|
+
const [snapshot] = await runDiscordProbe();
|
|
100
|
+
|
|
101
|
+
expect(findCheck(snapshot, "bot_token").passed).toBe(false);
|
|
102
|
+
expect(findCheck(snapshot, "guild_admission").passed).toBe(false);
|
|
103
|
+
// Nothing configured at all, which is a different report from a channel
|
|
104
|
+
// half set up: no step has been taken rather than one left undone.
|
|
105
|
+
expect(snapshot.setupStatus).toBe("not_configured");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an allow-list without a token is half set up", async () => {
|
|
109
|
+
admittedChannelCount = 3;
|
|
110
|
+
|
|
111
|
+
const [snapshot] = await runDiscordProbe();
|
|
112
|
+
|
|
113
|
+
expect(findCheck(snapshot, "bot_token").passed).toBe(false);
|
|
114
|
+
expect(snapshot.setupStatus).toBe("incomplete");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a token, a live connection and an allow-list read as ready", async () => {
|
|
118
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
119
|
+
admittedChannelCount = 2;
|
|
120
|
+
socketHealth = {
|
|
121
|
+
channel: "discord",
|
|
122
|
+
status: "connected",
|
|
123
|
+
lastLivenessAt: Date.parse("2026-08-21T17:00:00.000Z"),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const [snapshot] = await runDiscordProbe();
|
|
127
|
+
|
|
128
|
+
expect(findCheck(snapshot, "bot_token").passed).toBe(true);
|
|
129
|
+
const delivery = findCheck(snapshot, "inbound_delivery");
|
|
130
|
+
expect(delivery.passed).toBe(true);
|
|
131
|
+
expect(delivery.message).toContain("Discord");
|
|
132
|
+
expect(delivery.message).toContain("2026-08-21T17:00:00.000Z");
|
|
133
|
+
expect(findCheck(snapshot, "guild_admission").message).toContain(
|
|
134
|
+
"2 channels",
|
|
135
|
+
);
|
|
136
|
+
expect(snapshot.ready).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an empty allow-list is unfinished setup, not a live channel", async () => {
|
|
140
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
141
|
+
admittedChannelCount = 0;
|
|
142
|
+
|
|
143
|
+
const [snapshot] = await runDiscordProbe();
|
|
144
|
+
|
|
145
|
+
// A connected socket that admits nothing still drops every guild message,
|
|
146
|
+
// so claiming ready here would promise delivery the gate is refusing.
|
|
147
|
+
const admission = findCheck(snapshot, "guild_admission");
|
|
148
|
+
expect(admission.passed).toBe(false);
|
|
149
|
+
expect(snapshot.setupStatus).toBe("incomplete");
|
|
150
|
+
expect(snapshot.ready).toBe(false);
|
|
151
|
+
|
|
152
|
+
// And it is a setup step rather than an outage: direct messages carry no
|
|
153
|
+
// guild and never meet this gate, so the connection itself is fine.
|
|
154
|
+
expect(snapshot.health).not.toBe("failing");
|
|
155
|
+
expect(findCheck(snapshot, "inbound_delivery").passed).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("one admitted channel reads in the singular", async () => {
|
|
159
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
160
|
+
admittedChannelCount = 1;
|
|
161
|
+
|
|
162
|
+
const [snapshot] = await runDiscordProbe();
|
|
163
|
+
|
|
164
|
+
expect(findCheck(snapshot, "guild_admission").message).toContain(
|
|
165
|
+
"1 channel",
|
|
166
|
+
);
|
|
167
|
+
expect(findCheck(snapshot, "guild_admission").message).not.toContain(
|
|
168
|
+
"1 channels",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("an unreachable gateway does not claim the allow-list is empty", async () => {
|
|
173
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
174
|
+
admittedChannelCount = new Error("gateway down");
|
|
175
|
+
|
|
176
|
+
const [snapshot] = await runDiscordProbe();
|
|
177
|
+
|
|
178
|
+
const admission = findCheck(snapshot, "guild_admission");
|
|
179
|
+
expect(admission.passed).toBe(true);
|
|
180
|
+
expect(admission.indeterminate).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a dead socket reads as configured and failing, not unconfigured", async () => {
|
|
184
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
185
|
+
// Setup has to be finished for this to isolate the socket: an empty
|
|
186
|
+
// allow-list would leave it incomplete for a reason that is not the socket.
|
|
187
|
+
admittedChannelCount = 1;
|
|
188
|
+
socketHealth = { channel: "discord", status: "disconnected" };
|
|
189
|
+
|
|
190
|
+
const [snapshot] = await runDiscordProbe();
|
|
191
|
+
|
|
192
|
+
// The distinction the operational kind exists for: setup finished, and the
|
|
193
|
+
// channel is down. Reporting it as unconfigured would send someone back
|
|
194
|
+
// through a setup flow that has nothing left to do.
|
|
195
|
+
expect(snapshot.setupStatus).toBe("ready");
|
|
196
|
+
expect(snapshot.health).toBe("failing");
|
|
197
|
+
expect(snapshot.ready).toBe(false);
|
|
198
|
+
expect(findCheck(snapshot, "inbound_delivery").message).toMatch(
|
|
199
|
+
/not reaching this assistant/i,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an unreachable gateway is indeterminate, never a fault", async () => {
|
|
204
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
205
|
+
socketHealth = new Error("gateway down");
|
|
206
|
+
|
|
207
|
+
const [snapshot] = await runDiscordProbe();
|
|
208
|
+
|
|
209
|
+
const delivery = findCheck(snapshot, "inbound_delivery");
|
|
210
|
+
expect(delivery.passed).toBe(true);
|
|
211
|
+
expect(delivery.indeterminate).toBe(true);
|
|
212
|
+
// Not evidence of a fault, so it may not show the channel as broken, and
|
|
213
|
+
// not evidence of delivery either, so it may not make it ready.
|
|
214
|
+
expect(snapshot.health).not.toBe("failing");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("Discord runs no remote checks, so nothing calls out to it", async () => {
|
|
218
|
+
mockSecureKeys[credentialKey("discord_channel", "bot_token")] = "bot-fake";
|
|
219
|
+
|
|
220
|
+
const { createReadinessService } =
|
|
221
|
+
await import("../runtime/channel-readiness-service.js");
|
|
222
|
+
const [snapshot] = await createReadinessService().getReadiness(
|
|
223
|
+
"discord",
|
|
224
|
+
true,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// One bot token serves both the gateway connection and the sends, so a
|
|
228
|
+
// live socket already proves the credential a send would use.
|
|
229
|
+
expect(snapshot.remoteChecks ?? []).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -566,3 +566,129 @@ describe("ChannelReadinessService", () => {
|
|
|
566
566
|
expect(probe.remoteCallCount).toBe(1);
|
|
567
567
|
});
|
|
568
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("setup progress and operational health are separate axes", () => {
|
|
571
|
+
const configured = (name: string): ReadinessCheckResult => ({
|
|
572
|
+
name,
|
|
573
|
+
passed: true,
|
|
574
|
+
message: "configured",
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("a configured channel whose delivery fails is set up, not half set up", () => {
|
|
578
|
+
// The defect this separates: telling the owner of a fully configured
|
|
579
|
+
// channel to finish setup sends them to fix the one thing that is not
|
|
580
|
+
// broken. Setup is done; delivery is down.
|
|
581
|
+
const service = new ChannelReadinessService();
|
|
582
|
+
service.registerProbe(
|
|
583
|
+
makeProbe(
|
|
584
|
+
"phone",
|
|
585
|
+
[configured("creds")],
|
|
586
|
+
[
|
|
587
|
+
{
|
|
588
|
+
name: "inbound_delivery",
|
|
589
|
+
passed: false,
|
|
590
|
+
message: "nothing is arriving",
|
|
591
|
+
kind: "operational",
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
return service.getReadiness("phone", true).then(([snapshot]) => {
|
|
598
|
+
expect(snapshot.setupStatus).toBe("ready");
|
|
599
|
+
expect(snapshot.health).toBe("failing");
|
|
600
|
+
expect(snapshot.ready).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("a delivery check that establishes nothing reads unknown, not failing", async () => {
|
|
605
|
+
const service = new ChannelReadinessService();
|
|
606
|
+
service.registerProbe(
|
|
607
|
+
makeProbe(
|
|
608
|
+
"phone",
|
|
609
|
+
[configured("creds")],
|
|
610
|
+
[
|
|
611
|
+
{
|
|
612
|
+
name: "inbound_delivery",
|
|
613
|
+
passed: true,
|
|
614
|
+
indeterminate: true,
|
|
615
|
+
message: "could not reach the provider",
|
|
616
|
+
kind: "operational",
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
const [snapshot] = await service.getReadiness("phone", true);
|
|
623
|
+
expect(snapshot.setupStatus).toBe("ready");
|
|
624
|
+
expect(snapshot.health).toBe("unknown");
|
|
625
|
+
// Unknown is not proof, so it cannot make the channel ready, and it is
|
|
626
|
+
// not a fault, so it must not be reported as one.
|
|
627
|
+
expect(snapshot.ready).toBe(false);
|
|
628
|
+
expect(snapshot.reasons).toEqual([]);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("a channel that measures nothing operational reports no health at all", async () => {
|
|
632
|
+
// Absent, not "unknown": nothing was asked, so nothing is claimed, and
|
|
633
|
+
// readiness rests on configuration alone. This is the shape every channel
|
|
634
|
+
// without a delivery check still has today.
|
|
635
|
+
const service = new ChannelReadinessService();
|
|
636
|
+
service.registerProbe(makeProbe("phone", [configured("creds")]));
|
|
637
|
+
|
|
638
|
+
const [snapshot] = await service.getReadiness("phone", true);
|
|
639
|
+
expect(snapshot.health).toBeUndefined();
|
|
640
|
+
expect(snapshot.setupStatus).toBe("ready");
|
|
641
|
+
expect(snapshot.ready).toBe(true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("incomplete configuration still reads incomplete", async () => {
|
|
645
|
+
const service = new ChannelReadinessService();
|
|
646
|
+
service.registerProbe(
|
|
647
|
+
makeProbe("phone", [
|
|
648
|
+
configured("creds"),
|
|
649
|
+
{ name: "ingress", passed: false, message: "no public URL" },
|
|
650
|
+
]),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const [snapshot] = await service.getReadiness("phone", true);
|
|
654
|
+
expect(snapshot.setupStatus).toBe("incomplete");
|
|
655
|
+
expect(snapshot.ready).toBe(false);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("an untouched channel is not_configured, not incomplete", async () => {
|
|
659
|
+
const service = new ChannelReadinessService();
|
|
660
|
+
service.registerProbe(
|
|
661
|
+
makeProbe("phone", [
|
|
662
|
+
{ name: "creds", passed: false, message: "not configured" },
|
|
663
|
+
]),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const [snapshot] = await service.getReadiness("phone", true);
|
|
667
|
+
expect(snapshot.setupStatus).toBe("not_configured");
|
|
668
|
+
expect(snapshot.ready).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("ready still means every check verified, exactly as before", async () => {
|
|
672
|
+
// The safety property for this change: splitting the derivation must not
|
|
673
|
+
// move `ready` for any input, because the CLI and the web both gate on it.
|
|
674
|
+
const service = new ChannelReadinessService();
|
|
675
|
+
service.registerProbe(
|
|
676
|
+
makeProbe(
|
|
677
|
+
"phone",
|
|
678
|
+
[configured("creds")],
|
|
679
|
+
[
|
|
680
|
+
{
|
|
681
|
+
name: "inbound_delivery",
|
|
682
|
+
passed: true,
|
|
683
|
+
message: "arriving",
|
|
684
|
+
kind: "operational",
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
),
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
const [snapshot] = await service.getReadiness("phone", true);
|
|
691
|
+
expect(snapshot.ready).toBe(true);
|
|
692
|
+
expect(snapshot.health).toBe("ok");
|
|
693
|
+
});
|
|
694
|
+
});
|