@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,598 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { initializeDb } from "../../../persistence/db-init.js";
|
|
4
|
+
import type {
|
|
5
|
+
StreamingTranscriber,
|
|
6
|
+
SttStreamServerEvent,
|
|
7
|
+
} from "../../../stt/types.js";
|
|
8
|
+
import type { WatchSessionSummary } from "../../../watch/watch-session-manager.js";
|
|
9
|
+
import { WatchSessionManager } from "../../../watch/watch-session-manager.js";
|
|
10
|
+
import { renderWatchTimeline } from "../../../watch/watch-timeline.js";
|
|
11
|
+
import type { HostObservation } from "../../host-observe.js";
|
|
12
|
+
import {
|
|
13
|
+
closeWatchIngress,
|
|
14
|
+
drainWatchRetros,
|
|
15
|
+
reopenWatchIngressForTest,
|
|
16
|
+
type WatchStreamServerFrame,
|
|
17
|
+
WatchStreamSession,
|
|
18
|
+
type WatchStreamSocket,
|
|
19
|
+
} from "../watch-routes.js";
|
|
20
|
+
|
|
21
|
+
await initializeDb();
|
|
22
|
+
|
|
23
|
+
const PRINCIPAL_ID = "principal-watch-stream-test";
|
|
24
|
+
|
|
25
|
+
/** A socket that keeps every frame the session sent it. */
|
|
26
|
+
class FakeSocket implements WatchStreamSocket {
|
|
27
|
+
readonly frames: WatchStreamServerFrame[] = [];
|
|
28
|
+
closeCode: number | null = null;
|
|
29
|
+
closeReason: string | null = null;
|
|
30
|
+
|
|
31
|
+
send(data: string): void {
|
|
32
|
+
this.frames.push(JSON.parse(data) as WatchStreamServerFrame);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
close(code?: number, reason?: string): void {
|
|
36
|
+
this.closeCode = code ?? null;
|
|
37
|
+
this.closeReason = reason ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
types(): string[] {
|
|
41
|
+
return this.frames.map((frame) => frame.type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The same sequence with the capture acknowledgements taken out.
|
|
46
|
+
*
|
|
47
|
+
* Every session reads the screen as it opens and again around narration, so
|
|
48
|
+
* `observation` frames land in the middle of every sequence here, at moments
|
|
49
|
+
* the manager's cadence decides rather than the test. Cases whose subject is
|
|
50
|
+
* the lifecycle read this; cases whose subject is the capture signal read
|
|
51
|
+
* `types()` and say exactly where the frames fall.
|
|
52
|
+
*/
|
|
53
|
+
lifecycle(): string[] {
|
|
54
|
+
return this.types().filter((type) => type !== "observation");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
firstOfType<T extends WatchStreamServerFrame["type"]>(
|
|
58
|
+
type: T,
|
|
59
|
+
): Extract<WatchStreamServerFrame, { type: T }> | undefined {
|
|
60
|
+
return this.frames.find((frame) => frame.type === type) as
|
|
61
|
+
| Extract<WatchStreamServerFrame, { type: T }>
|
|
62
|
+
| undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A transcriber the test drives by hand. */
|
|
67
|
+
class FakeTranscriber implements StreamingTranscriber {
|
|
68
|
+
readonly providerId = "deepgram" as const;
|
|
69
|
+
readonly boundaryId = "daemon-streaming" as const;
|
|
70
|
+
readonly audio: { bytes: Buffer; mimeType: string }[] = [];
|
|
71
|
+
stopCount = 0;
|
|
72
|
+
private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
|
|
73
|
+
|
|
74
|
+
async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
|
|
75
|
+
this.onEvent = onEvent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
sendAudio(audio: Buffer, mimeType: string): void {
|
|
79
|
+
this.audio.push({ bytes: audio, mimeType });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
stop(): void {
|
|
83
|
+
this.stopCount += 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
emit(event: SttStreamServerEvent): void {
|
|
87
|
+
this.onEvent?.(event);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function observation(): HostObservation {
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
axTree: "Window: Editor\n[1] Button Save",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A manager wired to a scripted screen reader, so a session's cadence is a
|
|
100
|
+
* function of what the test says the host answered rather than of a real
|
|
101
|
+
* desktop client.
|
|
102
|
+
*/
|
|
103
|
+
function newManager() {
|
|
104
|
+
const observeCalls: (string | undefined)[] = [];
|
|
105
|
+
const manager = new WatchSessionManager({
|
|
106
|
+
observe: async (options) => {
|
|
107
|
+
observeCalls.push(options.sourceActorPrincipalId);
|
|
108
|
+
return observation();
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return { manager, observeCalls };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface HarnessOverrides {
|
|
115
|
+
manager?: WatchSessionManager;
|
|
116
|
+
transcriber?: FakeTranscriber | null;
|
|
117
|
+
principalId?: string | undefined;
|
|
118
|
+
idleTimeoutMs?: number;
|
|
119
|
+
/** How long the stand-in retrospective takes before it settles. */
|
|
120
|
+
retroDelayMs?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function newSession(overrides: HarnessOverrides = {}) {
|
|
124
|
+
const ws = new FakeSocket();
|
|
125
|
+
const transcriber =
|
|
126
|
+
overrides.transcriber === undefined
|
|
127
|
+
? new FakeTranscriber()
|
|
128
|
+
: overrides.transcriber;
|
|
129
|
+
const manager = overrides.manager ?? newManager().manager;
|
|
130
|
+
// The retrospective is a full agent turn, so every session in this file gets
|
|
131
|
+
// a stand-in for it and the tests read what it was handed. `finished` flips
|
|
132
|
+
// once the stand-in returns, which is what a drain has to wait for.
|
|
133
|
+
const retros: WatchSessionSummary[] = [];
|
|
134
|
+
const finished: WatchSessionSummary[] = [];
|
|
135
|
+
const session = new WatchStreamSession(ws, {
|
|
136
|
+
mimeType: "audio/webm",
|
|
137
|
+
manager,
|
|
138
|
+
resolveTranscriber: async () => transcriber,
|
|
139
|
+
resolveActorPrincipalId: async () =>
|
|
140
|
+
"principalId" in overrides ? overrides.principalId : PRINCIPAL_ID,
|
|
141
|
+
runRetro: async (summary) => {
|
|
142
|
+
retros.push(summary);
|
|
143
|
+
if (overrides.retroDelayMs !== undefined) {
|
|
144
|
+
await Bun.sleep(overrides.retroDelayMs);
|
|
145
|
+
}
|
|
146
|
+
finished.push(summary);
|
|
147
|
+
},
|
|
148
|
+
...(overrides.idleTimeoutMs !== undefined
|
|
149
|
+
? { idleTimeoutMs: overrides.idleTimeoutMs }
|
|
150
|
+
: {}),
|
|
151
|
+
});
|
|
152
|
+
return { ws, transcriber, manager, session, retros, finished };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("watch stream session", () => {
|
|
156
|
+
// The ingress latch is module state shared by every test in this file.
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
reopenWatchIngressForTest();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("observes on speech onset and acknowledges nothing", async () => {
|
|
162
|
+
// A controllable clock, so the test can step past the observation floor
|
|
163
|
+
// without waiting it out in real time.
|
|
164
|
+
let nowMs = 0;
|
|
165
|
+
const observeCalls: (string | undefined)[] = [];
|
|
166
|
+
const manager = new WatchSessionManager({
|
|
167
|
+
now: () => nowMs,
|
|
168
|
+
observe: async (options) => {
|
|
169
|
+
observeCalls.push(options.sourceActorPrincipalId);
|
|
170
|
+
return observation();
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const { ws, transcriber, session } = newSession({ manager });
|
|
174
|
+
await session.start();
|
|
175
|
+
await Bun.sleep(5);
|
|
176
|
+
|
|
177
|
+
// The session's opening observation. Step past the floor it just spent so
|
|
178
|
+
// the onset below is free to read the screen rather than being collapsed
|
|
179
|
+
// into it.
|
|
180
|
+
expect(observeCalls).toHaveLength(1);
|
|
181
|
+
nowMs += 6_000;
|
|
182
|
+
|
|
183
|
+
transcriber!.emit({ type: "turn-start" });
|
|
184
|
+
await Bun.sleep(5);
|
|
185
|
+
|
|
186
|
+
expect(observeCalls).toHaveLength(2);
|
|
187
|
+
// No `entry`: onset carries no text, and the final that follows is what
|
|
188
|
+
// files the narration. Acknowledging here would report an entry the
|
|
189
|
+
// timeline does not have. The two reads themselves are acknowledged, which
|
|
190
|
+
// is the case below.
|
|
191
|
+
expect(ws.lifecycle()).toEqual(["ready"]);
|
|
192
|
+
|
|
193
|
+
session.handleMessage(JSON.stringify({ type: "stop" }));
|
|
194
|
+
transcriber!.emit({ type: "closed" });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("starts, records narration, and stops", async () => {
|
|
198
|
+
const { manager, observeCalls } = newManager();
|
|
199
|
+
const { ws, transcriber, session } = newSession({ manager });
|
|
200
|
+
await session.start();
|
|
201
|
+
|
|
202
|
+
const ready = ws.firstOfType("ready");
|
|
203
|
+
expect(ready).toBeDefined();
|
|
204
|
+
expect(manager.isActive(ready!.conversationId)).toBe(true);
|
|
205
|
+
|
|
206
|
+
transcriber!.emit({ type: "final", text: " opening the invoice " });
|
|
207
|
+
// The narration lands synchronously; the screen read it triggers does not.
|
|
208
|
+
await Bun.sleep(5);
|
|
209
|
+
|
|
210
|
+
expect(ws.lifecycle()).toEqual(["ready", "entry"]);
|
|
211
|
+
expect(observeCalls).toEqual([PRINCIPAL_ID]);
|
|
212
|
+
|
|
213
|
+
const rendered = renderWatchTimeline(ready!.sessionId);
|
|
214
|
+
expect(rendered.totalEntries).toBe(2);
|
|
215
|
+
expect(rendered.text).toContain("opening the invoice");
|
|
216
|
+
|
|
217
|
+
session.handleMessage(JSON.stringify({ type: "stop" }));
|
|
218
|
+
expect(transcriber!.stopCount).toBe(1);
|
|
219
|
+
// The provider flushes and then reports its own close.
|
|
220
|
+
transcriber!.emit({ type: "closed" });
|
|
221
|
+
|
|
222
|
+
expect(ws.lifecycle()).toEqual(["ready", "entry", "closed"]);
|
|
223
|
+
expect(manager.isActive()).toBe(false);
|
|
224
|
+
expect(session.isClosed).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The capture signal, which is the only word a client ever gets that its
|
|
229
|
+
* screen was read. Every case here is about it being true: a frame per read
|
|
230
|
+
* that landed, and no frame at all for a read that did not.
|
|
231
|
+
*/
|
|
232
|
+
describe("capture acknowledgements", () => {
|
|
233
|
+
test("sends one frame per screen read that landed", async () => {
|
|
234
|
+
let nowMs = 0;
|
|
235
|
+
const manager = new WatchSessionManager({
|
|
236
|
+
now: () => nowMs,
|
|
237
|
+
observe: async () => observation(),
|
|
238
|
+
});
|
|
239
|
+
const { ws, transcriber, session } = newSession({ manager });
|
|
240
|
+
await session.start();
|
|
241
|
+
await Bun.sleep(5);
|
|
242
|
+
|
|
243
|
+
// The session's own opening read, acknowledged after `ready`: the
|
|
244
|
+
// manager is claimed before that frame goes out and the read behind it
|
|
245
|
+
// settles later.
|
|
246
|
+
expect(ws.types()).toEqual(["ready", "observation"]);
|
|
247
|
+
|
|
248
|
+
// Past the floor, so the onset below reads the screen rather than being
|
|
249
|
+
// collapsed into the read above.
|
|
250
|
+
nowMs += 6_000;
|
|
251
|
+
transcriber!.emit({ type: "turn-start" });
|
|
252
|
+
await Bun.sleep(5);
|
|
253
|
+
|
|
254
|
+
expect(ws.types()).toEqual(["ready", "observation", "observation"]);
|
|
255
|
+
|
|
256
|
+
session.destroy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("stays silent for a read the host could not serve", async () => {
|
|
260
|
+
// The shape every failure resolves to, timeouts included:
|
|
261
|
+
// `observeHostScreen` answers `{ ok: false }` rather than throwing, and a
|
|
262
|
+
// read that outran its budget is that answer with `timedOut` set.
|
|
263
|
+
const manager = new WatchSessionManager({
|
|
264
|
+
observe: async () => ({
|
|
265
|
+
ok: false as const,
|
|
266
|
+
reason: "No connected client supports screen observation",
|
|
267
|
+
timedOut: true,
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
const { ws, transcriber, session } = newSession({ manager });
|
|
271
|
+
await session.start();
|
|
272
|
+
await Bun.sleep(5);
|
|
273
|
+
|
|
274
|
+
transcriber!.emit({ type: "final", text: "still narrating" });
|
|
275
|
+
await Bun.sleep(5);
|
|
276
|
+
|
|
277
|
+
// The narration is filed and acknowledged; the screen behind it was
|
|
278
|
+
// never read, and nothing here says it was.
|
|
279
|
+
expect(ws.types()).toEqual(["ready", "entry"]);
|
|
280
|
+
|
|
281
|
+
session.destroy();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("stays silent for a read the session ended underneath", async () => {
|
|
285
|
+
const releases: ((observed: HostObservation) => void)[] = [];
|
|
286
|
+
const manager = new WatchSessionManager({
|
|
287
|
+
observe: () =>
|
|
288
|
+
new Promise<HostObservation>((resolve) => {
|
|
289
|
+
releases.push(resolve);
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
const { ws, session } = newSession({ manager });
|
|
293
|
+
await session.start();
|
|
294
|
+
await Bun.sleep(5);
|
|
295
|
+
expect(releases).toHaveLength(1);
|
|
296
|
+
|
|
297
|
+
// The session ends while its opening read is still out. What comes back
|
|
298
|
+
// describes a screen belonging to a session that is over, and the
|
|
299
|
+
// acknowledgement would land after `closed`.
|
|
300
|
+
session.destroy();
|
|
301
|
+
releases[0]!(observation());
|
|
302
|
+
await Bun.sleep(5);
|
|
303
|
+
|
|
304
|
+
expect(ws.types()).toEqual(["ready", "closed"]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("forwards binary and base64 audio frames to the transcriber", async () => {
|
|
309
|
+
const { ws, transcriber, session } = newSession();
|
|
310
|
+
await session.start();
|
|
311
|
+
|
|
312
|
+
session.handleBinaryAudio(Buffer.from([1, 2, 3]));
|
|
313
|
+
session.handleMessage(
|
|
314
|
+
JSON.stringify({
|
|
315
|
+
type: "audio",
|
|
316
|
+
audio: Buffer.from([4, 5]).toString("base64"),
|
|
317
|
+
mimeType: "audio/pcm",
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(transcriber!.audio).toHaveLength(2);
|
|
322
|
+
expect([...transcriber!.audio[0]!.bytes]).toEqual([1, 2, 3]);
|
|
323
|
+
expect(transcriber!.audio[0]!.mimeType).toBe("audio/webm");
|
|
324
|
+
expect([...transcriber!.audio[1]!.bytes]).toEqual([4, 5]);
|
|
325
|
+
expect(transcriber!.audio[1]!.mimeType).toBe("audio/pcm");
|
|
326
|
+
expect(ws.lifecycle()).toEqual(["ready"]);
|
|
327
|
+
|
|
328
|
+
session.destroy();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("draws no transcript: partials produce no frames", async () => {
|
|
332
|
+
const { ws, transcriber, session } = newSession();
|
|
333
|
+
await session.start();
|
|
334
|
+
|
|
335
|
+
transcriber!.emit({ type: "partial", text: "half a sen" });
|
|
336
|
+
transcriber!.emit({ type: "turn-end", text: "half a sentence" });
|
|
337
|
+
transcriber!.emit({ type: "final", text: " " });
|
|
338
|
+
|
|
339
|
+
expect(ws.lifecycle()).toEqual(["ready"]);
|
|
340
|
+
|
|
341
|
+
session.destroy();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("an unresolvable actor principal fails without opening a session", async () => {
|
|
345
|
+
const { manager } = newManager();
|
|
346
|
+
const { ws, transcriber, session } = newSession({
|
|
347
|
+
manager,
|
|
348
|
+
principalId: undefined,
|
|
349
|
+
});
|
|
350
|
+
await session.start();
|
|
351
|
+
|
|
352
|
+
expect(ws.types()).toEqual(["error", "closed"]);
|
|
353
|
+
expect(ws.firstOfType("error")!.category).toBe("session-error");
|
|
354
|
+
expect(ws.closeCode).toBe(1008);
|
|
355
|
+
expect(manager.isActive()).toBe(false);
|
|
356
|
+
// The provider stream is never opened for a session that could observe
|
|
357
|
+
// nothing, so there is nothing to stop.
|
|
358
|
+
expect(transcriber!.stopCount).toBe(0);
|
|
359
|
+
expect(session.isClosed).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("provider resolution failure yields a clean error then closed", async () => {
|
|
363
|
+
const { manager } = newManager();
|
|
364
|
+
const { ws, session } = newSession({ manager, transcriber: null });
|
|
365
|
+
await session.start();
|
|
366
|
+
|
|
367
|
+
expect(ws.types()).toEqual(["error", "closed"]);
|
|
368
|
+
expect(ws.firstOfType("error")!.category).toBe("provider-error");
|
|
369
|
+
expect(manager.isActive()).toBe(false);
|
|
370
|
+
expect(session.isClosed).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a second socket is turned away without disturbing the running session", async () => {
|
|
374
|
+
const { manager } = newManager();
|
|
375
|
+
const first = newSession({ manager });
|
|
376
|
+
await first.session.start();
|
|
377
|
+
const runningConversationId = first.ws.firstOfType("ready")!.conversationId;
|
|
378
|
+
|
|
379
|
+
const second = newSession({ manager });
|
|
380
|
+
await second.session.start();
|
|
381
|
+
|
|
382
|
+
expect(second.ws.types()).toEqual(["error", "closed"]);
|
|
383
|
+
expect(second.ws.firstOfType("error")!.message).toContain(
|
|
384
|
+
"already running",
|
|
385
|
+
);
|
|
386
|
+
// The loser's teardown must not release the winner's slot.
|
|
387
|
+
expect(manager.isActive(runningConversationId)).toBe(true);
|
|
388
|
+
expect(second.transcriber!.stopCount).toBe(1);
|
|
389
|
+
|
|
390
|
+
first.session.destroy();
|
|
391
|
+
expect(manager.isActive()).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("an abandoned socket is torn down by the idle timeout", async () => {
|
|
395
|
+
const { manager } = newManager();
|
|
396
|
+
const { ws, transcriber, session } = newSession({
|
|
397
|
+
manager,
|
|
398
|
+
idleTimeoutMs: 20,
|
|
399
|
+
});
|
|
400
|
+
await session.start();
|
|
401
|
+
expect(manager.isActive()).toBe(true);
|
|
402
|
+
|
|
403
|
+
await Bun.sleep(60);
|
|
404
|
+
|
|
405
|
+
expect(ws.lifecycle()).toEqual(["ready", "error", "closed"]);
|
|
406
|
+
expect(ws.firstOfType("error")!.category).toBe("timeout");
|
|
407
|
+
expect(ws.closeCode).toBe(1000);
|
|
408
|
+
expect(transcriber!.stopCount).toBe(1);
|
|
409
|
+
expect(manager.isActive()).toBe(false);
|
|
410
|
+
expect(session.isClosed).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("inbound audio keeps the idle timeout at bay", async () => {
|
|
414
|
+
const { manager } = newManager();
|
|
415
|
+
const { ws, session } = newSession({ manager, idleTimeoutMs: 40 });
|
|
416
|
+
await session.start();
|
|
417
|
+
|
|
418
|
+
for (let i = 0; i < 4; i += 1) {
|
|
419
|
+
await Bun.sleep(15);
|
|
420
|
+
session.handleBinaryAudio(Buffer.from([i]));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
expect(ws.lifecycle()).toEqual(["ready"]);
|
|
424
|
+
expect(manager.isActive()).toBe(true);
|
|
425
|
+
|
|
426
|
+
session.destroy();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("teardown runs the retrospective once, on the session it just ended", async () => {
|
|
430
|
+
const { manager } = newManager();
|
|
431
|
+
const { ws, transcriber, session, retros } = newSession({ manager });
|
|
432
|
+
await session.start();
|
|
433
|
+
const ready = ws.firstOfType("ready")!;
|
|
434
|
+
|
|
435
|
+
transcriber!.emit({ type: "final", text: "renaming the export" });
|
|
436
|
+
await Bun.sleep(5);
|
|
437
|
+
|
|
438
|
+
expect(retros).toHaveLength(0);
|
|
439
|
+
|
|
440
|
+
session.handleMessage(JSON.stringify({ type: "stop" }));
|
|
441
|
+
transcriber!.emit({ type: "closed" });
|
|
442
|
+
|
|
443
|
+
expect(retros).toHaveLength(1);
|
|
444
|
+
expect(retros[0]!.sessionId).toBe(ready.sessionId);
|
|
445
|
+
expect(retros[0]!.conversationId).toBe(ready.conversationId);
|
|
446
|
+
expect(retros[0]!.entryCount).toBe(2);
|
|
447
|
+
|
|
448
|
+
// The close that follows the provider's own is a no-op, so a socket that
|
|
449
|
+
// reports both does not report the session twice.
|
|
450
|
+
session.handleClose(1000, "session complete");
|
|
451
|
+
expect(retros).toHaveLength(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a socket turned away as busy runs no retrospective", async () => {
|
|
455
|
+
const { manager } = newManager();
|
|
456
|
+
const first = newSession({ manager });
|
|
457
|
+
await first.session.start();
|
|
458
|
+
|
|
459
|
+
const second = newSession({ manager });
|
|
460
|
+
await second.session.start();
|
|
461
|
+
|
|
462
|
+
expect(second.retros).toHaveLength(0);
|
|
463
|
+
|
|
464
|
+
first.session.handleClose(1000, "done");
|
|
465
|
+
expect(first.retros).toHaveLength(1);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("shutdown tears the session down without starting a retrospective", async () => {
|
|
469
|
+
const { manager } = newManager();
|
|
470
|
+
const { transcriber, session, retros } = newSession({ manager });
|
|
471
|
+
await session.start();
|
|
472
|
+
transcriber!.emit({ type: "final", text: "renaming the export" });
|
|
473
|
+
await Bun.sleep(5);
|
|
474
|
+
|
|
475
|
+
session.destroy();
|
|
476
|
+
|
|
477
|
+
// The process is going away, so a turn started here would be killed
|
|
478
|
+
// partway through. The timeline it would have read outlives the daemon.
|
|
479
|
+
expect(retros).toHaveLength(0);
|
|
480
|
+
expect(manager.isActive()).toBe(false);
|
|
481
|
+
expect(session.isClosed).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("shutdown waits for a retrospective that was already running", async () => {
|
|
485
|
+
const { manager } = newManager();
|
|
486
|
+
const { session, retros, finished } = newSession({
|
|
487
|
+
manager,
|
|
488
|
+
retroDelayMs: 30,
|
|
489
|
+
});
|
|
490
|
+
await session.start();
|
|
491
|
+
|
|
492
|
+
session.handleClose(1000, "done");
|
|
493
|
+
expect(retros).toHaveLength(1);
|
|
494
|
+
// Teardown does not block on the turn, so it is still going here.
|
|
495
|
+
expect(finished).toHaveLength(0);
|
|
496
|
+
|
|
497
|
+
expect(await drainWatchRetros(2_000)).toBe(0);
|
|
498
|
+
|
|
499
|
+
expect(finished).toHaveLength(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("a settled retrospective is forgotten, so the registry stays bounded", async () => {
|
|
503
|
+
const { manager } = newManager();
|
|
504
|
+
const first = newSession({ manager });
|
|
505
|
+
await first.session.start();
|
|
506
|
+
first.session.handleClose(1000, "done");
|
|
507
|
+
expect(await drainWatchRetros(2_000)).toBe(0);
|
|
508
|
+
|
|
509
|
+
const second = newSession({ manager });
|
|
510
|
+
await second.session.start();
|
|
511
|
+
second.session.handleClose(1000, "done");
|
|
512
|
+
|
|
513
|
+
// Only the second session's retro is outstanding. The first was dropped
|
|
514
|
+
// when it settled rather than accumulating for the life of the process.
|
|
515
|
+
expect(await drainWatchRetros(2_000)).toBe(0);
|
|
516
|
+
expect(first.finished).toHaveLength(1);
|
|
517
|
+
expect(second.finished).toHaveLength(1);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("the drain gives up rather than holding shutdown open", async () => {
|
|
521
|
+
const { manager } = newManager();
|
|
522
|
+
const { session, finished } = newSession({ manager, retroDelayMs: 400 });
|
|
523
|
+
await session.start();
|
|
524
|
+
|
|
525
|
+
session.handleClose(1000, "done");
|
|
526
|
+
const startedAtMs = Date.now();
|
|
527
|
+
const unsettled = await drainWatchRetros(20);
|
|
528
|
+
|
|
529
|
+
// It returns on its own deadline, and the unfinished turn is cut off
|
|
530
|
+
// rather than allowed to fail the shutdown that is cutting it off.
|
|
531
|
+
expect(unsettled).toBe(1);
|
|
532
|
+
expect(Date.now() - startedAtMs).toBeLessThan(300);
|
|
533
|
+
expect(finished).toHaveLength(0);
|
|
534
|
+
// Let the straggler settle so it does not leak into the next test.
|
|
535
|
+
await Bun.sleep(450);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("a session arriving after ingress closes is refused, not opened", async () => {
|
|
539
|
+
const { manager } = newManager();
|
|
540
|
+
const { ws, transcriber, session, retros } = newSession({ manager });
|
|
541
|
+
|
|
542
|
+
closeWatchIngress();
|
|
543
|
+
await session.start();
|
|
544
|
+
|
|
545
|
+
expect(ws.types()).toEqual(["error", "closed"]);
|
|
546
|
+
expect(ws.firstOfType("error")!.message).toContain("shutting down");
|
|
547
|
+
expect(ws.closeCode).toBe(1001);
|
|
548
|
+
// Nothing was claimed, so nothing is left running for the drain to miss.
|
|
549
|
+
expect(manager.isActive()).toBe(false);
|
|
550
|
+
expect(retros).toHaveLength(0);
|
|
551
|
+
expect(transcriber!.stopCount).toBe(0);
|
|
552
|
+
expect(session.isClosed).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("a session that opens during the drain registers no retrospective", async () => {
|
|
556
|
+
const { manager } = newManager();
|
|
557
|
+
const running = newSession({ manager, retroDelayMs: 60 });
|
|
558
|
+
await running.session.start();
|
|
559
|
+
running.session.handleClose(1000, "done");
|
|
560
|
+
|
|
561
|
+
// Shutdown's order: ingress first, then the open sessions, then the wait.
|
|
562
|
+
closeWatchIngress();
|
|
563
|
+
|
|
564
|
+
// A socket that opens and closes while the wait is already under way. Its
|
|
565
|
+
// retrospective would register after the drain took its snapshot, so the
|
|
566
|
+
// drain would return believing nothing was left. The long stand-in keeps
|
|
567
|
+
// it running past the drain, which is what makes the count tell the truth.
|
|
568
|
+
const latecomer = newSession({ manager, retroDelayMs: 500 });
|
|
569
|
+
setTimeout(() => {
|
|
570
|
+
void latecomer.session.start().then(() => {
|
|
571
|
+
latecomer.session.handleClose(1000, "done");
|
|
572
|
+
});
|
|
573
|
+
}, 15);
|
|
574
|
+
|
|
575
|
+
expect(await drainWatchRetros(2_000)).toBe(0);
|
|
576
|
+
|
|
577
|
+
expect(running.finished).toHaveLength(1);
|
|
578
|
+
expect(latecomer.retros).toHaveLength(0);
|
|
579
|
+
|
|
580
|
+
// Let any straggler settle so it cannot leak into the next test.
|
|
581
|
+
await Bun.sleep(600);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("a dropped socket releases the session slot", async () => {
|
|
585
|
+
const { manager } = newManager();
|
|
586
|
+
const { transcriber, session } = newSession({ manager });
|
|
587
|
+
await session.start();
|
|
588
|
+
|
|
589
|
+
session.handleClose(1006, "abnormal closure");
|
|
590
|
+
|
|
591
|
+
expect(manager.isActive()).toBe(false);
|
|
592
|
+
expect(transcriber!.stopCount).toBe(1);
|
|
593
|
+
|
|
594
|
+
// A second close is a no-op rather than a second stop.
|
|
595
|
+
session.handleClose(1006, "abnormal closure");
|
|
596
|
+
expect(transcriber!.stopCount).toBe(1);
|
|
597
|
+
});
|
|
598
|
+
});
|