@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
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StreamPlan } from "@vellumai/gateway-client";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AssistantEvent,
|
|
5
|
+
ToolResultEvent,
|
|
6
|
+
ToolUseStartEvent,
|
|
7
|
+
} from "../api/index.js";
|
|
4
8
|
import {
|
|
5
9
|
extractThreadTsFromCallbackUrl,
|
|
6
10
|
isSlackDeliveryCallbackUrl,
|
|
@@ -12,19 +16,16 @@ import {
|
|
|
12
16
|
} from "../daemon/assistant-attachments.js";
|
|
13
17
|
import { sendChannelStreamOp } from "../messaging/providers/index.js";
|
|
14
18
|
import { SLACK_STREAM_MARKDOWN_LIMIT } from "../messaging/providers/slack/api.js";
|
|
15
|
-
import { renderSlackBlocks } from "../messaging/providers/slack/render.js";
|
|
16
19
|
import { getLogger } from "../util/logger.js";
|
|
17
20
|
import { needsBoundarySpace } from "../util/text-spacing.js";
|
|
18
21
|
import {
|
|
19
22
|
hasDeliverableAssistantText,
|
|
20
23
|
NO_RESPONSE_INLINE_RE,
|
|
21
24
|
} from "./no-response.js";
|
|
22
|
-
import type { TaskProgressData } from "./slack-task-progress.js";
|
|
23
25
|
import {
|
|
24
|
-
|
|
26
|
+
getTaskProgressDataFromToolInput,
|
|
25
27
|
mergeTaskProgressData,
|
|
26
|
-
|
|
27
|
-
} from "./slack-task-progress.js";
|
|
28
|
+
} from "./task-progress.js";
|
|
28
29
|
|
|
29
30
|
const log = getLogger("slack-reply-session");
|
|
30
31
|
|
|
@@ -155,8 +156,7 @@ export function createSlackReplySession(params: {
|
|
|
155
156
|
// `renderHistoryContent`'s `joinWithSpacing` on the durable delivery path).
|
|
156
157
|
let pendingSegmentBoundary = false;
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
let activeProgress: TaskProgressData | undefined;
|
|
159
|
+
let activeProgress: StreamPlan | undefined;
|
|
160
160
|
// Fingerprint of the plan state last delivered to Slack, so progress that
|
|
161
161
|
// advances without new body text still flushes as a task-only append.
|
|
162
162
|
let deliveredProgressKey: string | undefined;
|
|
@@ -187,25 +187,8 @@ export function createSlackReplySession(params: {
|
|
|
187
187
|
return stripVellumLinks(stable).replace(NO_RESPONSE_INLINE_RE, "");
|
|
188
188
|
};
|
|
189
189
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const progressKey = (
|
|
194
|
-
title: string | undefined,
|
|
195
|
-
tasks: SlackStreamTask[] | undefined,
|
|
196
|
-
): string | undefined =>
|
|
197
|
-
tasks ? JSON.stringify({ title, tasks }) : undefined;
|
|
198
|
-
|
|
199
|
-
// Typed from the stream operation these are passed to, so the element type
|
|
200
|
-
// follows that contract rather than drifting from it.
|
|
201
|
-
const imageBlocks = (
|
|
202
|
-
text: string,
|
|
203
|
-
): Extract<SlackStreamOp, { action: "stop" }>["blocks"] => {
|
|
204
|
-
const blocks = renderSlackBlocks(text)?.filter(
|
|
205
|
-
(block) => block.type === "image",
|
|
206
|
-
);
|
|
207
|
-
return blocks && blocks.length > 0 ? blocks : undefined;
|
|
208
|
-
};
|
|
190
|
+
const progressKey = (plan: StreamPlan | undefined): string | undefined =>
|
|
191
|
+
plan ? JSON.stringify(plan) : undefined;
|
|
209
192
|
|
|
210
193
|
const enqueue = (op: () => Promise<void>): void => {
|
|
211
194
|
opChain = opChain.catch(() => undefined).then(op);
|
|
@@ -218,28 +201,28 @@ export function createSlackReplySession(params: {
|
|
|
218
201
|
return;
|
|
219
202
|
}
|
|
220
203
|
const firstChunk = clean.slice(0, SLACK_STREAM_MARKDOWN_LIMIT);
|
|
221
|
-
const
|
|
222
|
-
const tasks = planTasks();
|
|
204
|
+
const plan = activeProgress;
|
|
223
205
|
try {
|
|
224
206
|
const result = await sendChannelStreamOp(replyCallbackUrl, chatId, {
|
|
225
207
|
action: "start",
|
|
226
|
-
threadTs,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
208
|
+
anchorMessageId: threadTs,
|
|
209
|
+
text: clean,
|
|
210
|
+
appended: firstChunk,
|
|
211
|
+
...(plan ? { plan } : {}),
|
|
212
|
+
...(recipientUserId
|
|
213
|
+
? {
|
|
214
|
+
audience: {
|
|
215
|
+
kind: "oneReader" as const,
|
|
216
|
+
userId: recipientUserId,
|
|
217
|
+
...(recipientTeamId ? { userOrgId: recipientTeamId } : {}),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: {}),
|
|
238
221
|
});
|
|
239
222
|
if (result.ok && result.ts) {
|
|
240
223
|
streamTs = result.ts;
|
|
241
224
|
confirmedLength = firstChunk.length;
|
|
242
|
-
deliveredProgressKey = progressKey(
|
|
225
|
+
deliveredProgressKey = progressKey(plan);
|
|
243
226
|
state = "streaming";
|
|
244
227
|
// The stream is already open on Slack's side, so an `onStreamOpen`
|
|
245
228
|
// failure must not downgrade to fallback and repost the visible
|
|
@@ -270,9 +253,8 @@ export function createSlackReplySession(params: {
|
|
|
270
253
|
return;
|
|
271
254
|
}
|
|
272
255
|
const clean = streamableText();
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const key = progressKey(title, tasks);
|
|
256
|
+
const plan = activeProgress;
|
|
257
|
+
const key = progressKey(plan);
|
|
276
258
|
// `chat.appendStream` caps `markdown_text` per call, so a delta wider
|
|
277
259
|
// than the limit drains across successive append calls. Each append
|
|
278
260
|
// carries the current task state, advancing the plan alongside text.
|
|
@@ -284,10 +266,10 @@ export function createSlackReplySession(params: {
|
|
|
284
266
|
try {
|
|
285
267
|
await sendChannelStreamOp(replyCallbackUrl, chatId, {
|
|
286
268
|
action: "append",
|
|
287
|
-
streamTs,
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
...(
|
|
269
|
+
streamId: streamTs,
|
|
270
|
+
text: clean,
|
|
271
|
+
appended: chunk,
|
|
272
|
+
...(plan ? { plan } : {}),
|
|
291
273
|
});
|
|
292
274
|
confirmedLength += chunk.length;
|
|
293
275
|
deliveredProgressKey = key ?? deliveredProgressKey;
|
|
@@ -305,13 +287,13 @@ export function createSlackReplySession(params: {
|
|
|
305
287
|
// A failure disables further task-only appends for the session; the
|
|
306
288
|
// unchanged fingerprint leaves the update pending, so it rides the
|
|
307
289
|
// next text append and `stopStream` carries the final state.
|
|
308
|
-
if (!taskOnlyAppendsDisabled &&
|
|
290
|
+
if (!taskOnlyAppendsDisabled && plan && key !== deliveredProgressKey) {
|
|
309
291
|
try {
|
|
310
292
|
await sendChannelStreamOp(replyCallbackUrl, chatId, {
|
|
311
293
|
action: "append",
|
|
312
|
-
streamTs,
|
|
313
|
-
|
|
314
|
-
|
|
294
|
+
streamId: streamTs,
|
|
295
|
+
text: clean,
|
|
296
|
+
plan,
|
|
315
297
|
});
|
|
316
298
|
deliveredProgressKey = key;
|
|
317
299
|
} catch (err) {
|
|
@@ -363,24 +345,68 @@ export function createSlackReplySession(params: {
|
|
|
363
345
|
}
|
|
364
346
|
};
|
|
365
347
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
348
|
+
/**
|
|
349
|
+
* The plan tool calls this turn has made, awaiting their results.
|
|
350
|
+
*
|
|
351
|
+
* `ui_show` and `ui_update` are tools the model reaches for, so a plan is
|
|
352
|
+
* turn output and arrives on this session's own stream. The daemon also
|
|
353
|
+
* publishes `ui_surface_show`, but that goes to the conversation sink, which
|
|
354
|
+
* no channel consumes.
|
|
355
|
+
*
|
|
356
|
+
* Held rather than applied on sight, because a `tool_use_start` is an
|
|
357
|
+
* intention: the surface tool rejects a stale `surface_id` or a malformed
|
|
358
|
+
* payload, and showing a plan the canonical surface refused is worse than
|
|
359
|
+
* showing none. An update is also merged at result time so it composes onto
|
|
360
|
+
* the plan actually in effect rather than the one in effect when the call
|
|
361
|
+
* began.
|
|
362
|
+
*
|
|
363
|
+
* `ui_show` carries no surface id in its input, since the id is minted when
|
|
364
|
+
* the tool runs, so a later `ui_update` cannot be matched to the card it
|
|
365
|
+
* targets. One plan per turn is tracked instead, which is what a map keyed by
|
|
366
|
+
* surface id collapsed to in practice: whichever entry was touched last was
|
|
367
|
+
* the one rendered.
|
|
368
|
+
*/
|
|
369
|
+
const pendingPlanCalls = new Map<
|
|
370
|
+
string,
|
|
371
|
+
{
|
|
372
|
+
readonly kind: "show" | "update";
|
|
373
|
+
readonly input: Record<string, unknown>;
|
|
374
|
+
}
|
|
375
|
+
>();
|
|
376
|
+
|
|
377
|
+
const observePlanToolStart = (msg: ToolUseStartEvent): void => {
|
|
378
|
+
const kind =
|
|
379
|
+
msg.toolName === "ui_show"
|
|
380
|
+
? "show"
|
|
381
|
+
: msg.toolName === "ui_update"
|
|
382
|
+
? "update"
|
|
383
|
+
: undefined;
|
|
384
|
+
if (!kind || !msg.toolUseId) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
pendingPlanCalls.set(msg.toolUseId, { kind, input: msg.input });
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const observePlanToolResult = (msg: ToolResultEvent): void => {
|
|
391
|
+
if (!msg.toolUseId) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const pending = pendingPlanCalls.get(msg.toolUseId);
|
|
395
|
+
if (!pending) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
pendingPlanCalls.delete(msg.toolUseId);
|
|
399
|
+
if (msg.isError === true) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const progress =
|
|
403
|
+
pending.kind === "show"
|
|
404
|
+
? getTaskProgressDataFromToolInput(pending.input)
|
|
405
|
+
: mergeTaskProgressData(activeProgress, pending.input.data);
|
|
406
|
+
if (!progress) {
|
|
381
407
|
return;
|
|
382
408
|
}
|
|
383
|
-
activeProgress =
|
|
409
|
+
activeProgress = progress;
|
|
384
410
|
scheduleFlush();
|
|
385
411
|
};
|
|
386
412
|
|
|
@@ -390,9 +416,15 @@ export function createSlackReplySession(params: {
|
|
|
390
416
|
return;
|
|
391
417
|
}
|
|
392
418
|
|
|
393
|
-
if (msg.type === "
|
|
394
|
-
|
|
395
|
-
|
|
419
|
+
if (msg.type === "tool_use_start") {
|
|
420
|
+
observePlanToolStart(msg);
|
|
421
|
+
}
|
|
422
|
+
// guard:allow-tool-result-only. This reads `AssistantEvent`, not the
|
|
423
|
+
// provider content blocks the guard protects: `web_search_tool_result`
|
|
424
|
+
// is a block type in conversation history and never an event, so no
|
|
425
|
+
// result can be dropped by omitting it.
|
|
426
|
+
if (msg.type === "tool_result") {
|
|
427
|
+
observePlanToolResult(msg);
|
|
396
428
|
}
|
|
397
429
|
if (msg.type === "assistant_text_delta") {
|
|
398
430
|
if (pendingSegmentBoundary && msg.text.length > 0) {
|
|
@@ -433,17 +465,17 @@ export function createSlackReplySession(params: {
|
|
|
433
465
|
}
|
|
434
466
|
const clean = cleanedText();
|
|
435
467
|
const remaining = clean.slice(confirmedLength);
|
|
436
|
-
const
|
|
437
|
-
const title = activeProgress?.title;
|
|
438
|
-
const tasks = planTasks();
|
|
468
|
+
const plan = activeProgress;
|
|
439
469
|
try {
|
|
440
470
|
await sendChannelStreamOp(replyCallbackUrl, chatId, {
|
|
441
471
|
action: "stop",
|
|
442
|
-
streamTs,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
472
|
+
streamId: streamTs,
|
|
473
|
+
// The whole reply, not the remainder. A channel that finalizes its
|
|
474
|
+
// stream in place appends `appended`; one whose preview evaporates
|
|
475
|
+
// needs this to send the message that stays.
|
|
476
|
+
text: clean,
|
|
477
|
+
...(remaining.length > 0 ? { appended: remaining } : {}),
|
|
478
|
+
...(plan ? { plan } : {}),
|
|
447
479
|
});
|
|
448
480
|
confirmedLength = clean.length;
|
|
449
481
|
} catch (err) {
|
|
@@ -3,8 +3,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
3
3
|
import {
|
|
4
4
|
getTaskProgressDataFromSurfaceData,
|
|
5
5
|
mergeTaskProgressData,
|
|
6
|
-
|
|
7
|
-
} from "./slack-task-progress.js";
|
|
6
|
+
} from "./task-progress.js";
|
|
8
7
|
|
|
9
8
|
describe("getTaskProgressDataFromSurfaceData", () => {
|
|
10
9
|
test("reads title, steps, and step details from a task_progress surface", () => {
|
|
@@ -99,29 +98,3 @@ describe("mergeTaskProgressData", () => {
|
|
|
99
98
|
});
|
|
100
99
|
});
|
|
101
100
|
});
|
|
102
|
-
|
|
103
|
-
describe("toSlackStreamTasks", () => {
|
|
104
|
-
test("maps steps onto Slack task cards with stable ids and details", () => {
|
|
105
|
-
expect(
|
|
106
|
-
toSlackStreamTasks({
|
|
107
|
-
title: "Quick Briefing",
|
|
108
|
-
steps: [
|
|
109
|
-
{
|
|
110
|
-
label: "Check weather",
|
|
111
|
-
status: "completed",
|
|
112
|
-
detail: "Forecast fetched",
|
|
113
|
-
},
|
|
114
|
-
{ label: "Summarize", status: "failed" },
|
|
115
|
-
],
|
|
116
|
-
}),
|
|
117
|
-
).toEqual([
|
|
118
|
-
{
|
|
119
|
-
id: "task-0",
|
|
120
|
-
title: "Check weather",
|
|
121
|
-
status: "complete",
|
|
122
|
-
details: "Forecast fetched",
|
|
123
|
-
},
|
|
124
|
-
{ id: "task-1", title: "Summarize", status: "error" },
|
|
125
|
-
]);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
@@ -1,20 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { StreamPlan, StreamPlanStep } from "@vellumai/gateway-client";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* A single step of a `task_progress` UI surface: an ordered, status-bearing
|
|
5
|
-
* unit of work the assistant reports while a turn runs.
|
|
6
|
-
*/
|
|
7
|
-
export type TaskProgressStep = {
|
|
8
|
-
label: string;
|
|
9
|
-
status: "pending" | "in_progress" | "completed" | "failed";
|
|
10
|
-
detail?: string;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type TaskProgressData = {
|
|
14
|
-
/** Plan title shown above the steps (e.g. "Q2 Launch Plan"). */
|
|
15
|
-
title?: string;
|
|
16
|
-
steps: TaskProgressStep[];
|
|
17
|
-
};
|
|
3
|
+
import { coerceSurfaceDataRecord } from "../api/surfaces.js";
|
|
18
4
|
|
|
19
5
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
6
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -23,7 +9,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
23
9
|
/** Read a `task_progress` surface payload into typed steps, if it is one. */
|
|
24
10
|
export function getTaskProgressDataFromSurfaceData(
|
|
25
11
|
data: unknown,
|
|
26
|
-
):
|
|
12
|
+
): StreamPlan | undefined {
|
|
27
13
|
if (!isRecord(data)) {
|
|
28
14
|
return undefined;
|
|
29
15
|
}
|
|
@@ -33,12 +19,12 @@ export function getTaskProgressDataFromSurfaceData(
|
|
|
33
19
|
return parseTaskProgressData(data.templateData);
|
|
34
20
|
}
|
|
35
21
|
|
|
36
|
-
function parseTaskProgressData(value: unknown):
|
|
22
|
+
function parseTaskProgressData(value: unknown): StreamPlan | undefined {
|
|
37
23
|
if (!isRecord(value) || !Array.isArray(value.steps)) {
|
|
38
24
|
return undefined;
|
|
39
25
|
}
|
|
40
26
|
|
|
41
|
-
const steps = value.steps.flatMap((step):
|
|
27
|
+
const steps = value.steps.flatMap((step): StreamPlanStep[] => {
|
|
42
28
|
if (!isRecord(step)) {
|
|
43
29
|
return [];
|
|
44
30
|
}
|
|
@@ -72,15 +58,38 @@ function parseTaskProgressData(value: unknown): TaskProgressData | undefined {
|
|
|
72
58
|
return { ...(title ? { title } : {}), steps };
|
|
73
59
|
}
|
|
74
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Task progress from a `ui_show` or `ui_update` tool call's input.
|
|
63
|
+
*
|
|
64
|
+
* The model reaches for `ui_show` to draw a plan, so the card's contents ride
|
|
65
|
+
* the turn's own tool-call stream, which every channel consumer already reads.
|
|
66
|
+
* The `ui_surface_show` event the daemon publishes alongside it goes to the
|
|
67
|
+
* conversation sink instead, and no channel consumes that, so reading the tool
|
|
68
|
+
* input is what lets a channel render a plan at all.
|
|
69
|
+
*
|
|
70
|
+
* The template may sit at the top level of the input or nested under `data`,
|
|
71
|
+
* and `data` itself may arrive double-encoded as a JSON string, which is why
|
|
72
|
+
* the coercion is shared with the surface tools rather than restated here.
|
|
73
|
+
*/
|
|
74
|
+
export function getTaskProgressDataFromToolInput(
|
|
75
|
+
input: unknown,
|
|
76
|
+
): StreamPlan | undefined {
|
|
77
|
+
const record = coerceSurfaceDataRecord(input);
|
|
78
|
+
return (
|
|
79
|
+
getTaskProgressDataFromSurfaceData(record) ??
|
|
80
|
+
getTaskProgressDataFromSurfaceData(coerceSurfaceDataRecord(record.data))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
/**
|
|
76
85
|
* Apply a `ui_surface_update` payload onto the steps already known for a
|
|
77
86
|
* surface. A full `task_progress` payload replaces the steps; a partial
|
|
78
87
|
* `templateData` update is merged over the existing steps.
|
|
79
88
|
*/
|
|
80
89
|
export function mergeTaskProgressData(
|
|
81
|
-
existing:
|
|
90
|
+
existing: StreamPlan | undefined,
|
|
82
91
|
data: unknown,
|
|
83
|
-
):
|
|
92
|
+
): StreamPlan | undefined {
|
|
84
93
|
if (!isRecord(data)) {
|
|
85
94
|
return existing;
|
|
86
95
|
}
|
|
@@ -98,33 +107,3 @@ export function mergeTaskProgressData(
|
|
|
98
107
|
...(isRecord(data.templateData) ? data.templateData : {}),
|
|
99
108
|
});
|
|
100
109
|
}
|
|
101
|
-
|
|
102
|
-
const TASK_PROGRESS_STATUS_TO_SLACK: Record<
|
|
103
|
-
TaskProgressStep["status"],
|
|
104
|
-
SlackStreamTask["status"]
|
|
105
|
-
> = {
|
|
106
|
-
pending: "pending",
|
|
107
|
-
in_progress: "in_progress",
|
|
108
|
-
completed: "complete",
|
|
109
|
-
failed: "error",
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Map ordered `task_progress` steps onto Slack streaming task cards. Step
|
|
114
|
-
* position supplies the stable card `id` (a step keeps its index across
|
|
115
|
-
* updates), the label becomes the card title, the step detail becomes the
|
|
116
|
-
* card details, and the surface status maps onto Slack's task-card status
|
|
117
|
-
* vocabulary.
|
|
118
|
-
*
|
|
119
|
-
* @see https://docs.slack.dev/ai/developing-agents
|
|
120
|
-
*/
|
|
121
|
-
export function toSlackStreamTasks(
|
|
122
|
-
progress: TaskProgressData,
|
|
123
|
-
): SlackStreamTask[] {
|
|
124
|
-
return progress.steps.map((step, index) => ({
|
|
125
|
-
id: `task-${index}`,
|
|
126
|
-
title: step.label,
|
|
127
|
-
status: TASK_PROGRESS_STATUS_TO_SLACK[step.status],
|
|
128
|
-
...(step.detail ? { details: step.detail } : {}),
|
|
129
|
-
}));
|
|
130
|
-
}
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
escapeContentBoundaries,
|
|
5
|
+
escapeTagBoundaries,
|
|
5
6
|
parseExternalContentEnvelope,
|
|
6
7
|
unwrapExternalContentForDisplay,
|
|
7
8
|
wrapUntrustedContent,
|
|
@@ -79,6 +80,47 @@ describe("wrapUntrustedContent", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("escapeTagBoundaries", () => {
|
|
84
|
+
// A complete literal tag is the easiest boundary to forge and the least
|
|
85
|
+
// interesting. Matching stops at the tag name so the near-misses a model
|
|
86
|
+
// still reads as a fence go too.
|
|
87
|
+
const VARIANTS = [
|
|
88
|
+
"</watch-timeline>",
|
|
89
|
+
"</watch-timeline >",
|
|
90
|
+
"</watch-timeline\n>",
|
|
91
|
+
"</WATCH-TIMELINE>",
|
|
92
|
+
"</WaTcH-TiMeLiNe>",
|
|
93
|
+
"</watch-timeline",
|
|
94
|
+
'</watch-timeline id="x">',
|
|
95
|
+
"<watch-timeline>",
|
|
96
|
+
"<watch-timeline",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const variant of VARIANTS) {
|
|
100
|
+
test(`neutralizes ${JSON.stringify(variant)}`, () => {
|
|
101
|
+
const escaped = escapeTagBoundaries(
|
|
102
|
+
`before ${variant} after`,
|
|
103
|
+
"watch-timeline",
|
|
104
|
+
);
|
|
105
|
+
expect(escaped).not.toMatch(/<\/?watch-timeline/i);
|
|
106
|
+
expect(escaped).toContain("<");
|
|
107
|
+
// Neutralized in place: the surrounding text is untouched.
|
|
108
|
+
expect(escaped.startsWith("before ")).toBe(true);
|
|
109
|
+
expect(escaped.endsWith(" after")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
test("leaves a different tag alone", () => {
|
|
114
|
+
const axTree = "<ax-tree>Window: Editor</ax-tree>";
|
|
115
|
+
expect(escapeTagBoundaries(axTree, "watch-timeline")).toBe(axTree);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("leaves ordinary markup alone", () => {
|
|
119
|
+
const safe = "Hello, this is a normal note about <html> tags.";
|
|
120
|
+
expect(escapeTagBoundaries(safe, "watch-timeline")).toBe(safe);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
82
124
|
describe("escapeContentBoundaries", () => {
|
|
83
125
|
test("escapes closing tag", () => {
|
|
84
126
|
expect(escapeContentBoundaries("</external_content>")).toBe(
|
|
@@ -58,6 +58,9 @@ const DEFAULT_BUDGETS: Record<UntrustedContentSource, number> = {
|
|
|
58
58
|
|
|
59
59
|
const UNTRUSTED_CONTENT_SOURCE_SET = new Set<string>(UNTRUSTED_CONTENT_SOURCES);
|
|
60
60
|
|
|
61
|
+
/** Name of the wrapper element untrusted content is fenced in. */
|
|
62
|
+
const EXTERNAL_CONTENT_TAG_NAME = "external_content";
|
|
63
|
+
|
|
61
64
|
const EXTERNAL_CONTENT_ENVELOPE_PATTERN =
|
|
62
65
|
/^<external_content\s+([^\r\n<>]*)>\n([\s\S]*)\n<\/external_content>$/;
|
|
63
66
|
|
|
@@ -113,24 +116,40 @@ export function unwrapExternalContentForDisplay(value: string): string {
|
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
/**
|
|
116
|
-
* Escape sequences that could forge or break out of
|
|
117
|
-
*
|
|
119
|
+
* Escape sequences that could forge or break out of a `<tagName>` fence
|
|
120
|
+
* placed around untrusted text. Case-insensitive to cover mixed-case
|
|
118
121
|
* evasion attempts.
|
|
119
122
|
*
|
|
120
123
|
* Both tag forms are escaped. A closing tag would end the fence early.
|
|
121
|
-
* An opening tag is subtler: it lets
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
124
|
+
* An opening tag is subtler: it lets untrusted text fabricate what reads
|
|
125
|
+
* as a second fence, one it gets to label with any attribute it likes, and
|
|
126
|
+
* it defeats any structural reasoning over the result, since a forged tag
|
|
127
|
+
* is indistinguishable from a real one. Nothing legitimate arrives from
|
|
128
|
+
* the outside world carrying either.
|
|
129
|
+
*
|
|
130
|
+
* Matching stops at the tag name rather than at a closing `>`, so the
|
|
131
|
+
* variants a model still reads as a boundary are all neutralized:
|
|
132
|
+
* `</tag >`, a newline before the `>`, an unclosed `</tag`, and anything
|
|
133
|
+
* with attributes. Requiring the full literal tag would let every one of
|
|
134
|
+
* those through.
|
|
135
|
+
*
|
|
136
|
+
* Not to be confused with `escapeAxTreeContent` in
|
|
137
|
+
* `context/outbound-sanitize.ts`, which escapes the closing `</ax-tree>`
|
|
138
|
+
* only and must keep doing so: AX-tree compaction finds whole blocks by
|
|
139
|
+
* their opening tag and replaces them with a placeholder.
|
|
126
140
|
*/
|
|
127
|
-
export function
|
|
141
|
+
export function escapeTagBoundaries(content: string, tagName: string): string {
|
|
128
142
|
return content.replace(
|
|
129
|
-
|
|
143
|
+
new RegExp(`</?${tagName}`, "gi"),
|
|
130
144
|
(match) => `<${match.slice(1)}`,
|
|
131
145
|
);
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
/** {@link escapeTagBoundaries} for the `<external_content>` wrapper. */
|
|
149
|
+
export function escapeContentBoundaries(content: string): string {
|
|
150
|
+
return escapeTagBoundaries(content, EXTERNAL_CONTENT_TAG_NAME);
|
|
151
|
+
}
|
|
152
|
+
|
|
134
153
|
// ---------------------------------------------------------------------------
|
|
135
154
|
// Helpers
|
|
136
155
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
liveVoiceEndScreen,
|
|
5
|
+
liveVoiceSilenceReason,
|
|
6
|
+
} from "../live-voice-funnel.js";
|
|
7
|
+
|
|
8
|
+
describe("liveVoiceSilenceReason", () => {
|
|
9
|
+
it("blames the transport when the session never went active", () => {
|
|
10
|
+
// Nothing after `ready` can be held against a session that never got
|
|
11
|
+
// there: the user had no chance to speak.
|
|
12
|
+
expect(
|
|
13
|
+
liveVoiceSilenceReason({
|
|
14
|
+
reachedActive: false,
|
|
15
|
+
receivedAudio: false,
|
|
16
|
+
detectedSpeech: false,
|
|
17
|
+
}),
|
|
18
|
+
).toBe("no_ready");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("blames the transport even if stray audio arrived first", () => {
|
|
22
|
+
// Audio can land before the preflight resolves. The session still never
|
|
23
|
+
// became usable, and reporting that as a microphone problem would send
|
|
24
|
+
// someone looking in the wrong layer.
|
|
25
|
+
expect(
|
|
26
|
+
liveVoiceSilenceReason({
|
|
27
|
+
reachedActive: false,
|
|
28
|
+
receivedAudio: true,
|
|
29
|
+
detectedSpeech: true,
|
|
30
|
+
}),
|
|
31
|
+
).toBe("no_ready");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reports a microphone that never opened", () => {
|
|
35
|
+
expect(
|
|
36
|
+
liveVoiceSilenceReason({
|
|
37
|
+
reachedActive: true,
|
|
38
|
+
receivedAudio: false,
|
|
39
|
+
detectedSpeech: false,
|
|
40
|
+
}),
|
|
41
|
+
).toBe("no_audio");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("separates a mic that opened but carried no speech", () => {
|
|
45
|
+
// The distinction that makes this taxonomy worth having: a denied
|
|
46
|
+
// permission and a muted mic both produce a silent session, and only this
|
|
47
|
+
// pair of flags tells them apart.
|
|
48
|
+
expect(
|
|
49
|
+
liveVoiceSilenceReason({
|
|
50
|
+
reachedActive: true,
|
|
51
|
+
receivedAudio: true,
|
|
52
|
+
detectedSpeech: false,
|
|
53
|
+
}),
|
|
54
|
+
).toBe("no_speech");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reports speech that never became a turn", () => {
|
|
58
|
+
expect(
|
|
59
|
+
liveVoiceSilenceReason({
|
|
60
|
+
reachedActive: true,
|
|
61
|
+
receivedAudio: true,
|
|
62
|
+
detectedSpeech: true,
|
|
63
|
+
}),
|
|
64
|
+
).toBe("no_turn");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("liveVoiceEndScreen", () => {
|
|
69
|
+
it("carries the close reason alone when nothing else applies", () => {
|
|
70
|
+
expect(liveVoiceEndScreen("client_end")).toBe("ended_client_end");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("carries a failure code in the detail slot", () => {
|
|
74
|
+
expect(liveVoiceEndScreen("error", "invalid_field")).toBe(
|
|
75
|
+
"ended_error:invalid_field",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries a silence reason when the session merely produced nothing", () => {
|
|
80
|
+
expect(liveVoiceEndScreen("client_end", null, "no_audio")).toBe(
|
|
81
|
+
"ended_client_end:silent_no_audio",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prefers the failure code when a session both failed and was silent", () => {
|
|
86
|
+
// A session that died on an error is explained by the error; its silence
|
|
87
|
+
// is a consequence, not a second finding.
|
|
88
|
+
expect(liveVoiceEndScreen("error", "invalid_field", "no_audio")).toBe(
|
|
89
|
+
"ended_error:invalid_field",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps every combination inside the wire field's 64-char bound", () => {
|
|
94
|
+
// `screen` is capped at 64 chars by the ingest serializer and an over-long
|
|
95
|
+
// value is dropped silently, taking the whole row with it.
|
|
96
|
+
const longest = liveVoiceEndScreen("transport_closed", null, "no_speech");
|
|
97
|
+
expect(longest.length).toBeLessThanOrEqual(64);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("prefixes the silence value so it reads correctly in a mislabelled column", () => {
|
|
101
|
+
// It shares the detail slot with failure codes, and the admin dashboard
|
|
102
|
+
// renders that slot under a "failure code" header until it learns the
|
|
103
|
+
// difference. The prefix keeps the value honest meanwhile.
|
|
104
|
+
expect(liveVoiceEndScreen("client_end", null, "no_turn")).toContain(
|
|
105
|
+
"silent_",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|