@vellumai/assistant 0.11.1 → 0.11.2-staging.2
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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `assistant-reply-producer.ts`: which finished replies earn a push,
|
|
3
|
+
* and the shape of the signal the qualifying ones emit.
|
|
4
|
+
*/
|
|
5
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
|
|
8
|
+
import type { AttentionState } from "../../persistence/conversation-attention-store.js";
|
|
9
|
+
import type {
|
|
10
|
+
ConversationRow,
|
|
11
|
+
MessageRow,
|
|
12
|
+
} from "../../persistence/conversation-crud.js";
|
|
13
|
+
import {
|
|
14
|
+
MEMORY_V2_CONSOLIDATION_SOURCE,
|
|
15
|
+
resolveConversationKind,
|
|
16
|
+
} from "../../persistence/conversation-types.js";
|
|
17
|
+
import type { ContentBlock } from "../../providers/types.js";
|
|
18
|
+
import { NOTIFICATION_TITLE_MAX_LENGTH } from "../notification-utils.js";
|
|
19
|
+
|
|
20
|
+
// ── Module mocks ───────────────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// `mock.module` is hoisted, so these intercepts apply before the module under
|
|
23
|
+
// test resolves its imports. Each test rewrites the module-scoped fixtures
|
|
24
|
+
// below and inspects the captured emit calls afterwards.
|
|
25
|
+
|
|
26
|
+
const emitCalls: any[] = [];
|
|
27
|
+
const messageLookups: string[] = [];
|
|
28
|
+
let conversationRow: ConversationRow | null = null;
|
|
29
|
+
let assistantRow: MessageRow | null = null;
|
|
30
|
+
let initiatingRow: MessageRow | null = null;
|
|
31
|
+
let attentionState: AttentionState | null = null;
|
|
32
|
+
let getConversationShouldThrow = false;
|
|
33
|
+
|
|
34
|
+
mock.module("../emit-signal.js", () => ({
|
|
35
|
+
emitNotificationSignal: async (params: any) => {
|
|
36
|
+
emitCalls.push(params);
|
|
37
|
+
return {
|
|
38
|
+
signalId: "sig-1",
|
|
39
|
+
deduplicated: false,
|
|
40
|
+
dispatched: true,
|
|
41
|
+
reason: "ok",
|
|
42
|
+
deliveryResults: [],
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const CONVERSATION_ID = "conv-1";
|
|
48
|
+
const ASSISTANT_MESSAGE_ID = "msg-assistant-1";
|
|
49
|
+
const USER_MESSAGE_ID = "msg-user-1";
|
|
50
|
+
|
|
51
|
+
const realCrud = await import("../../persistence/conversation-crud.js");
|
|
52
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
53
|
+
...realCrud,
|
|
54
|
+
getConversation: () => {
|
|
55
|
+
if (getConversationShouldThrow) {
|
|
56
|
+
throw new Error("simulated conversation lookup failure");
|
|
57
|
+
}
|
|
58
|
+
return conversationRow;
|
|
59
|
+
},
|
|
60
|
+
getMessageById: (messageId: string) => {
|
|
61
|
+
messageLookups.push(messageId);
|
|
62
|
+
return messageId === ASSISTANT_MESSAGE_ID ? assistantRow : initiatingRow;
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// Attachments the assistant row carries. Linked by the agent loop before the
|
|
67
|
+
// producer runs, and exposed separately from the row's content blocks.
|
|
68
|
+
let assistantAttachments: Array<{ originalFilename: string }> = [];
|
|
69
|
+
const attachmentLookups: string[] = [];
|
|
70
|
+
const realAttachmentsStore =
|
|
71
|
+
await import("../../persistence/attachments-store.js");
|
|
72
|
+
mock.module("../../persistence/attachments-store.js", () => ({
|
|
73
|
+
...realAttachmentsStore,
|
|
74
|
+
getAttachmentMetadataForMessage: (messageId: string) => {
|
|
75
|
+
attachmentLookups.push(messageId);
|
|
76
|
+
return assistantAttachments;
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const realAttentionStore =
|
|
81
|
+
await import("../../persistence/conversation-attention-store.js");
|
|
82
|
+
mock.module("../../persistence/conversation-attention-store.js", () => ({
|
|
83
|
+
...realAttentionStore,
|
|
84
|
+
getAttentionStateByConversationIds: (ids: string[]) => {
|
|
85
|
+
const map = new Map<string, AttentionState>();
|
|
86
|
+
if (attentionState) {
|
|
87
|
+
map.set(ids[0], attentionState);
|
|
88
|
+
}
|
|
89
|
+
return map;
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const { emitAssistantReplyNotification } =
|
|
94
|
+
await import("../assistant-reply-producer.js");
|
|
95
|
+
|
|
96
|
+
// ── Fixtures ───────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function makeConversation(
|
|
99
|
+
overrides: Partial<ConversationRow> = {},
|
|
100
|
+
): ConversationRow {
|
|
101
|
+
return {
|
|
102
|
+
id: CONVERSATION_ID,
|
|
103
|
+
title: "Weekend plans",
|
|
104
|
+
createdAt: 1700000000000,
|
|
105
|
+
updatedAt: 1700000000000,
|
|
106
|
+
totalInputTokens: 0,
|
|
107
|
+
totalOutputTokens: 0,
|
|
108
|
+
totalEstimatedCost: 0,
|
|
109
|
+
contextSummary: null,
|
|
110
|
+
contextCompactedMessageCount: 0,
|
|
111
|
+
contextCompactedAt: null,
|
|
112
|
+
historyStrippedAt: null,
|
|
113
|
+
slackContextCompactionWatermarkTs: null,
|
|
114
|
+
slackContextCompactionWatermarkAt: null,
|
|
115
|
+
conversationType: "chat",
|
|
116
|
+
source: "user",
|
|
117
|
+
originChannel: null,
|
|
118
|
+
originInterface: null,
|
|
119
|
+
forkParentConversationId: null,
|
|
120
|
+
forkParentMessageId: null,
|
|
121
|
+
isAutoTitle: 0,
|
|
122
|
+
scheduleJobId: null,
|
|
123
|
+
lastMessageAt: null,
|
|
124
|
+
archivedAt: null,
|
|
125
|
+
surfacedAt: null,
|
|
126
|
+
inferenceProfile: null,
|
|
127
|
+
enabledPlugins: null,
|
|
128
|
+
inferenceProfileSessionId: null,
|
|
129
|
+
inferenceProfileExpiresAt: null,
|
|
130
|
+
lastNotifiedInferenceProfile: null,
|
|
131
|
+
processingStartedAt: null,
|
|
132
|
+
...overrides,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function makeMessage(overrides: Partial<MessageRow> = {}): MessageRow {
|
|
137
|
+
return {
|
|
138
|
+
id: USER_MESSAGE_ID,
|
|
139
|
+
conversationId: CONVERSATION_ID,
|
|
140
|
+
role: "user",
|
|
141
|
+
content: [{ type: "text", text: "hello" }] as ContentBlock[],
|
|
142
|
+
createdAt: 1700000000100,
|
|
143
|
+
metadata: null,
|
|
144
|
+
clientMessageId: null,
|
|
145
|
+
finalized: 1,
|
|
146
|
+
...overrides,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function makeAssistantRow(content: ContentBlock[]): MessageRow {
|
|
151
|
+
return makeMessage({
|
|
152
|
+
id: ASSISTANT_MESSAGE_ID,
|
|
153
|
+
role: "assistant",
|
|
154
|
+
content,
|
|
155
|
+
createdAt: 1700000000200,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function makeAttentionState(
|
|
160
|
+
overrides: Partial<AttentionState> = {},
|
|
161
|
+
): AttentionState {
|
|
162
|
+
return {
|
|
163
|
+
conversationId: CONVERSATION_ID,
|
|
164
|
+
latestAssistantMessageId: ASSISTANT_MESSAGE_ID,
|
|
165
|
+
latestAssistantMessageAt: 1700000000200,
|
|
166
|
+
lastSeenAssistantMessageId: null,
|
|
167
|
+
lastSeenAssistantMessageAt: null,
|
|
168
|
+
lastSeenEventAt: null,
|
|
169
|
+
lastSeenConfidence: null,
|
|
170
|
+
lastSeenSignalType: null,
|
|
171
|
+
lastSeenSourceChannel: null,
|
|
172
|
+
lastSeenSource: null,
|
|
173
|
+
lastSeenEvidenceText: null,
|
|
174
|
+
createdAt: 1700000000000,
|
|
175
|
+
updatedAt: 1700000000200,
|
|
176
|
+
...overrides,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const NON_USER_KIND_CASES: Array<{
|
|
181
|
+
name: string;
|
|
182
|
+
overrides: Partial<ConversationRow>;
|
|
183
|
+
}> = [
|
|
184
|
+
{ name: "background", overrides: { conversationType: "background" } },
|
|
185
|
+
{ name: "scheduled", overrides: { conversationType: "scheduled" } },
|
|
186
|
+
{
|
|
187
|
+
name: "memory-consolidation",
|
|
188
|
+
overrides: { source: MEMORY_V2_CONSOLIDATION_SOURCE },
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const warnCalls: unknown[] = [];
|
|
193
|
+
const rlog = {
|
|
194
|
+
warn: (...args: unknown[]) => {
|
|
195
|
+
warnCalls.push(args);
|
|
196
|
+
},
|
|
197
|
+
} as any;
|
|
198
|
+
|
|
199
|
+
async function run(
|
|
200
|
+
overrides: Partial<Parameters<typeof emitAssistantReplyNotification>[0]> = {},
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
await emitAssistantReplyNotification({
|
|
203
|
+
conversationId: CONVERSATION_ID,
|
|
204
|
+
assistantMessageId: ASSISTANT_MESSAGE_ID,
|
|
205
|
+
userMessageId: USER_MESSAGE_ID,
|
|
206
|
+
rlog,
|
|
207
|
+
...overrides,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
emitCalls.length = 0;
|
|
213
|
+
warnCalls.length = 0;
|
|
214
|
+
messageLookups.length = 0;
|
|
215
|
+
attachmentLookups.length = 0;
|
|
216
|
+
assistantAttachments = [];
|
|
217
|
+
getConversationShouldThrow = false;
|
|
218
|
+
conversationRow = makeConversation();
|
|
219
|
+
assistantRow = makeAssistantRow([
|
|
220
|
+
{ type: "text", text: "Sure, here is the plan." },
|
|
221
|
+
] as ContentBlock[]);
|
|
222
|
+
initiatingRow = makeMessage();
|
|
223
|
+
attentionState = makeAttentionState();
|
|
224
|
+
// Empty overrides = every flag resolves to its registry default, so the
|
|
225
|
+
// kill switch reads as enabled unless a case says otherwise.
|
|
226
|
+
setOverridesForTesting({});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ── Tests ──────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
describe("emitAssistantReplyNotification", () => {
|
|
232
|
+
test("emits one well-formed signal for an unseen user-conversation reply", async () => {
|
|
233
|
+
await run();
|
|
234
|
+
|
|
235
|
+
expect(emitCalls).toHaveLength(1);
|
|
236
|
+
expect(emitCalls[0]).toEqual({
|
|
237
|
+
sourceEventName: "chat.assistant_reply",
|
|
238
|
+
sourceChannel: "vellum",
|
|
239
|
+
sourceContextId: CONVERSATION_ID,
|
|
240
|
+
attentionHints: {
|
|
241
|
+
requiresAction: false,
|
|
242
|
+
urgency: "medium",
|
|
243
|
+
isAsyncBackground: false,
|
|
244
|
+
visibleInSourceNow: false,
|
|
245
|
+
},
|
|
246
|
+
contextPayload: {
|
|
247
|
+
requestedTitle: "Weekend plans",
|
|
248
|
+
requestedMessage: "Sure, here is the plan.",
|
|
249
|
+
},
|
|
250
|
+
dedupeKey: `chat.assistant_reply:${CONVERSATION_ID}:${ASSISTANT_MESSAGE_ID}`,
|
|
251
|
+
});
|
|
252
|
+
// No conversation-creation fields: the platform channel is push-only.
|
|
253
|
+
expect("requiresConversation" in emitCalls[0]).toBe(false);
|
|
254
|
+
expect("conversationAffinityHint" in emitCalls[0]).toBe(false);
|
|
255
|
+
expect("routingIntent" in emitCalls[0]).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("stays silent when the kill-switch flag is off", async () => {
|
|
259
|
+
setOverridesForTesting({ "assistant-reply-push": false });
|
|
260
|
+
|
|
261
|
+
await run();
|
|
262
|
+
|
|
263
|
+
expect(emitCalls).toHaveLength(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("omits requestedTitle when the conversation has no title", async () => {
|
|
267
|
+
conversationRow = makeConversation({ title: " " });
|
|
268
|
+
|
|
269
|
+
await run();
|
|
270
|
+
|
|
271
|
+
expect(emitCalls).toHaveLength(1);
|
|
272
|
+
expect(emitCalls[0].contextPayload).toEqual({
|
|
273
|
+
requestedMessage: "Sure, here is the plan.",
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Titles are user-controlled (renames, imports), so they get the same
|
|
278
|
+
// treatment as the body rather than riding into the payload verbatim.
|
|
279
|
+
test("strips control characters and newlines out of the title", async () => {
|
|
280
|
+
conversationRow = makeConversation({
|
|
281
|
+
title: "Weekend\nplans\u0007today",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await run();
|
|
285
|
+
|
|
286
|
+
expect(emitCalls[0].contextPayload.requestedTitle).toBe(
|
|
287
|
+
"Weekend plans today",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("caps the title at the shared title budget", async () => {
|
|
292
|
+
conversationRow = makeConversation({ title: "T".repeat(300) });
|
|
293
|
+
|
|
294
|
+
await run();
|
|
295
|
+
|
|
296
|
+
const title = emitCalls[0].contextPayload.requestedTitle as string;
|
|
297
|
+
expect(title).toHaveLength(NOTIFICATION_TITLE_MAX_LENGTH);
|
|
298
|
+
expect(title.endsWith("…")).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("omits requestedTitle when the title sanitizes to nothing", async () => {
|
|
302
|
+
conversationRow = makeConversation({ title: "\u0000\u0007" });
|
|
303
|
+
|
|
304
|
+
await run();
|
|
305
|
+
|
|
306
|
+
expect(emitCalls).toHaveLength(1);
|
|
307
|
+
expect(emitCalls[0].contextPayload).toEqual({
|
|
308
|
+
requestedMessage: "Sure, here is the plan.",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("caps the preview at 200 chars", async () => {
|
|
313
|
+
assistantRow = makeAssistantRow([
|
|
314
|
+
{ type: "text", text: "a".repeat(300) },
|
|
315
|
+
] as ContentBlock[]);
|
|
316
|
+
|
|
317
|
+
await run();
|
|
318
|
+
|
|
319
|
+
const preview = emitCalls[0].contextPayload.requestedMessage as string;
|
|
320
|
+
expect(preview).toHaveLength(200);
|
|
321
|
+
expect(preview.endsWith("…")).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Control characters would otherwise ride into the APNs payload verbatim.
|
|
325
|
+
test("strips control characters and newlines out of the preview", async () => {
|
|
326
|
+
assistantRow = makeAssistantRow([
|
|
327
|
+
{ type: "text", text: "Hello\n\tthere" },
|
|
328
|
+
] as ContentBlock[]);
|
|
329
|
+
|
|
330
|
+
await run();
|
|
331
|
+
|
|
332
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe("Hello there");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Blank lines and list indentation would otherwise spend the preview's
|
|
336
|
+
// length budget on whitespace.
|
|
337
|
+
test("collapses whitespace runs in the preview", async () => {
|
|
338
|
+
assistantRow = makeAssistantRow([
|
|
339
|
+
{ type: "text", text: " Here:\n\n - item\n - other " },
|
|
340
|
+
] as ContentBlock[]);
|
|
341
|
+
|
|
342
|
+
await run();
|
|
343
|
+
|
|
344
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
345
|
+
"Here: - item - other",
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Each case asserts the shared classifier's verdict alongside the silence, so
|
|
350
|
+
// the gate is exercised through `resolveConversationKind` rather than through
|
|
351
|
+
// a restatement of its branches.
|
|
352
|
+
for (const { name, overrides } of NON_USER_KIND_CASES) {
|
|
353
|
+
test(`stays silent for a ${name} conversation`, async () => {
|
|
354
|
+
conversationRow = makeConversation(overrides);
|
|
355
|
+
|
|
356
|
+
expect(
|
|
357
|
+
resolveConversationKind(
|
|
358
|
+
conversationRow.source,
|
|
359
|
+
conversationRow.conversationType,
|
|
360
|
+
),
|
|
361
|
+
).not.toBe("user");
|
|
362
|
+
|
|
363
|
+
await run();
|
|
364
|
+
|
|
365
|
+
expect(emitCalls).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
test("stays silent when the conversation is missing", async () => {
|
|
370
|
+
conversationRow = null;
|
|
371
|
+
|
|
372
|
+
await run();
|
|
373
|
+
|
|
374
|
+
expect(emitCalls).toHaveLength(0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("stays silent when the initiating user message is automated", async () => {
|
|
378
|
+
initiatingRow = makeMessage({
|
|
379
|
+
metadata: JSON.stringify({ automated: true }),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await run();
|
|
383
|
+
|
|
384
|
+
expect(emitCalls).toHaveLength(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Machine-signal rows persist with role "user" and are neither tool results
|
|
388
|
+
// nor `automated`, so only the shared echo-suppression classifier keeps a
|
|
389
|
+
// turn opened by one of them from pushing a reply.
|
|
390
|
+
const LIFECYCLE_ROW_CASES: Array<{ name: string; metadata: unknown }> = [
|
|
391
|
+
{
|
|
392
|
+
name: "subagent notification",
|
|
393
|
+
metadata: {
|
|
394
|
+
subagentNotification: {
|
|
395
|
+
subagentId: "sub-1",
|
|
396
|
+
label: "researcher",
|
|
397
|
+
status: "running",
|
|
398
|
+
conversationId: "conv-child-1",
|
|
399
|
+
objective: "look something up",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "ACP notification",
|
|
405
|
+
metadata: { acpNotification: { acpSessionId: "acp-1", agent: "codex" } },
|
|
406
|
+
},
|
|
407
|
+
{ name: "hidden machine signal", metadata: { hidden: true } },
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
for (const { name, metadata } of LIFECYCLE_ROW_CASES) {
|
|
411
|
+
test(`stays silent when the turn was opened by a ${name} row`, async () => {
|
|
412
|
+
initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
|
|
413
|
+
|
|
414
|
+
await run();
|
|
415
|
+
|
|
416
|
+
expect(emitCalls).toHaveLength(0);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A pointer turn fact-checks the rows it generated only after the turn ends
|
|
421
|
+
// and deletes them when validation fails, so a push at turn end would have
|
|
422
|
+
// already carried a call outcome the deterministic fallback then replaces.
|
|
423
|
+
test("stays silent when the turn was opened by a pointer instruction", async () => {
|
|
424
|
+
initiatingRow = makeMessage({
|
|
425
|
+
metadata: JSON.stringify({ pointerInstruction: true }),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await run();
|
|
429
|
+
|
|
430
|
+
expect(emitCalls).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// The phone case carries a `phone` channel; the in-app live-voice case is
|
|
434
|
+
// `vellum`/`macos`, identical to a typed desktop send, which is why the
|
|
435
|
+
// channel field cannot stand in for the `voiceSessionTurn` marker.
|
|
436
|
+
const VOICE_ROW_CASES: Array<{ name: string; metadata: unknown }> = [
|
|
437
|
+
{
|
|
438
|
+
name: "phone call",
|
|
439
|
+
metadata: {
|
|
440
|
+
voiceSessionTurn: true,
|
|
441
|
+
userMessageChannel: "phone",
|
|
442
|
+
userMessageInterface: "phone",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "in-app live voice",
|
|
447
|
+
metadata: {
|
|
448
|
+
voiceSessionTurn: true,
|
|
449
|
+
userMessageChannel: "vellum",
|
|
450
|
+
userMessageInterface: "macos",
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
for (const { name, metadata } of VOICE_ROW_CASES) {
|
|
456
|
+
test(`stays silent when the turn was opened by a ${name} utterance`, async () => {
|
|
457
|
+
initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
|
|
458
|
+
|
|
459
|
+
await run();
|
|
460
|
+
|
|
461
|
+
expect(emitCalls).toHaveLength(0);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
test("still emits for a typed desktop send on the same channel", async () => {
|
|
466
|
+
initiatingRow = makeMessage({
|
|
467
|
+
metadata: JSON.stringify({
|
|
468
|
+
userMessageChannel: "vellum",
|
|
469
|
+
userMessageInterface: "macos",
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
await run();
|
|
474
|
+
|
|
475
|
+
expect(emitCalls).toHaveLength(1);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// A channel turn's reply is delivered back to the originating surface, so the
|
|
479
|
+
// sender already has it in Slack/Telegram; a push would be a second copy.
|
|
480
|
+
for (const channel of ["slack", "telegram"] as const) {
|
|
481
|
+
test(`stays silent for a turn opened from ${channel}`, async () => {
|
|
482
|
+
initiatingRow = makeMessage({
|
|
483
|
+
metadata: JSON.stringify({
|
|
484
|
+
userMessageChannel: channel,
|
|
485
|
+
assistantMessageChannel: channel,
|
|
486
|
+
}),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await run();
|
|
490
|
+
|
|
491
|
+
expect(emitCalls).toHaveLength(0);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Rows predating the channel stamp (and the daemon paths that omit it) are
|
|
496
|
+
// in-app turns, so an absent channel must not suppress the push.
|
|
497
|
+
test("still emits when the initiating row carries no channel", async () => {
|
|
498
|
+
initiatingRow = makeMessage({
|
|
499
|
+
metadata: JSON.stringify({ userMessageInterface: "web" }),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await run();
|
|
503
|
+
|
|
504
|
+
expect(emitCalls).toHaveLength(1);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// An unrecognized channel fails the whole metadata schema, so these two
|
|
508
|
+
// cases pin both halves of the permissive fallback: a suppression marker
|
|
509
|
+
// sharing the row still closes its gate, and a row whose only defect is the
|
|
510
|
+
// channel still notifies.
|
|
511
|
+
test("stays silent when a row with an unrecognized channel is also hidden", async () => {
|
|
512
|
+
initiatingRow = makeMessage({
|
|
513
|
+
metadata: JSON.stringify({
|
|
514
|
+
userMessageChannel: "not-a-channel",
|
|
515
|
+
hidden: true,
|
|
516
|
+
}),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await run();
|
|
520
|
+
|
|
521
|
+
expect(emitCalls).toHaveLength(0);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("still emits when the initiating row carries an unrecognized channel", async () => {
|
|
525
|
+
initiatingRow = makeMessage({
|
|
526
|
+
metadata: JSON.stringify({ userMessageChannel: "not-a-channel" }),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
await run();
|
|
530
|
+
|
|
531
|
+
expect(emitCalls).toHaveLength(1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// `replyDeliveredInAppOnly` turns (the retry route re-running a stored
|
|
535
|
+
// anchor) carry none of the anchor's delivery orchestration: nothing is
|
|
536
|
+
// posted back to the channel and no voice session speaks the reply, so the
|
|
537
|
+
// push is the user's only copy.
|
|
538
|
+
const OFF_APP_DELIVERY_CASES: Array<{ name: string; metadata: unknown }> = [
|
|
539
|
+
{
|
|
540
|
+
name: "a Slack anchor",
|
|
541
|
+
metadata: {
|
|
542
|
+
userMessageChannel: "slack",
|
|
543
|
+
assistantMessageChannel: "slack",
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{ name: "a Telegram anchor", metadata: { userMessageChannel: "telegram" } },
|
|
547
|
+
{
|
|
548
|
+
name: "a phone-call anchor",
|
|
549
|
+
metadata: { voiceSessionTurn: true, userMessageChannel: "phone" },
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "an in-app live-voice anchor",
|
|
553
|
+
metadata: { voiceSessionTurn: true, userMessageChannel: "vellum" },
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
for (const { name, metadata } of OFF_APP_DELIVERY_CASES) {
|
|
558
|
+
test(`emits for an app-only re-run of ${name}`, async () => {
|
|
559
|
+
initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
|
|
560
|
+
|
|
561
|
+
await run({ replyDeliveredInAppOnly: true });
|
|
562
|
+
|
|
563
|
+
expect(emitCalls).toHaveLength(1);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The row-shape reasons are properties of the anchor itself, so an app-only
|
|
568
|
+
// re-run does not reopen them.
|
|
569
|
+
const ROW_SHAPE_SUPPRESSION_CASES: Array<{
|
|
570
|
+
name: string;
|
|
571
|
+
metadata: unknown;
|
|
572
|
+
}> = [
|
|
573
|
+
{ name: "automated", metadata: { automated: true } },
|
|
574
|
+
{ name: "pointer-instruction", metadata: { pointerInstruction: true } },
|
|
575
|
+
{ name: "hidden machine-signal", metadata: { hidden: true } },
|
|
576
|
+
{
|
|
577
|
+
name: "background-event",
|
|
578
|
+
metadata: { backgroundEventSource: "schedule" },
|
|
579
|
+
},
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
for (const { name, metadata } of ROW_SHAPE_SUPPRESSION_CASES) {
|
|
583
|
+
test(`stays silent for an app-only re-run of a ${name} anchor`, async () => {
|
|
584
|
+
initiatingRow = makeMessage({ metadata: JSON.stringify(metadata) });
|
|
585
|
+
|
|
586
|
+
await run({ replyDeliveredInAppOnly: true });
|
|
587
|
+
|
|
588
|
+
expect(emitCalls).toHaveLength(0);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
test("reads the initiating row by the threaded id, not by scanning", async () => {
|
|
593
|
+
await run();
|
|
594
|
+
|
|
595
|
+
expect(messageLookups).toEqual([ASSISTANT_MESSAGE_ID, USER_MESSAGE_ID]);
|
|
596
|
+
expect(emitCalls).toHaveLength(1);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("stays silent when no initiating user message id was threaded", async () => {
|
|
600
|
+
await run({ userMessageId: undefined });
|
|
601
|
+
|
|
602
|
+
expect(emitCalls).toHaveLength(0);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("stays silent when the initiating user message row is missing", async () => {
|
|
606
|
+
initiatingRow = null;
|
|
607
|
+
|
|
608
|
+
await run();
|
|
609
|
+
|
|
610
|
+
expect(emitCalls).toHaveLength(0);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("uses the caller-supplied conversation instead of re-reading it", async () => {
|
|
614
|
+
conversationRow = null;
|
|
615
|
+
|
|
616
|
+
await run({ conversation: makeConversation({ title: "Handed down" }) });
|
|
617
|
+
|
|
618
|
+
expect(emitCalls).toHaveLength(1);
|
|
619
|
+
expect(emitCalls[0].contextPayload.requestedTitle).toBe("Handed down");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("stays silent when the reply is already seen", async () => {
|
|
623
|
+
attentionState = makeAttentionState({
|
|
624
|
+
lastSeenAssistantMessageId: ASSISTANT_MESSAGE_ID,
|
|
625
|
+
lastSeenAssistantMessageAt: 1700000000200,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
await run();
|
|
629
|
+
|
|
630
|
+
expect(emitCalls).toHaveLength(0);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("stays silent when there is no attention state row", async () => {
|
|
634
|
+
attentionState = null;
|
|
635
|
+
|
|
636
|
+
await run();
|
|
637
|
+
|
|
638
|
+
expect(emitCalls).toHaveLength(0);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("stays silent when the reply has no user-visible text", async () => {
|
|
642
|
+
assistantRow = makeAssistantRow([
|
|
643
|
+
{ type: "tool_use", id: "t1", name: "bash", input: {} },
|
|
644
|
+
] as ContentBlock[]);
|
|
645
|
+
|
|
646
|
+
await run();
|
|
647
|
+
|
|
648
|
+
expect(emitCalls).toHaveLength(0);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// A file- or image-generation reply carries its output as linked attachments
|
|
652
|
+
// rather than text blocks, so the empty-text branch would otherwise swallow
|
|
653
|
+
// the push for a user who left while the assistant was producing the file.
|
|
654
|
+
describe("attachment-only replies", () => {
|
|
655
|
+
beforeEach(() => {
|
|
656
|
+
assistantRow = makeAssistantRow([
|
|
657
|
+
{ type: "tool_use", id: "t1", name: "write_file", input: {} },
|
|
658
|
+
] as ContentBlock[]);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("names the single attachment it generated", async () => {
|
|
662
|
+
assistantAttachments = [{ originalFilename: "quarterly-report.pdf" }];
|
|
663
|
+
|
|
664
|
+
await run();
|
|
665
|
+
|
|
666
|
+
expect(emitCalls).toHaveLength(1);
|
|
667
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
668
|
+
"Sent quarterly-report.pdf",
|
|
669
|
+
);
|
|
670
|
+
expect(attachmentLookups).toEqual([ASSISTANT_MESSAGE_ID]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("counts the attachments when the reply generated several", async () => {
|
|
674
|
+
assistantAttachments = [
|
|
675
|
+
{ originalFilename: "one.png" },
|
|
676
|
+
{ originalFilename: "two.png" },
|
|
677
|
+
{ originalFilename: "three.png" },
|
|
678
|
+
];
|
|
679
|
+
|
|
680
|
+
await run();
|
|
681
|
+
|
|
682
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
683
|
+
"Sent 3 attachments",
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("falls back to generic copy when the filename sanitizes to nothing", async () => {
|
|
688
|
+
assistantAttachments = [{ originalFilename: "\u0000\u0007" }];
|
|
689
|
+
|
|
690
|
+
await run();
|
|
691
|
+
|
|
692
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
693
|
+
"Sent an attachment",
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("keeps the fallback body inside the preview budget", async () => {
|
|
698
|
+
assistantAttachments = [{ originalFilename: `${"f".repeat(300)}.png` }];
|
|
699
|
+
|
|
700
|
+
await run();
|
|
701
|
+
|
|
702
|
+
const preview = emitCalls[0].contextPayload.requestedMessage as string;
|
|
703
|
+
expect(preview).toHaveLength(200);
|
|
704
|
+
expect(preview.endsWith("…")).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("stays silent when the reply has neither text nor attachments", async () => {
|
|
708
|
+
await run();
|
|
709
|
+
|
|
710
|
+
expect(emitCalls).toHaveLength(0);
|
|
711
|
+
expect(attachmentLookups).toEqual([ASSISTANT_MESSAGE_ID]);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// The attachment read is the empty-text branch's fallback, so a reply that
|
|
716
|
+
// already has text must not pay for it.
|
|
717
|
+
test("skips the attachment lookup when the reply has text", async () => {
|
|
718
|
+
await run();
|
|
719
|
+
|
|
720
|
+
expect(emitCalls).toHaveLength(1);
|
|
721
|
+
expect(attachmentLookups).toEqual([]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("stays silent when the assistant row is missing", async () => {
|
|
725
|
+
assistantRow = null;
|
|
726
|
+
|
|
727
|
+
await run();
|
|
728
|
+
|
|
729
|
+
expect(emitCalls).toHaveLength(0);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("never throws when a dependency throws", async () => {
|
|
733
|
+
getConversationShouldThrow = true;
|
|
734
|
+
|
|
735
|
+
await run();
|
|
736
|
+
|
|
737
|
+
expect(emitCalls).toHaveLength(0);
|
|
738
|
+
expect(warnCalls).toHaveLength(1);
|
|
739
|
+
});
|
|
740
|
+
});
|