@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
|
@@ -29,6 +29,22 @@ mock.module("./channel-invite-transports/whatsapp.js", () => ({
|
|
|
29
29
|
resolveWhatsAppDisplayNumber: () => undefined,
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
+
type SocketHealth = {
|
|
33
|
+
channel: string;
|
|
34
|
+
status: "connected" | "disconnected" | "not_configured" | "unsupported";
|
|
35
|
+
lastLivenessAt?: number;
|
|
36
|
+
};
|
|
37
|
+
let socketHealth: SocketHealth | Error;
|
|
38
|
+
|
|
39
|
+
mock.module("../channels/gateway-channel-socket-health.js", () => ({
|
|
40
|
+
readChannelSocketHealth: async () => {
|
|
41
|
+
if (socketHealth instanceof Error) {
|
|
42
|
+
throw socketHealth;
|
|
43
|
+
}
|
|
44
|
+
return socketHealth;
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
32
48
|
const originalFetch = globalThis.fetch;
|
|
33
49
|
let fetchCalls: Array<{ url: string; init?: RequestInit }>;
|
|
34
50
|
let fetchHandler: (
|
|
@@ -42,6 +58,7 @@ beforeEach(() => {
|
|
|
42
58
|
setConfig("slack", {});
|
|
43
59
|
fetchCalls = [];
|
|
44
60
|
fetchHandler = () => ({ ok: true, body: { ok: true } });
|
|
61
|
+
socketHealth = { channel: "slack", status: "connected" };
|
|
45
62
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
46
63
|
const url = typeof input === "string" ? input : input.toString();
|
|
47
64
|
fetchCalls.push({ url, init });
|
|
@@ -295,3 +312,127 @@ describe("slack scope-grant check", () => {
|
|
|
295
312
|
expect(scopeCheck.passed).toBe(true);
|
|
296
313
|
});
|
|
297
314
|
});
|
|
315
|
+
|
|
316
|
+
describe("slack inbound delivery", () => {
|
|
317
|
+
/**
|
|
318
|
+
* Credential health and delivery health are different claims. Valid tokens,
|
|
319
|
+
* a reachable `auth.test` and a matching workspace all hold while a socket
|
|
320
|
+
* sits open and delivers nothing, so the delivery verdict must not ride on
|
|
321
|
+
* any of them.
|
|
322
|
+
*/
|
|
323
|
+
test("fails on a dead socket even while auth.test passes", async () => {
|
|
324
|
+
// Both tokens, or the channel is not fully configured and "incomplete" is
|
|
325
|
+
// the correct answer rather than the one under test.
|
|
326
|
+
mockSecureKeys[credentialKey("slack_channel", "bot_token")] = "xoxb-fake";
|
|
327
|
+
mockSecureKeys[credentialKey("slack_channel", "app_token")] = "xapp-fake";
|
|
328
|
+
setConfig("slack", { teamId: "T123" });
|
|
329
|
+
fetchHandler = () => ({
|
|
330
|
+
ok: true,
|
|
331
|
+
body: { ok: true, team_id: "T123", team: "acme", user: "apollobot" },
|
|
332
|
+
});
|
|
333
|
+
socketHealth = { channel: "slack", status: "disconnected" };
|
|
334
|
+
|
|
335
|
+
const [snapshot] = await runSlackRemoteProbe();
|
|
336
|
+
const authTest = (snapshot.remoteChecks ?? []).find(
|
|
337
|
+
(c) => c.name === "auth_test",
|
|
338
|
+
)!;
|
|
339
|
+
const delivery = snapshot.localChecks.find(
|
|
340
|
+
(c) => c.name === "inbound_delivery",
|
|
341
|
+
)!;
|
|
342
|
+
|
|
343
|
+
expect(authTest.passed).toBe(true);
|
|
344
|
+
expect(delivery.passed).toBe(false);
|
|
345
|
+
expect(delivery.indeterminate).toBeFalsy();
|
|
346
|
+
expect(delivery.message).toMatch(/not reaching this assistant/i);
|
|
347
|
+
// The point of declaring it operational: a configured channel whose
|
|
348
|
+
// socket is dead is down, not half set up.
|
|
349
|
+
expect(snapshot.setupStatus).toBe("ready");
|
|
350
|
+
expect(snapshot.health).toBe("failing");
|
|
351
|
+
expect(snapshot.ready).toBe(false);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("passes on a live socket and reports the last keepalive", async () => {
|
|
355
|
+
socketHealth = {
|
|
356
|
+
channel: "slack",
|
|
357
|
+
status: "connected",
|
|
358
|
+
lastLivenessAt: Date.parse("2026-08-21T17:00:00.000Z"),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const [snapshot] = await runSlackRemoteProbe();
|
|
362
|
+
const delivery = snapshot.localChecks.find(
|
|
363
|
+
(c) => c.name === "inbound_delivery",
|
|
364
|
+
)!;
|
|
365
|
+
|
|
366
|
+
expect(delivery.passed).toBe(true);
|
|
367
|
+
expect(delivery.message).toContain("2026-08-21T17:00:00.000Z");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("a freshly opened socket passes before its first keepalive", async () => {
|
|
371
|
+
// The first probe is a full interval after open, so requiring a keepalive
|
|
372
|
+
// would report every healthy reconnect as broken.
|
|
373
|
+
socketHealth = { channel: "slack", status: "connected" };
|
|
374
|
+
|
|
375
|
+
const [snapshot] = await runSlackRemoteProbe();
|
|
376
|
+
const delivery = snapshot.localChecks.find(
|
|
377
|
+
(c) => c.name === "inbound_delivery",
|
|
378
|
+
)!;
|
|
379
|
+
|
|
380
|
+
expect(delivery.passed).toBe(true);
|
|
381
|
+
expect(delivery.message).toMatch(/no keepalive answered yet/i);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("an unconfigured channel is indeterminate, not broken", async () => {
|
|
385
|
+
socketHealth = { channel: "slack", status: "not_configured" };
|
|
386
|
+
|
|
387
|
+
const [snapshot] = await runSlackRemoteProbe();
|
|
388
|
+
const delivery = snapshot.localChecks.find(
|
|
389
|
+
(c) => c.name === "inbound_delivery",
|
|
390
|
+
)!;
|
|
391
|
+
|
|
392
|
+
expect(delivery.indeterminate).toBe(true);
|
|
393
|
+
expect(delivery.passed).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("an unreachable gateway is indeterminate, not an outage", async () => {
|
|
397
|
+
// A gateway we cannot reach is a fact about the gateway. Rendering it as
|
|
398
|
+
// "Slack is disconnected" would invent an outage.
|
|
399
|
+
socketHealth = new Error("gateway socket refused");
|
|
400
|
+
|
|
401
|
+
const [snapshot] = await runSlackRemoteProbe();
|
|
402
|
+
const delivery = snapshot.localChecks.find(
|
|
403
|
+
(c) => c.name === "inbound_delivery",
|
|
404
|
+
)!;
|
|
405
|
+
|
|
406
|
+
expect(delivery.indeterminate).toBe(true);
|
|
407
|
+
expect(delivery.passed).toBe(true);
|
|
408
|
+
expect(delivery.message).toMatch(/could not reach the gateway/i);
|
|
409
|
+
// Unknown, not failing: an unreachable gateway is a fact about the
|
|
410
|
+
// gateway, and the channel must be neither vouched for nor condemned.
|
|
411
|
+
expect(snapshot.health).toBe("unknown");
|
|
412
|
+
expect(snapshot.ready).toBe(false);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("runs on every request rather than riding the remote-check cache", async () => {
|
|
416
|
+
// Remote checks are cached for five minutes, which is far longer than a
|
|
417
|
+
// socket takes to die and recover, so a cached verdict would report a
|
|
418
|
+
// state the connection has already left. One service instance across both
|
|
419
|
+
// reads, or the cache under test is never populated in the first place.
|
|
420
|
+
const { createReadinessService } =
|
|
421
|
+
await import("../runtime/channel-readiness-service.js");
|
|
422
|
+
const service = createReadinessService();
|
|
423
|
+
const delivery = (
|
|
424
|
+
snapshots: Awaited<ReturnType<typeof service.getReadiness>>,
|
|
425
|
+
) => snapshots[0].localChecks.find((c) => c.name === "inbound_delivery")!;
|
|
426
|
+
|
|
427
|
+
socketHealth = { channel: "slack", status: "connected" };
|
|
428
|
+
expect(delivery(await service.getReadiness("slack", true)).passed).toBe(
|
|
429
|
+
true,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Same service, so the remote checks beside this one are now cached.
|
|
433
|
+
socketHealth = { channel: "slack", status: "disconnected" };
|
|
434
|
+
expect(delivery(await service.getReadiness("slack", true)).passed).toBe(
|
|
435
|
+
false,
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -266,7 +266,7 @@ describe("channel-reply-delivery", () => {
|
|
|
266
266
|
payload: {
|
|
267
267
|
chatId: "chat-1",
|
|
268
268
|
text: "Before tool.",
|
|
269
|
-
|
|
269
|
+
renderRichly: true,
|
|
270
270
|
attachments: undefined,
|
|
271
271
|
assistantId: "assistant-1",
|
|
272
272
|
},
|
|
@@ -276,7 +276,7 @@ describe("channel-reply-delivery", () => {
|
|
|
276
276
|
payload: {
|
|
277
277
|
chatId: "chat-1",
|
|
278
278
|
text: "After tool.",
|
|
279
|
-
|
|
279
|
+
renderRichly: true,
|
|
280
280
|
attachments,
|
|
281
281
|
assistantId: "assistant-1",
|
|
282
282
|
},
|
|
@@ -335,14 +335,14 @@ describe("channel-reply-delivery", () => {
|
|
|
335
335
|
expect(deliveryCalls[0].payload).toEqual({
|
|
336
336
|
chatId: "chat-3",
|
|
337
337
|
text: "Before tool.",
|
|
338
|
-
|
|
338
|
+
renderRichly: true,
|
|
339
339
|
attachments: undefined,
|
|
340
340
|
assistantId: "assistant-2",
|
|
341
341
|
});
|
|
342
342
|
expect(deliveryCalls[1].payload).toEqual({
|
|
343
343
|
chatId: "chat-3",
|
|
344
344
|
text: "After tool.",
|
|
345
|
-
|
|
345
|
+
renderRichly: true,
|
|
346
346
|
attachments: [
|
|
347
347
|
{
|
|
348
348
|
id: "att-2",
|
|
@@ -65,7 +65,7 @@ import type {
|
|
|
65
65
|
import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
|
|
66
66
|
import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
|
|
67
67
|
import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
|
|
68
|
-
import {
|
|
68
|
+
import { isDesktopOriginatedUserMessage } from "../persistence/conversation-types.js";
|
|
69
69
|
|
|
70
70
|
function createWebTurnContext(
|
|
71
71
|
clientOs: string | undefined,
|
|
@@ -179,9 +179,9 @@ describe("client OS surface metadata persistence", () => {
|
|
|
179
179
|
* `Conversation.clientOs` is a live field that only a transport-carrying
|
|
180
180
|
* message refreshes, so a transport-less turn stamps whatever an earlier send
|
|
181
181
|
* left there. `clientOsFromRequest` separates the two, and
|
|
182
|
-
* {@link
|
|
182
|
+
* {@link isDesktopOriginatedUserMessage} (the row-origin read behind the
|
|
183
183
|
* `chat.assistant_reply` presence gate) requires it before letting an attended
|
|
184
|
-
*
|
|
184
|
+
* desktop suppress a push.
|
|
185
185
|
*/
|
|
186
186
|
describe("client OS per-row evidence marker", () => {
|
|
187
187
|
beforeEach(() => {
|
|
@@ -198,7 +198,20 @@ describe("client OS per-row evidence marker", () => {
|
|
|
198
198
|
|
|
199
199
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
200
200
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
201
|
-
expect(
|
|
201
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("recognizes a Windows app turn as desktop-originated", async () => {
|
|
205
|
+
const ctx = createWebTurnContext("windows");
|
|
206
|
+
await persistQueuedMessageBody(ctx, {
|
|
207
|
+
content: "hello",
|
|
208
|
+
requestId: "req-transport-os-windows",
|
|
209
|
+
requestClientOs: "windows",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(lastUserMetadata().client).toEqual({ os: "windows" });
|
|
213
|
+
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
214
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
202
215
|
});
|
|
203
216
|
|
|
204
217
|
// Mirrors the Mac-originated case above: a plain browser tab reports
|
|
@@ -214,7 +227,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
214
227
|
|
|
215
228
|
expect(lastUserMetadata().client).toEqual({ os: "web" });
|
|
216
229
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
217
|
-
expect(
|
|
230
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
218
231
|
});
|
|
219
232
|
|
|
220
233
|
test("marks an OS this row's own request headers reported", async () => {
|
|
@@ -226,14 +239,14 @@ describe("client OS per-row evidence marker", () => {
|
|
|
226
239
|
});
|
|
227
240
|
|
|
228
241
|
expect(lastUserMetadata().clientOsFromRequest).toBe(true);
|
|
229
|
-
expect(
|
|
242
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(true);
|
|
230
243
|
});
|
|
231
244
|
|
|
232
245
|
// The bug shape: a button tapped on the phone reaches a conversation whose
|
|
233
246
|
// last desktop send left `clientOs: "macos"` behind. The row still carries
|
|
234
247
|
// the inherited OS for telemetry, but nothing says this turn came from the
|
|
235
248
|
// Mac, so the reply push must survive.
|
|
236
|
-
test("leaves an inherited OS unmarked and not
|
|
249
|
+
test("leaves an inherited OS unmarked and not desktop-originated", async () => {
|
|
237
250
|
const ctx = createWebTurnContext("macos");
|
|
238
251
|
await persistQueuedMessageBody(ctx, {
|
|
239
252
|
content: "[User action on card surface: submit]",
|
|
@@ -242,7 +255,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
242
255
|
|
|
243
256
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
244
257
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
245
|
-
expect(
|
|
258
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
246
259
|
});
|
|
247
260
|
|
|
248
261
|
// The marker vouches for the `client.os` that actually landed, not for
|
|
@@ -258,7 +271,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
258
271
|
|
|
259
272
|
expect(lastUserMetadata().client).toEqual({ os: "macos" });
|
|
260
273
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
261
|
-
expect(
|
|
274
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
262
275
|
});
|
|
263
276
|
|
|
264
277
|
// The marker is derived from what this persist can see, so a bag value
|
|
@@ -273,7 +286,7 @@ describe("client OS per-row evidence marker", () => {
|
|
|
273
286
|
});
|
|
274
287
|
|
|
275
288
|
expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
|
|
276
|
-
expect(
|
|
289
|
+
expect(isDesktopOriginatedUserMessage(lastUserMetadata())).toBe(false);
|
|
277
290
|
});
|
|
278
291
|
|
|
279
292
|
test("leaves the marker off for a reported OS outside the vocabulary", async () => {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A watch session's timeline is the most sensitive thing a conversation owns:
|
|
3
|
+
* what the user narrated, and frames of their screen while they narrated it.
|
|
4
|
+
* Both live in the timeline row, so every path that removes a conversation has
|
|
5
|
+
* to take those rows with it.
|
|
6
|
+
*
|
|
7
|
+
* `clearAll` is the sharpest case. Its bulk deletes run in a sqlite3
|
|
8
|
+
* subprocess, so the daemon's event loop is free between them and an append
|
|
9
|
+
* can land mid-wipe. The timeline purge is the wipe's last statement, after
|
|
10
|
+
* `conversations` is emptied: an append that beat it is a row it deletes, and
|
|
11
|
+
* one that follows it has no conversation to key itself to.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
// Keep the rest of the module real; only the Qdrant collection drop is
|
|
16
|
+
// replaced, so `clearAll` does not reach for a lexical index that is not
|
|
17
|
+
// running here.
|
|
18
|
+
const actualLexical =
|
|
19
|
+
await import("../persistence/job-handlers/message-lexical.js");
|
|
20
|
+
mock.module("../persistence/job-handlers/message-lexical.js", () => ({
|
|
21
|
+
...actualLexical,
|
|
22
|
+
clearMessagesLexicalIndex: async () => {},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An append to run from inside the wipe, on the statement it is keyed to.
|
|
27
|
+
*
|
|
28
|
+
* Which interleaving a race test runs is otherwise up to whichever subprocess
|
|
29
|
+
* happens to be slow, and the interleaving that matters is a narrow one: the
|
|
30
|
+
* append has to write its row while `conversations` is still populated, so its
|
|
31
|
+
* own existence check has nothing to catch and only the wipe's ordering keeps
|
|
32
|
+
* the row from outliving the wipe.
|
|
33
|
+
*/
|
|
34
|
+
let appendDuringWipe: { onStatement: string; run: () => void } | null = null;
|
|
35
|
+
|
|
36
|
+
// Every bulk delete in `clearAll` goes through `runAsyncSqlite`, which spawns
|
|
37
|
+
// a sqlite3 subprocess and yields the event loop. The wrapper runs the real
|
|
38
|
+
// statement and stands in for whatever else the daemon would have done while
|
|
39
|
+
// it was in flight.
|
|
40
|
+
const actualAsyncQuery = await import("../persistence/db-async-query.js");
|
|
41
|
+
const realRunAsyncSqlite = actualAsyncQuery.runAsyncSqlite;
|
|
42
|
+
mock.module("../persistence/db-async-query.js", () => ({
|
|
43
|
+
...actualAsyncQuery,
|
|
44
|
+
runAsyncSqlite: async (
|
|
45
|
+
sql: string,
|
|
46
|
+
label: string,
|
|
47
|
+
options?: Parameters<typeof actualAsyncQuery.runAsyncSqlite>[2],
|
|
48
|
+
) => {
|
|
49
|
+
const result = await realRunAsyncSqlite(sql, label, options);
|
|
50
|
+
if (appendDuringWipe && appendDuringWipe.onStatement === sql) {
|
|
51
|
+
const pending = appendDuringWipe;
|
|
52
|
+
appendDuringWipe = null;
|
|
53
|
+
pending.run();
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
clearAll,
|
|
61
|
+
createConversation,
|
|
62
|
+
deleteConversation,
|
|
63
|
+
deleteConversationGently,
|
|
64
|
+
} from "../persistence/conversation-crud.js";
|
|
65
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
66
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
67
|
+
import { attachments } from "../persistence/schema/conversations.js";
|
|
68
|
+
import type { WatchAppendResult } from "../watch/watch-timeline.js";
|
|
69
|
+
import {
|
|
70
|
+
appendNarration,
|
|
71
|
+
appendObservation,
|
|
72
|
+
readWatchScreenshot,
|
|
73
|
+
renderWatchTimeline,
|
|
74
|
+
} from "../watch/watch-timeline.js";
|
|
75
|
+
|
|
76
|
+
await initializeDb();
|
|
77
|
+
|
|
78
|
+
/** A 1x1 JPEG, the shape the host hands over on every observe. */
|
|
79
|
+
const SCREENSHOT_BASE64 =
|
|
80
|
+
"/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==";
|
|
81
|
+
|
|
82
|
+
interface WatchedConversation {
|
|
83
|
+
readonly conversationId: string;
|
|
84
|
+
readonly sessionId: string;
|
|
85
|
+
readonly entryId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A conversation with one narration and one screenshot-carrying observation. */
|
|
89
|
+
function seedWatchedConversation(title: string): WatchedConversation {
|
|
90
|
+
const conversation = createConversation(title);
|
|
91
|
+
const sessionId = `watch-${conversation.id}`;
|
|
92
|
+
|
|
93
|
+
appendObservation(sessionId, {
|
|
94
|
+
conversationId: conversation.id,
|
|
95
|
+
atMs: 1_000,
|
|
96
|
+
observation: { axTree: "window Mail", screenshot: SCREENSHOT_BASE64 },
|
|
97
|
+
attachScreenshot: true,
|
|
98
|
+
});
|
|
99
|
+
appendNarration(sessionId, {
|
|
100
|
+
conversationId: conversation.id,
|
|
101
|
+
atMs: 2_000,
|
|
102
|
+
text: "this is how I file it",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const rendered = renderWatchTimeline(sessionId);
|
|
106
|
+
expect(rendered.totalEntries).toBe(2);
|
|
107
|
+
const entryId = rendered.screenshotEntryIds[0];
|
|
108
|
+
expect(entryId).toBeDefined();
|
|
109
|
+
expect(readWatchScreenshot(entryId as string)).not.toBeNull();
|
|
110
|
+
|
|
111
|
+
return { conversationId: conversation.id, sessionId, entryId: entryId ?? "" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function expectPurged(watched: WatchedConversation): void {
|
|
115
|
+
expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
|
|
116
|
+
expect(readWatchScreenshot(watched.entryId)).toBeNull();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every attachment row in the store. */
|
|
120
|
+
function attachmentCount(): number {
|
|
121
|
+
return getDb().select({ id: attachments.id }).from(attachments).all().length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("deleteConversation purges the watch timeline", () => {
|
|
125
|
+
test("takes the entries and the frames they carry", () => {
|
|
126
|
+
const watched = seedWatchedConversation("watched-sync");
|
|
127
|
+
const other = seedWatchedConversation("watched-sync-other");
|
|
128
|
+
|
|
129
|
+
// A timeline keeps its screenshots in its own rows, so watching a session
|
|
130
|
+
// stages nothing in the attachment store for a delete to chase.
|
|
131
|
+
expect(attachmentCount()).toBe(0);
|
|
132
|
+
|
|
133
|
+
deleteConversation(watched.conversationId);
|
|
134
|
+
|
|
135
|
+
expectPurged(watched);
|
|
136
|
+
// Scoped to the conversation being deleted, not the whole store.
|
|
137
|
+
expect(renderWatchTimeline(other.sessionId).totalEntries).toBe(2);
|
|
138
|
+
expect(readWatchScreenshot(other.entryId)).not.toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("deleteConversationGently purges the watch timeline", () => {
|
|
143
|
+
test("the off-loop path clears the same state as the synchronous one", async () => {
|
|
144
|
+
const watched = seedWatchedConversation("watched-gentle");
|
|
145
|
+
|
|
146
|
+
await deleteConversationGently(watched.conversationId);
|
|
147
|
+
|
|
148
|
+
expectPurged(watched);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("an append that lands after the delete", () => {
|
|
153
|
+
test("is refused and leaves no row", () => {
|
|
154
|
+
const watched = seedWatchedConversation("watched-late-append");
|
|
155
|
+
|
|
156
|
+
deleteConversation(watched.conversationId);
|
|
157
|
+
const result = appendObservation(watched.sessionId, {
|
|
158
|
+
conversationId: watched.conversationId,
|
|
159
|
+
atMs: 3_000,
|
|
160
|
+
observation: { axTree: "window Mail", screenshot: SCREENSHOT_BASE64 },
|
|
161
|
+
attachScreenshot: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({ ok: false, reason: "conversation_missing" });
|
|
165
|
+
expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("clearAll purges every watch timeline", () => {
|
|
170
|
+
test("wipes the entries and the frames they carry", async () => {
|
|
171
|
+
const first = seedWatchedConversation("watched-clear-all-a");
|
|
172
|
+
const second = seedWatchedConversation("watched-clear-all-b");
|
|
173
|
+
|
|
174
|
+
await clearAll();
|
|
175
|
+
|
|
176
|
+
expectPurged(first);
|
|
177
|
+
expectPurged(second);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("an append that lands inside a clear-all", () => {
|
|
182
|
+
test("does not outlive the wipe that overtook it", async () => {
|
|
183
|
+
const watched = seedWatchedConversation("watched-clear-all-race");
|
|
184
|
+
|
|
185
|
+
// The append runs where the wipe is most exposed: mid-sequence, with
|
|
186
|
+
// `conversations` still populated so its existence check has nothing to
|
|
187
|
+
// catch. Only the timeline purge coming after `conversations` keeps the
|
|
188
|
+
// row from surviving.
|
|
189
|
+
let landed: WatchAppendResult | null = null;
|
|
190
|
+
appendDuringWipe = {
|
|
191
|
+
onStatement: "DELETE FROM messages",
|
|
192
|
+
run: () => {
|
|
193
|
+
landed = appendObservation(watched.sessionId, {
|
|
194
|
+
conversationId: watched.conversationId,
|
|
195
|
+
atMs: 3_000,
|
|
196
|
+
observation: {
|
|
197
|
+
axTree: "window Mail",
|
|
198
|
+
screenshot: SCREENSHOT_BASE64,
|
|
199
|
+
},
|
|
200
|
+
attachScreenshot: true,
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
await clearAll();
|
|
206
|
+
|
|
207
|
+
// The row really did land, so the wipe had something to sweep.
|
|
208
|
+
const result = landed as WatchAppendResult | null;
|
|
209
|
+
expect(result?.ok).toBe(true);
|
|
210
|
+
expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
|
|
211
|
+
expect(
|
|
212
|
+
readWatchScreenshot(result?.ok === true ? result.entryId : ""),
|
|
213
|
+
).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("an append that arrives once the clear-all is done", () => {
|
|
218
|
+
test("is refused, because there is no conversation left to key it to", async () => {
|
|
219
|
+
const watched = seedWatchedConversation("watched-clear-all-after");
|
|
220
|
+
|
|
221
|
+
await clearAll();
|
|
222
|
+
const result = appendNarration(watched.sessionId, {
|
|
223
|
+
conversationId: watched.conversationId,
|
|
224
|
+
atMs: 3_000,
|
|
225
|
+
text: "too late",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result).toEqual({ ok: false, reason: "conversation_missing" });
|
|
229
|
+
expect(renderWatchTimeline(watched.sessionId).totalEntries).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -582,6 +582,23 @@ describe("classifyConversationError", () => {
|
|
|
582
582
|
expect(result.retryable).toBe(true);
|
|
583
583
|
expect(result.errorCategory).toBe("tool_ordering");
|
|
584
584
|
});
|
|
585
|
+
|
|
586
|
+
it("classifies user-terminal history rejections separately", () => {
|
|
587
|
+
/** User-terminal provider rejections receive a dedicated category. */
|
|
588
|
+
|
|
589
|
+
// GIVEN a provider error whose history ends with a model turn
|
|
590
|
+
const error = new Error(
|
|
591
|
+
"Requests ending with a model turn are not supported.",
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// WHEN the conversation error is classified
|
|
595
|
+
const result = classifyConversationError(error, baseCtx);
|
|
596
|
+
|
|
597
|
+
// THEN it is a retryable ordering error in the user-terminal category
|
|
598
|
+
expect(result.code).toBe("PROVIDER_ORDERING");
|
|
599
|
+
expect(result.retryable).toBe(true);
|
|
600
|
+
expect(result.errorCategory).toBe("history_user_terminal");
|
|
601
|
+
});
|
|
585
602
|
});
|
|
586
603
|
|
|
587
604
|
describe("web search ordering errors", () => {
|
|
@@ -71,6 +71,14 @@ describe("resolveVerbosity", () => {
|
|
|
71
71
|
).toBe("rich");
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("explicit interfaceHint=windows overrides to rich", () => {
|
|
75
|
+
expect(
|
|
76
|
+
resolveVerbosity("telegram" as NotificationChannel, {
|
|
77
|
+
interfaceHint: "windows",
|
|
78
|
+
}),
|
|
79
|
+
).toBe("rich");
|
|
80
|
+
});
|
|
81
|
+
|
|
74
82
|
test("explicit interfaceHint=telegram overrides to compact", () => {
|
|
75
83
|
expect(
|
|
76
84
|
resolveVerbosity("vellum" as NotificationChannel, {
|
|
@@ -49,6 +49,14 @@ describe("resolveSlash /commands interface-aware help", () => {
|
|
|
49
49
|
]);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test("renders desktop command help for Windows", async () => {
|
|
53
|
+
const lines = await resolveCommandsLines(
|
|
54
|
+
makeSlashContext({ userMessageInterface: "windows" }),
|
|
55
|
+
);
|
|
56
|
+
expect(lines.some((line) => line.startsWith("/btw "))).toBe(true);
|
|
57
|
+
expect(lines.some((line) => line.startsWith("/fork "))).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
52
60
|
test("renders iOS command help with /fork", async () => {
|
|
53
61
|
const lines = await resolveCommandsLines(
|
|
54
62
|
makeSlashContext({ userMessageInterface: "ios" }),
|
|
@@ -90,6 +90,12 @@ describe("classifyDiskPressureTurnPolicy", () => {
|
|
|
90
90
|
metadata: localOwnerTurn,
|
|
91
91
|
expected: { action: "allow-cleanup-mode", reason: "local-owner" },
|
|
92
92
|
},
|
|
93
|
+
{
|
|
94
|
+
name: "locked Windows local owner without trust enters cleanup mode",
|
|
95
|
+
status: status({ acknowledged: true }),
|
|
96
|
+
metadata: { ...localOwnerTurn, sourceInterface: "windows" },
|
|
97
|
+
expected: { action: "allow-cleanup-mode", reason: "local-owner" },
|
|
98
|
+
},
|
|
93
99
|
{
|
|
94
100
|
name: "locked guardian enters cleanup mode",
|
|
95
101
|
status: status(),
|