@vellumai/assistant 0.11.3 → 0.11.4-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/ARCHITECTURE.md +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +140 -38
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +2 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +55 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +9 -2
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +82 -11
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +9 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +13 -3
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-intents.ts +26 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +63 -0
- package/src/tts/reasoning-tag-filter.ts +110 -0
- package/src/workspace/byok-default-profile-ensure.ts +61 -18
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- package/src/live-voice/front-decision.ts +0 -476
|
@@ -1,102 +1,178 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `runConversationTurn` against the real conversation store, the real agent
|
|
3
|
+
* loop and the real database. The only thing stood in for is the provider's
|
|
4
|
+
* HTTP boundary — `resolveProviderFromConnection` hands back a scripted mock
|
|
5
|
+
* — so everything these tests read back (the `conversations` row, the user
|
|
6
|
+
* message and the metadata stamped on it, the channel binding) is what
|
|
7
|
+
* production wrote, not a stub mirroring what production is believed to do.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
mock,
|
|
15
|
+
spyOn,
|
|
16
|
+
test,
|
|
17
|
+
} from "bun:test";
|
|
2
18
|
|
|
19
|
+
import type { AssistantEvent } from "../api/index.js";
|
|
20
|
+
import { clearAllActiveConversations } from "../daemon/conversation-store.js";
|
|
21
|
+
import {
|
|
22
|
+
conversationMetadataSyncTag,
|
|
23
|
+
SYNC_TAGS,
|
|
24
|
+
} from "../daemon/message-types/sync.js";
|
|
3
25
|
import {
|
|
4
26
|
createConversation,
|
|
5
|
-
ensureConversationExists,
|
|
6
27
|
getConversation,
|
|
28
|
+
getMessages,
|
|
29
|
+
parseMessageMetadata,
|
|
7
30
|
} from "../persistence/conversation-crud.js";
|
|
31
|
+
import { getConversationByKey } from "../persistence/conversation-key-store.js";
|
|
8
32
|
import {
|
|
9
33
|
isEchoSuppressedUserMessage,
|
|
10
34
|
isReplyPushIneligibleUserMessage,
|
|
11
35
|
} from "../persistence/conversation-types.js";
|
|
12
36
|
import { getDb } from "../persistence/db-connection.js";
|
|
13
37
|
import { initializeDb } from "../persistence/db-init.js";
|
|
38
|
+
import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
|
|
39
|
+
import { getBindingByConversation } from "../persistence/external-conversation-store.js";
|
|
40
|
+
import { createConnection } from "../providers/inference/connections.js";
|
|
41
|
+
import * as providerRegistry from "../providers/registry.js";
|
|
42
|
+
import type { Provider, ProviderResponse } from "../providers/types.js";
|
|
43
|
+
import { createMockProvider, textResponse } from "./helpers/mock-provider.js";
|
|
44
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
45
|
+
import { waitFor } from "./helpers/wait-for.js";
|
|
14
46
|
|
|
15
47
|
await initializeDb();
|
|
16
48
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
// Stub the heavy machinery: the in-memory conversation build (provider wiring,
|
|
31
|
-
// system prompt, history hydration) and the SSE event fan-out. The agent turn
|
|
32
|
-
// itself is a no-op — this test only asserts that the `conversations` row is
|
|
33
|
-
// persisted before the turn runs. The real `getOrCreateConversation` now
|
|
34
|
-
// creates the DB row before hydrating, so the mock mirrors that by calling
|
|
35
|
-
// `ensureConversationExists`. The persistence module is intentionally NOT
|
|
36
|
-
// mocked so the real `ensureConversationExists` runs against the real DB.
|
|
37
|
-
let lastProcessMessageConversationId: string | undefined;
|
|
38
|
-
let lastProcessMessageOptions: Record<string, unknown> | undefined;
|
|
39
|
-
let lastEnqueueOptions: Record<string, unknown> | undefined;
|
|
40
|
-
let conversationIsProcessing = false;
|
|
41
|
-
mock.module("../daemon/conversation-store.js", () => ({
|
|
42
|
-
getOrCreateConversation: async (
|
|
43
|
-
conversationId: string,
|
|
44
|
-
options?: { conversationType?: string },
|
|
45
|
-
) => {
|
|
46
|
-
if (!getConversation(conversationId)) {
|
|
47
|
-
if (options?.conversationType) {
|
|
48
|
-
createConversation({
|
|
49
|
-
id: conversationId,
|
|
50
|
-
conversationType: options.conversationType as
|
|
51
|
-
| "standard"
|
|
52
|
-
| "background",
|
|
53
|
-
});
|
|
54
|
-
} else {
|
|
55
|
-
ensureConversationExists(conversationId, "vellum");
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return {
|
|
59
|
-
abortController: undefined,
|
|
60
|
-
isProcessing: () => conversationIsProcessing,
|
|
61
|
-
async processMessage(processOptions: Record<string, unknown>) {
|
|
62
|
-
// The row must already exist by the time the turn persists its user
|
|
63
|
-
// message — record the id so the FK precondition can be asserted.
|
|
64
|
-
lastProcessMessageConversationId = conversationId;
|
|
65
|
-
lastProcessMessageOptions = processOptions;
|
|
66
|
-
return "user-message-id";
|
|
67
|
-
},
|
|
68
|
-
enqueueMessage: (enqueueOptions: Record<string, unknown>) => {
|
|
69
|
-
lastEnqueueOptions = enqueueOptions;
|
|
70
|
-
return { rejected: false };
|
|
71
|
-
},
|
|
72
|
-
};
|
|
49
|
+
// A conversation is built against the default provider, which resolves from
|
|
50
|
+
// config through a connection row. Both are seeded for real so the store takes
|
|
51
|
+
// the same path it takes in the daemon; only the provider the connection
|
|
52
|
+
// resolves to is scripted.
|
|
53
|
+
setConfig("llm", {
|
|
54
|
+
profiles: {
|
|
55
|
+
test: {
|
|
56
|
+
provider: "anthropic",
|
|
57
|
+
provider_connection: "test-conn",
|
|
58
|
+
model: "claude-opus-4-6",
|
|
59
|
+
},
|
|
73
60
|
},
|
|
74
|
-
|
|
61
|
+
activeProfile: "test",
|
|
62
|
+
});
|
|
63
|
+
setConfig("memory", { enabled: false, v2: { enabled: false } });
|
|
64
|
+
|
|
65
|
+
createConnection(getDb(), {
|
|
66
|
+
name: "test-conn",
|
|
67
|
+
provider: "anthropic",
|
|
68
|
+
auth: { type: "api_key", credential: "credential/test/api_key" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The SSE fan-out is the one collaborator with nowhere to write in a test —
|
|
72
|
+
// every publish the turn makes (including the list invalidations asserted
|
|
73
|
+
// below, which reach clients through the real `resource-sync-events`) funnels
|
|
74
|
+
// through here.
|
|
75
|
+
const broadcasts: AssistantEvent[] = [];
|
|
76
|
+
mock.module(
|
|
77
|
+
"../runtime/assistant-event-hub.js",
|
|
78
|
+
(): Partial<typeof import("../runtime/assistant-event-hub.js")> => ({
|
|
79
|
+
broadcastMessage: (msg: AssistantEvent) => {
|
|
80
|
+
broadcasts.push(msg);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
const { provider: scriptedProvider } = createMockProvider([
|
|
86
|
+
textResponse("scripted reply"),
|
|
87
|
+
]);
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
let gate: Promise<void> | undefined;
|
|
90
|
+
let releaseGate: (() => void) | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Park the next turn inside the provider call until released, so a second turn
|
|
94
|
+
* arrives while the conversation is genuinely processing rather than while a
|
|
95
|
+
* stub reports that it is.
|
|
96
|
+
*/
|
|
97
|
+
function holdTheTurnOpen(): void {
|
|
98
|
+
let resolveGate!: () => void;
|
|
99
|
+
gate = new Promise<void>((resolve) => {
|
|
100
|
+
resolveGate = resolve;
|
|
101
|
+
});
|
|
102
|
+
releaseGate = () => {
|
|
103
|
+
gate = undefined;
|
|
104
|
+
releaseGate = undefined;
|
|
105
|
+
resolveGate();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A test that fails before releasing must not leave the next one parked on a
|
|
110
|
+
// gate it never opened.
|
|
111
|
+
afterEach(() => releaseGate?.());
|
|
112
|
+
|
|
113
|
+
const provider: Provider = {
|
|
114
|
+
name: "scripted",
|
|
115
|
+
async sendMessage(messages, options): Promise<ProviderResponse> {
|
|
116
|
+
await gate;
|
|
117
|
+
return scriptedProvider.sendMessage(messages, options);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
spyOn(providerRegistry, "resolveProviderFromConnection").mockResolvedValue(
|
|
122
|
+
provider,
|
|
123
|
+
);
|
|
83
124
|
|
|
84
|
-
// Import under test AFTER the mocks are registered so its dynamic imports
|
|
85
|
-
// resolve to the stubs above.
|
|
86
125
|
const { runConversationTurn } =
|
|
87
126
|
await import("../plugin-api/conversation-turn.js");
|
|
88
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The list-level invalidations a turn published, as the tag sets clients
|
|
130
|
+
* consume. Only a shape-changing reason carries the list umbrella tag, so a
|
|
131
|
+
* reason that merely edits an existing row (a generated title) is excluded —
|
|
132
|
+
* these are the announcements that a conversation appeared or vanished.
|
|
133
|
+
*/
|
|
134
|
+
function listInvalidations(): string[][] {
|
|
135
|
+
return broadcasts
|
|
136
|
+
.filter((msg) => msg.type === "sync_changed")
|
|
137
|
+
.map((msg) => msg.tags)
|
|
138
|
+
.filter((tags) => tags.includes(SYNC_TAGS.conversationsList));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The tag set `publishConversationListAndMetadataChanged` emits for a new row. */
|
|
142
|
+
function creationTags(conversationId: string): string[] {
|
|
143
|
+
return [
|
|
144
|
+
SYNC_TAGS.conversationsList,
|
|
145
|
+
conversationMetadataSyncTag(conversationId),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** User message rows of a conversation, oldest first. */
|
|
150
|
+
function userRows(conversationId: string) {
|
|
151
|
+
return getMessages(conversationId).filter((row) => row.role === "user");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The metadata a turn persisted on a message row, parsed as consumers read it. */
|
|
155
|
+
function metadataOf(
|
|
156
|
+
row: { metadata: string | null } | undefined,
|
|
157
|
+
): Record<string, unknown> | undefined {
|
|
158
|
+
return parseMessageMetadata(row?.metadata ?? null);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resetDb(): void {
|
|
162
|
+
// In-memory conversations outlive the rows they were hydrated from, and a
|
|
163
|
+
// reused id would otherwise hand the next test a Conversation whose
|
|
164
|
+
// `conversations` row this wipe just deleted.
|
|
165
|
+
clearAllActiveConversations();
|
|
166
|
+
const db = getDb();
|
|
167
|
+
db.run("DELETE FROM messages");
|
|
168
|
+
db.run("DELETE FROM external_conversation_bindings");
|
|
169
|
+
db.run("DELETE FROM conversation_keys");
|
|
170
|
+
db.run("DELETE FROM conversations");
|
|
171
|
+
broadcasts.length = 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
89
174
|
describe("runConversationTurn persistence", () => {
|
|
90
|
-
beforeEach(
|
|
91
|
-
const db = getDb();
|
|
92
|
-
db.run("DELETE FROM messages");
|
|
93
|
-
db.run("DELETE FROM conversations");
|
|
94
|
-
listChangedCalls.length = 0;
|
|
95
|
-
lastProcessMessageConversationId = undefined;
|
|
96
|
-
lastProcessMessageOptions = undefined;
|
|
97
|
-
lastEnqueueOptions = undefined;
|
|
98
|
-
conversationIsProcessing = false;
|
|
99
|
-
});
|
|
175
|
+
beforeEach(resetDb);
|
|
100
176
|
|
|
101
177
|
test("persists a conversations row for a freshly-minted conversation", async () => {
|
|
102
178
|
const result = await runConversationTurn({
|
|
@@ -104,16 +180,20 @@ describe("runConversationTurn persistence", () => {
|
|
|
104
180
|
});
|
|
105
181
|
|
|
106
182
|
// The row exists on disk — not just as an in-memory Conversation object —
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
183
|
+
// and the user message the turn persisted found its FK target there.
|
|
184
|
+
expect(getConversation(result.conversationId)?.id).toBe(
|
|
185
|
+
result.conversationId,
|
|
186
|
+
);
|
|
187
|
+
expect(userRows(result.conversationId).map((row) => row.id)).toEqual([
|
|
188
|
+
result.userMessageId,
|
|
189
|
+
]);
|
|
111
190
|
|
|
112
191
|
// Siblings/sidebars are told about the new conversation, mirroring the
|
|
113
192
|
// send-message route.
|
|
114
|
-
expect(
|
|
115
|
-
|
|
116
|
-
|
|
193
|
+
expect(listInvalidations()).toEqual([creationTags(result.conversationId)]);
|
|
194
|
+
expect(
|
|
195
|
+
broadcasts.filter((msg) => msg.type === "conversation_list_invalidated"),
|
|
196
|
+
).toEqual([{ type: "conversation_list_invalidated", reason: "created" }]);
|
|
117
197
|
});
|
|
118
198
|
|
|
119
199
|
test("adopts a caller-supplied conversation id verbatim when no row exists", async () => {
|
|
@@ -126,12 +206,12 @@ describe("runConversationTurn persistence", () => {
|
|
|
126
206
|
|
|
127
207
|
expect(result.conversationId).toBe(conversationId);
|
|
128
208
|
expect(getConversation(conversationId)?.id).toBe(conversationId);
|
|
129
|
-
expect(
|
|
209
|
+
expect(listInvalidations()).toEqual([creationTags(conversationId)]);
|
|
130
210
|
});
|
|
131
211
|
|
|
132
212
|
test("is a no-op for an already-persisted conversation row", async () => {
|
|
133
213
|
const existing = createConversation({ title: "already here" });
|
|
134
|
-
|
|
214
|
+
broadcasts.length = 0;
|
|
135
215
|
|
|
136
216
|
const result = await runConversationTurn({
|
|
137
217
|
conversationId: existing.id,
|
|
@@ -141,7 +221,7 @@ describe("runConversationTurn persistence", () => {
|
|
|
141
221
|
expect(result.conversationId).toBe(existing.id);
|
|
142
222
|
// Row is untouched and no duplicate "created" invalidation fires.
|
|
143
223
|
expect(getConversation(existing.id)?.title).toBe("already here");
|
|
144
|
-
expect(
|
|
224
|
+
expect(listInvalidations()).toEqual([]);
|
|
145
225
|
});
|
|
146
226
|
});
|
|
147
227
|
|
|
@@ -151,28 +231,24 @@ describe("runConversationTurn persistence", () => {
|
|
|
151
231
|
// eligibility predicate's verdict on the stamped metadata, so the marker and
|
|
152
232
|
// the gate that reads it cannot drift apart.
|
|
153
233
|
describe("runConversationTurn provenance", () => {
|
|
154
|
-
beforeEach(
|
|
155
|
-
const db = getDb();
|
|
156
|
-
db.run("DELETE FROM messages");
|
|
157
|
-
db.run("DELETE FROM conversations");
|
|
158
|
-
lastProcessMessageOptions = undefined;
|
|
159
|
-
lastEnqueueOptions = undefined;
|
|
160
|
-
conversationIsProcessing = false;
|
|
161
|
-
});
|
|
234
|
+
beforeEach(resetDb);
|
|
162
235
|
|
|
163
236
|
test("stamps the initiating row automated so its reply raises no push", async () => {
|
|
164
237
|
const existing = createConversation({ title: "standard conversation" });
|
|
165
238
|
|
|
166
|
-
await runConversationTurn({
|
|
239
|
+
const result = await runConversationTurn({
|
|
167
240
|
conversationId: existing.id,
|
|
168
241
|
content: [{ type: "text", text: "transcript excerpt" }],
|
|
169
242
|
});
|
|
170
243
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
244
|
+
// The scripted reply came back, so the marker below was stamped by a turn
|
|
245
|
+
// that ran rather than by one that fell over into an error row.
|
|
246
|
+
expect(result.content).toMatchObject([
|
|
247
|
+
{ type: "text", text: "scripted reply" },
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const metadata = metadataOf(userRows(existing.id)[0]);
|
|
251
|
+
expect(metadata).toMatchObject({ automated: true });
|
|
176
252
|
expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
|
|
177
253
|
// Not an echo-suppression marker: the row still renders in the transcript.
|
|
178
254
|
expect(isEchoSuppressedUserMessage(metadata)).toBe(false);
|
|
@@ -180,16 +256,269 @@ describe("runConversationTurn provenance", () => {
|
|
|
180
256
|
|
|
181
257
|
test("stamps the same marker on a turn queued behind a busy conversation", async () => {
|
|
182
258
|
const existing = createConversation({ title: "busy conversation" });
|
|
183
|
-
|
|
259
|
+
holdTheTurnOpen();
|
|
260
|
+
|
|
261
|
+
const inFlight = runConversationTurn({
|
|
262
|
+
conversationId: existing.id,
|
|
263
|
+
content: [{ type: "text", text: "first excerpt" }],
|
|
264
|
+
});
|
|
265
|
+
await waitFor(() => userRows(existing.id).length === 1, {
|
|
266
|
+
timeoutMs: 5_000,
|
|
267
|
+
message: "the first turn never reached the provider",
|
|
268
|
+
});
|
|
184
269
|
|
|
185
270
|
const result = await runConversationTurn({
|
|
186
271
|
conversationId: existing.id,
|
|
187
272
|
content: [{ type: "text", text: "transcript excerpt" }],
|
|
188
273
|
});
|
|
189
|
-
|
|
190
274
|
expect(result.queued).toBe(true);
|
|
191
|
-
|
|
192
|
-
|
|
275
|
+
|
|
276
|
+
// The queue drains after the in-flight turn releases, and the marker has
|
|
277
|
+
// to survive that trip: it is stamped now but read off the persisted row
|
|
278
|
+
// much later.
|
|
279
|
+
releaseGate?.();
|
|
280
|
+
await inFlight;
|
|
281
|
+
await waitFor(() => userRows(existing.id).length === 2, {
|
|
282
|
+
timeoutMs: 5_000,
|
|
283
|
+
message: "the queued turn never drained",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const metadata = metadataOf(userRows(existing.id)[1]);
|
|
287
|
+
expect(metadata).toMatchObject({ automated: true });
|
|
193
288
|
expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
|
|
289
|
+
}, 20_000);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Addressing a turn by chat rather than by conversation id.
|
|
294
|
+
*
|
|
295
|
+
* The point of these is not that the turn runs (that is the same code path
|
|
296
|
+
* either way) but that the conversation it runs in is the one the rest of
|
|
297
|
+
* the assistant already addresses by those coordinates. A caller keeping its
|
|
298
|
+
* own chat-to-conversation map would pass every test above and still be
|
|
299
|
+
* invisible to conversation reset, to the deny lanes, and to the channel
|
|
300
|
+
* metadata the conversation list renders.
|
|
301
|
+
*/
|
|
302
|
+
describe("runConversationTurn channel binding", () => {
|
|
303
|
+
const CHANNEL = {
|
|
304
|
+
sourceChannel: "plugin" as const,
|
|
305
|
+
externalChatId: "imessage:+12025550142",
|
|
306
|
+
displayName: "Ada",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
beforeEach(resetDb);
|
|
310
|
+
|
|
311
|
+
test("resolves the chat to the conversation inbound would have used", async () => {
|
|
312
|
+
const result = await runConversationTurn({
|
|
313
|
+
channel: CHANNEL,
|
|
314
|
+
content: [{ type: "text", text: "hello" }],
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// The public name for this conversation, which is what reset and the deny
|
|
318
|
+
// lanes address it by.
|
|
319
|
+
expect(
|
|
320
|
+
getConversationByKey(
|
|
321
|
+
buildScopedConversationKey(
|
|
322
|
+
CHANNEL.sourceChannel,
|
|
323
|
+
CHANNEL.externalChatId,
|
|
324
|
+
),
|
|
325
|
+
)?.conversationId,
|
|
326
|
+
).toBe(result.conversationId);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("keeps the same chat in the same conversation across turns", async () => {
|
|
330
|
+
const first = await runConversationTurn({
|
|
331
|
+
channel: CHANNEL,
|
|
332
|
+
content: [{ type: "text", text: "hello" }],
|
|
333
|
+
});
|
|
334
|
+
const second = await runConversationTurn({
|
|
335
|
+
channel: CHANNEL,
|
|
336
|
+
content: [{ type: "text", text: "still me" }],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(second.conversationId).toBe(first.conversationId);
|
|
340
|
+
// Only the first turn minted a conversation.
|
|
341
|
+
expect(listInvalidations()).toEqual([creationTags(first.conversationId)]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("gives a different chat its own conversation", async () => {
|
|
345
|
+
const ada = await runConversationTurn({
|
|
346
|
+
channel: CHANNEL,
|
|
347
|
+
content: [{ type: "text", text: "hello" }],
|
|
348
|
+
});
|
|
349
|
+
const grace = await runConversationTurn({
|
|
350
|
+
channel: { ...CHANNEL, externalChatId: "imessage:+12025550188" },
|
|
351
|
+
content: [{ type: "text", text: "hello" }],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(grace.conversationId).not.toBe(ada.conversationId);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("records the channel metadata the conversation list reads", async () => {
|
|
358
|
+
const result = await runConversationTurn({
|
|
359
|
+
channel: { ...CHANNEL, externalUserId: "+12025550142" },
|
|
360
|
+
content: [{ type: "text", text: "hello" }],
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const binding = getBindingByConversation(result.conversationId);
|
|
364
|
+
expect(binding).toMatchObject({
|
|
365
|
+
sourceChannel: "plugin",
|
|
366
|
+
externalChatId: CHANNEL.externalChatId,
|
|
367
|
+
externalUserId: "+12025550142",
|
|
368
|
+
displayName: "Ada",
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("follows the vendor when a display name changes", async () => {
|
|
373
|
+
await runConversationTurn({
|
|
374
|
+
channel: CHANNEL,
|
|
375
|
+
content: [{ type: "text", text: "hello" }],
|
|
376
|
+
});
|
|
377
|
+
const result = await runConversationTurn({
|
|
378
|
+
channel: { ...CHANNEL, displayName: "Ada L." },
|
|
379
|
+
content: [{ type: "text", text: "renamed" }],
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(getBindingByConversation(result.conversationId)?.displayName).toBe(
|
|
383
|
+
"Ada L.",
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("runs the turn on the channel it was addressed by", async () => {
|
|
388
|
+
// Runtime assembly reads the per-turn channel first and only then the
|
|
389
|
+
// conversation's origin, so a turn into a conversation whose origin was
|
|
390
|
+
// never recorded would otherwise run as `vellum`. That is the wrong
|
|
391
|
+
// channel for the message rows and for the permission cascade the tools
|
|
392
|
+
// are approved against.
|
|
393
|
+
const result = await runConversationTurn({
|
|
394
|
+
channel: CHANNEL,
|
|
395
|
+
content: [{ type: "text", text: "hello" }],
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const rows = getMessages(result.conversationId);
|
|
399
|
+
expect(metadataOf(rows[0])).toMatchObject({
|
|
400
|
+
userMessageChannel: "plugin",
|
|
401
|
+
assistantMessageChannel: "plugin",
|
|
402
|
+
});
|
|
403
|
+
expect(rows[1].role).toBe("assistant");
|
|
404
|
+
expect(metadataOf(rows[1])).toMatchObject({
|
|
405
|
+
userMessageChannel: "plugin",
|
|
406
|
+
assistantMessageChannel: "plugin",
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("a queued turn carries its own channel rather than inheriting one", async () => {
|
|
411
|
+
// The drain happens after this call returns and reads the channel off the
|
|
412
|
+
// queued message, falling back to whichever turn was in flight. Here that
|
|
413
|
+
// fallback would be `vellum` — someone else's channel on a conversation
|
|
414
|
+
// shared with another channel.
|
|
415
|
+
const conversationId = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
|
|
416
|
+
holdTheTurnOpen();
|
|
417
|
+
|
|
418
|
+
const inFlight = runConversationTurn({
|
|
419
|
+
conversationId,
|
|
420
|
+
content: [{ type: "text", text: "native turn" }],
|
|
421
|
+
});
|
|
422
|
+
await waitFor(() => userRows(conversationId).length === 1, {
|
|
423
|
+
timeoutMs: 5_000,
|
|
424
|
+
message: "the first turn never reached the provider",
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const result = await runConversationTurn({
|
|
428
|
+
conversationId,
|
|
429
|
+
channel: CHANNEL,
|
|
430
|
+
content: [{ type: "text", text: "hello" }],
|
|
431
|
+
});
|
|
432
|
+
expect(result.queued).toBe(true);
|
|
433
|
+
|
|
434
|
+
releaseGate?.();
|
|
435
|
+
await inFlight;
|
|
436
|
+
await waitFor(() => userRows(conversationId).length === 2, {
|
|
437
|
+
timeoutMs: 5_000,
|
|
438
|
+
message: "the queued turn never drained",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(metadataOf(userRows(conversationId)[1])).toMatchObject({
|
|
442
|
+
userMessageChannel: "plugin",
|
|
443
|
+
assistantMessageChannel: "plugin",
|
|
444
|
+
});
|
|
445
|
+
}, 20_000);
|
|
446
|
+
|
|
447
|
+
test("leaves the channel unset when the turn names none", async () => {
|
|
448
|
+
// Clearing rather than leaving whatever the last turn set: a stale
|
|
449
|
+
// context would report this turn on a channel it has nothing to do with.
|
|
450
|
+
await runConversationTurn({
|
|
451
|
+
channel: CHANNEL,
|
|
452
|
+
content: [{ type: "text", text: "hello" }],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const result = await runConversationTurn({
|
|
456
|
+
content: [{ type: "text", text: "hello" }],
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const rows = getMessages(result.conversationId);
|
|
460
|
+
const userMetadata = metadataOf(rows[0]);
|
|
461
|
+
expect(userMetadata).toMatchObject({ automated: true });
|
|
462
|
+
expect(userMetadata?.userMessageChannel).toBeUndefined();
|
|
463
|
+
// The conversation carries no origin either, so the turn falls back to the
|
|
464
|
+
// native channel rather than to the previous turn's.
|
|
465
|
+
expect(metadataOf(rows[1])).toMatchObject({
|
|
466
|
+
userMessageChannel: "vellum",
|
|
467
|
+
assistantMessageChannel: "vellum",
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("keeps a known sender when a later turn omits it", async () => {
|
|
472
|
+
// A plugin that knows only the chat coordinates this time is silent about
|
|
473
|
+
// the sender, not asserting it has none. Erasing the stored name on that
|
|
474
|
+
// silence drops it from the conversation and session APIs.
|
|
475
|
+
await runConversationTurn({
|
|
476
|
+
channel: { ...CHANNEL, externalUserId: "+12025550142" },
|
|
477
|
+
content: [{ type: "text", text: "hello" }],
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const result = await runConversationTurn({
|
|
481
|
+
channel: {
|
|
482
|
+
sourceChannel: CHANNEL.sourceChannel,
|
|
483
|
+
externalChatId: CHANNEL.externalChatId,
|
|
484
|
+
},
|
|
485
|
+
content: [{ type: "text", text: "still me" }],
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
expect(getBindingByConversation(result.conversationId)).toMatchObject({
|
|
489
|
+
externalUserId: "+12025550142",
|
|
490
|
+
displayName: "Ada",
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("clears a sender the caller explicitly says is gone", async () => {
|
|
495
|
+
await runConversationTurn({
|
|
496
|
+
channel: CHANNEL,
|
|
497
|
+
content: [{ type: "text", text: "hello" }],
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const result = await runConversationTurn({
|
|
501
|
+
channel: { ...CHANNEL, displayName: null },
|
|
502
|
+
content: [{ type: "text", text: "anonymous now" }],
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(
|
|
506
|
+
getBindingByConversation(result.conversationId)?.displayName,
|
|
507
|
+
).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("an explicit conversation id wins over the address", async () => {
|
|
511
|
+
// Two different conversations were named. Re-resolving would overrule the
|
|
512
|
+
// caller, so the explicit one is honoured and nothing is bound behind it.
|
|
513
|
+
const existing = createConversation({ title: "chosen" });
|
|
514
|
+
|
|
515
|
+
const result = await runConversationTurn({
|
|
516
|
+
conversationId: existing.id,
|
|
517
|
+
channel: CHANNEL,
|
|
518
|
+
content: [{ type: "text", text: "hello" }],
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(result.conversationId).toBe(existing.id);
|
|
522
|
+
expect(getBindingByConversation(existing.id)).toBeNull();
|
|
194
523
|
});
|
|
195
524
|
});
|
|
@@ -375,19 +375,24 @@ describe("scoped-approval-grants / expiry", () => {
|
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
test("already-consumed grants are not affected by expiry sweep", () => {
|
|
378
|
-
const
|
|
378
|
+
const expiresAt = Date.now() + 60_000;
|
|
379
379
|
createScopedApprovalGrant(
|
|
380
380
|
grantParams({
|
|
381
381
|
scopeMode: "request_id",
|
|
382
382
|
requestId: "req-consumed",
|
|
383
|
-
expiresAt
|
|
383
|
+
expiresAt,
|
|
384
384
|
}),
|
|
385
385
|
);
|
|
386
|
-
consumeScopedApprovalGrantByRequestId(
|
|
386
|
+
const consumed = consumeScopedApprovalGrantByRequestId(
|
|
387
|
+
"req-consumed",
|
|
388
|
+
"c1",
|
|
389
|
+
expiresAt - 1,
|
|
390
|
+
);
|
|
391
|
+
expect(consumed.ok).toBe(true);
|
|
387
392
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
const count = expireScopedApprovalGrants();
|
|
393
|
+
// Sweep after the grant's expiresAt. Status is consumed, so the active-only
|
|
394
|
+
// expiry update must leave it alone.
|
|
395
|
+
const count = expireScopedApprovalGrants(expiresAt);
|
|
391
396
|
expect(count).toBe(0);
|
|
392
397
|
});
|
|
393
398
|
});
|