@vellumai/assistant 0.11.8 → 0.11.9-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 +2 -2
- package/Dockerfile +8 -48
- package/docker-entrypoint.sh +5 -1
- package/docker-kata-apt-env.sh +3 -0
- package/docker-kata-apt-shims.sh +127 -0
- package/docker-kata-apt-wrapper.sh +45 -0
- package/docker-kata-chroot-exec.sh +35 -0
- package/docker-kata-pip.sh +8 -2
- package/docs/architecture/memory.md +8 -4
- package/docs/guardian-request-flow.md +18 -14
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/openapi.yaml +193 -4
- package/package.json +2 -2
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
- package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
- package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
- package/src/__tests__/attachments-store.test.ts +27 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/canned-reply-release.test.ts +48 -5
- package/src/__tests__/channel-delivery-store.test.ts +129 -18
- package/src/__tests__/channel-reply-delivery.test.ts +553 -93
- package/src/__tests__/channel-retry-sweep.test.ts +199 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
- package/src/__tests__/conversation-error.test.ts +15 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
- package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +469 -0
- package/src/__tests__/dm-persistence.test.ts +16 -0
- package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-question-mode.test.ts +108 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
- package/src/__tests__/host-proxy-interface.test.ts +10 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
- package/src/__tests__/injector-v3-suppression.test.ts +43 -9
- package/src/__tests__/list-messages-page-latest.test.ts +127 -0
- package/src/__tests__/list-messages-system-card.test.ts +103 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/notification-decision-fallback.test.ts +199 -77
- package/src/__tests__/notification-decision-strategy.test.ts +198 -213
- package/src/__tests__/notification-discord-adapter.test.ts +25 -0
- package/src/__tests__/notification-slack-adapter.test.ts +133 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
- package/src/__tests__/openai-provider.test.ts +5 -5
- package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
- package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
- package/src/__tests__/process-message-display-content.test.ts +20 -9
- package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/reaction-persistence.test.ts +22 -198
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
- package/src/__tests__/skill-load-tool.test.ts +27 -0
- package/src/__tests__/skills.test.ts +34 -1
- package/src/__tests__/strip-memory-injections.test.ts +3 -4
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/thread-backfill.test.ts +5 -3
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/voice-session-bridge.test.ts +18 -0
- package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
- package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
- package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/agent/attachments.ts +13 -2
- package/src/agent/loop.ts +3 -19
- package/src/api/README.md +9 -5
- package/src/api/index.ts +9 -8
- package/src/api/package.json +1 -0
- package/src/api/responses/conversation-message.ts +8 -0
- package/src/api/responses/home.ts +7 -18
- package/src/api/surfaces.ts +114 -7
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/__tests__/types.test.ts +22 -1
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/channels/types.ts +8 -6
- package/src/cli/__tests__/catalog-search-help.test.ts +12 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +15 -1
- package/src/cli/commands/channels/index.ts +6 -2
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/cli/commands/plugins.help.ts +6 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
- package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
- package/src/cli/lib/bundled-marketplace.json +14 -0
- package/src/cli/lib/install-from-github.ts +9 -5
- package/src/cli/lib/install-from-platform.ts +12 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/config/bundled-skills/schedule/SKILL.md +25 -11
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
- package/src/config/call-site-defaults.ts +4 -1
- package/src/config/feature-flag-registry.json +21 -4
- package/src/config/schemas/memory-v3.ts +4 -3
- package/src/context/strip-injections.ts +17 -56
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
- package/src/daemon/conversation-error.ts +2 -0
- package/src/daemon/conversation-messaging.ts +195 -47
- package/src/daemon/conversation-process.ts +20 -5
- package/src/daemon/conversation-runtime-assembly.ts +48 -39
- package/src/daemon/conversation-store.ts +159 -11
- package/src/daemon/conversation-tool-setup.ts +9 -2
- package/src/daemon/conversation.ts +290 -19
- package/src/daemon/dictation-text-processing.ts +2 -7
- package/src/daemon/handlers/config-channels.ts +33 -3
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/port-oversized-content.test.ts +117 -0
- package/src/daemon/port-oversized-content.ts +110 -0
- package/src/daemon/process-message.ts +87 -16
- package/src/daemon/reaction-record.test.ts +23 -11
- package/src/daemon/reaction-record.ts +6 -9
- package/src/documents/document-store.ts +1 -5
- package/src/home/conversation-starter-validation.ts +1 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-photo.ts +544 -61
- package/src/live-voice/live-voice-session.ts +6 -2
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/provider-message-metadata.ts +40 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
- package/src/messaging/providers/channel-transport.ts +28 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/slack/api.ts +11 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
- package/src/messaging/providers/slack/message-metadata.ts +141 -1
- package/src/messaging/providers/slack/send.test.ts +58 -0
- package/src/messaging/providers/slack/send.ts +4 -4
- package/src/messaging/providers/slack/transport.ts +28 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
- package/src/messaging/providers/telegram-bot/send.ts +93 -0
- package/src/messaging/providers/telegram-bot/transport.ts +71 -0
- package/src/messaging/reaction-envelopes.test.ts +73 -0
- package/src/messaging/reaction-envelopes.ts +33 -5
- package/src/messaging/read-provider-metadata.ts +4 -3
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
- package/src/monitoring/recovery/db.ts +35 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
- package/src/monitoring/recovery/run-recovery.ts +6 -2
- package/src/monitoring/recovery/stale-processing.ts +3 -20
- package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
- package/src/notifications/AGENTS.md +2 -2
- package/src/notifications/README.md +2 -2
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
- package/src/notifications/__tests__/broadcaster.test.ts +35 -4
- package/src/notifications/__tests__/notification-utils.test.ts +31 -0
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/adapters/discord.ts +9 -5
- package/src/notifications/adapters/shared.ts +17 -4
- package/src/notifications/adapters/slack.ts +14 -4
- package/src/notifications/adapters/telegram.ts +8 -4
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/broadcaster.ts +57 -25
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/notifications/copy-composer.ts +13 -32
- package/src/notifications/decision-engine.ts +56 -237
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/notifications/guardian-feed-projection.ts +0 -15
- package/src/notifications/guardian-question-mode.ts +147 -6
- package/src/notifications/notification-utils.ts +117 -1
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/conversation-crud.ts +93 -14
- package/src/persistence/conversation-queries.ts +144 -17
- package/src/persistence/conversation-types.ts +39 -5
- package/src/persistence/delivery-crud.ts +224 -28
- package/src/persistence/delivery-status.ts +21 -2
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +71 -2
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/persistence/steps.ts +4 -0
- package/src/platform/client.ts +55 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
- package/src/plugins/defaults/injector-order.ts +2 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/memory-marker.ts +17 -7
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/types.ts +15 -4
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/plugins/mcp-servers.ts +14 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
- package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/call-site-routing.ts +5 -4
- package/src/providers/connection-resolution.ts +29 -1
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +12 -7
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +31 -34
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
- package/src/providers/registry.ts +10 -7
- package/src/providers/routing-identity.ts +2 -1
- package/src/providers/types.ts +1 -4
- package/src/providers/vellum-model-routing.ts +3 -2
- package/src/runtime/AGENTS.md +40 -15
- package/src/runtime/approval-message-composer.ts +1 -6
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/channel-reply-delivery.ts +182 -115
- package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
- package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
- package/src/runtime/channel-retry-sweep.ts +102 -1
- package/src/runtime/finalize-event-delivery.ts +4 -4
- package/src/runtime/guardian-action-message-composer.ts +1 -4
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/http-router.ts +1 -5
- package/src/runtime/question-request-guardian-bridge.ts +2 -3
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/canned-reply-release.ts +19 -9
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/channel-verification-routes.ts +4 -1
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
- package/src/runtime/routes/conversation-management-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +138 -59
- package/src/runtime/routes/diagnostics-routes.ts +111 -60
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +170 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/routes/sight-frame-routes.ts +136 -0
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/client-os.ts +11 -1
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +4 -3
- package/src/tools/watch/watch-retro-report.ts +205 -0
- package/src/watch/__tests__/watch-retro.test.ts +268 -40
- package/src/watch/watch-retro.ts +190 -77
- package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +4 -0
- package/docker-kata-pip-chroot.sh +0 -22
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
- package/src/api/events/conversation-list-invalidated.ts +0 -38
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The intercept reads the reactor's trust solely from
|
|
5
5
|
* `sourceMetadata.trustVerdict` (via `actorTrustContextFromVerdict`) — no
|
|
6
|
-
* local resolver, cache warm, or IPC reads. Pins the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* local resolver, cache warm, or IPC reads. Pins the dispositions: an
|
|
7
|
+
* admitted actor's reaction is recorded against the reacted message's
|
|
8
|
+
* conversation whatever their trust class, and unknown / missing / failed /
|
|
9
9
|
* contradictory verdicts drop fail-closed before any write.
|
|
10
10
|
*/
|
|
11
11
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -70,45 +70,110 @@ mock.module("../../../contacts/contact-store.js", () => ({
|
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
|
|
72
72
|
let recordInboundCalls: Array<{ conversationId?: string }> = [];
|
|
73
|
-
|
|
73
|
+
// Keyed the way the real `channel_inbound_events` row is keyed, so a test can
|
|
74
|
+
// replay a sequence of events and see which of them the daemon treats as new.
|
|
75
|
+
const inboundStore = new Map<
|
|
76
|
+
string,
|
|
77
|
+
{ eventId: string; conversationId: string }
|
|
78
|
+
>();
|
|
79
|
+
const inboundKey = (channel: string, chat: string, id: string) =>
|
|
80
|
+
`${channel}\u0000${chat}\u0000${id}`;
|
|
81
|
+
/** Seed a row so the next delivery of `externalMessageId` reads as a redelivery. */
|
|
82
|
+
function seedRecordedEvent(externalMessageId: string, eventId: string): void {
|
|
83
|
+
inboundStore.set(inboundKey("slack", SLACK_CHANNEL_ID, externalMessageId), {
|
|
84
|
+
eventId,
|
|
85
|
+
conversationId: "conv-target",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
74
88
|
let outboundTargetConversationId: string | null = null;
|
|
75
89
|
let outboundLookupChannels: string[] = [];
|
|
76
90
|
let storedTarget: { messageId: string; conversationId: string } | null = null;
|
|
77
91
|
mock.module("../../../persistence/delivery-crud.js", () => ({
|
|
78
92
|
recordInbound: (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
channel: string,
|
|
94
|
+
chat: string,
|
|
95
|
+
externalMessageId: string,
|
|
82
96
|
options?: { conversationId?: string },
|
|
83
97
|
) => {
|
|
84
98
|
recordInboundCalls.push({ conversationId: options?.conversationId });
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
const key = inboundKey(channel, chat, externalMessageId);
|
|
100
|
+
const existing = inboundStore.get(key);
|
|
101
|
+
if (existing) {
|
|
102
|
+
return { ...existing, accepted: true, duplicate: true };
|
|
103
|
+
}
|
|
104
|
+
const row = {
|
|
105
|
+
eventId: `evt-${inboundStore.size + 1}`,
|
|
87
106
|
conversationId: options?.conversationId ?? "conv-minted",
|
|
88
|
-
accepted: true,
|
|
89
|
-
duplicate: false,
|
|
90
107
|
};
|
|
108
|
+
inboundStore.set(key, row);
|
|
109
|
+
return { ...row, accepted: true, duplicate: false };
|
|
91
110
|
},
|
|
92
111
|
findMessageBySourceId: () => storedTarget,
|
|
93
|
-
|
|
112
|
+
findMessageByProviderMessageId: (sourceChannel: string) => {
|
|
94
113
|
outboundLookupChannels.push(sourceChannel);
|
|
95
|
-
return outboundTargetConversationId
|
|
114
|
+
return outboundTargetConversationId
|
|
115
|
+
? {
|
|
116
|
+
messageId: "outbound-row-1",
|
|
117
|
+
conversationId: outboundTargetConversationId,
|
|
118
|
+
}
|
|
119
|
+
: null;
|
|
96
120
|
},
|
|
97
|
-
findInboundEvent: (
|
|
121
|
+
findInboundEvent: (
|
|
122
|
+
channel: string,
|
|
123
|
+
chat: string,
|
|
124
|
+
externalMessageId: string,
|
|
125
|
+
) => inboundStore.get(inboundKey(channel, chat, externalMessageId)) ?? null,
|
|
98
126
|
clearPayload: () => {},
|
|
127
|
+
storePayload: (eventId: string, payload: Record<string, unknown>) => {
|
|
128
|
+
storedPayloads.push({ eventId, payload });
|
|
129
|
+
},
|
|
99
130
|
linkMessage: () => {},
|
|
100
131
|
}));
|
|
132
|
+
const storedPayloads: Array<{
|
|
133
|
+
eventId: string;
|
|
134
|
+
payload: Record<string, unknown>;
|
|
135
|
+
}> = [];
|
|
101
136
|
|
|
102
137
|
let addMessageCalls = 0;
|
|
138
|
+
let targetRow: {
|
|
139
|
+
role: string;
|
|
140
|
+
content: unknown;
|
|
141
|
+
metadata?: string | null;
|
|
142
|
+
} | null = null;
|
|
103
143
|
mock.module("../../../persistence/conversation-crud.js", () => ({
|
|
104
144
|
addMessage: async () => {
|
|
105
145
|
addMessageCalls++;
|
|
106
146
|
return { id: "msg-1" };
|
|
107
147
|
},
|
|
148
|
+
getMessageById: () => targetRow,
|
|
108
149
|
}));
|
|
109
150
|
|
|
151
|
+
let markProcessedCalls = 0;
|
|
110
152
|
mock.module("../../../persistence/delivery-status.js", () => ({
|
|
111
|
-
markProcessed: () => {
|
|
153
|
+
markProcessed: () => {
|
|
154
|
+
markProcessedCalls++;
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
let dispatchedTurns: Array<Record<string, unknown>> = [];
|
|
159
|
+
let dispatchThrows = false;
|
|
160
|
+
mock.module("./background-dispatch.js", () => ({
|
|
161
|
+
processChannelMessageInBackground: (params: Record<string, unknown>) => {
|
|
162
|
+
if (dispatchThrows) {
|
|
163
|
+
throw new Error("dispatch setup exploded");
|
|
164
|
+
}
|
|
165
|
+
dispatchedTurns.push(params);
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
// The intercept imports `processMessage` only to hand it to the dispatch;
|
|
170
|
+
// stub it so the test never drags the daemon turn machinery in at import
|
|
171
|
+
// time.
|
|
172
|
+
mock.module("../../../daemon/process-message.js", () => ({
|
|
173
|
+
processMessage: async () => ({ messageId: "turn-user-row" }),
|
|
174
|
+
}));
|
|
175
|
+
mock.module("../../../daemon/approval-generators.js", () => ({
|
|
176
|
+
createApprovalCopyGenerator: () => ({}),
|
|
112
177
|
}));
|
|
113
178
|
mock.module("../../../persistence/external-conversation-store.js", () => ({
|
|
114
179
|
upsertBinding: () => {},
|
|
@@ -120,24 +185,6 @@ mock.module("../../../daemon/disk-pressure-policy.js", () => ({
|
|
|
120
185
|
classifyDiskPressureTurnPolicy: () => ({ action: "allow" }),
|
|
121
186
|
}));
|
|
122
187
|
|
|
123
|
-
let guardianReplyCalls: Array<{
|
|
124
|
-
trustClass: string;
|
|
125
|
-
guardianPrincipalId: string | null | undefined;
|
|
126
|
-
}> = [];
|
|
127
|
-
let guardianReplyResponse: Record<string, unknown> | undefined;
|
|
128
|
-
mock.module("./guardian-reply-intercept.js", () => ({
|
|
129
|
-
handleGuardianReplyIntercept: async (params: {
|
|
130
|
-
trustClass: string;
|
|
131
|
-
guardianPrincipalId: string | null | undefined;
|
|
132
|
-
}) => {
|
|
133
|
-
guardianReplyCalls.push({
|
|
134
|
-
trustClass: params.trustClass,
|
|
135
|
-
guardianPrincipalId: params.guardianPrincipalId,
|
|
136
|
-
});
|
|
137
|
-
return { response: guardianReplyResponse };
|
|
138
|
-
},
|
|
139
|
-
}));
|
|
140
|
-
|
|
141
188
|
import { handleReactionIntercept } from "./reaction-intercept.js";
|
|
142
189
|
|
|
143
190
|
// ---------------------------------------------------------------------------
|
|
@@ -179,23 +226,34 @@ let msgCounter = 0;
|
|
|
179
226
|
function buildParams(overrides: {
|
|
180
227
|
rawSenderId: string;
|
|
181
228
|
trustVerdict?: TrustVerdict;
|
|
229
|
+
op?: "added" | "removed";
|
|
230
|
+
externalMessageId?: string;
|
|
231
|
+
sourceChannel?: "slack" | "discord";
|
|
182
232
|
}) {
|
|
183
233
|
msgCounter++;
|
|
234
|
+
const sourceChannel = overrides.sourceChannel ?? ("slack" as const);
|
|
184
235
|
return {
|
|
185
236
|
reaction: {
|
|
186
|
-
op: "added" as const,
|
|
237
|
+
op: overrides.op ?? ("added" as const),
|
|
187
238
|
emoji: "white_check_mark",
|
|
239
|
+
emojiKind: "shortcode" as const,
|
|
240
|
+
emojiName: "white_check_mark",
|
|
188
241
|
targetMessageId: "1700000000.1",
|
|
189
242
|
},
|
|
190
|
-
sourceChannel
|
|
191
|
-
sourceInterface:
|
|
243
|
+
sourceChannel,
|
|
244
|
+
sourceInterface: sourceChannel,
|
|
192
245
|
conversationExternalId: SLACK_CHANNEL_ID,
|
|
193
|
-
externalMessageId:
|
|
246
|
+
externalMessageId:
|
|
247
|
+
overrides.externalMessageId ??
|
|
248
|
+
`${SLACK_CHANNEL_ID}:1700000000.1:${msgCounter}`,
|
|
194
249
|
rawSenderId: overrides.rawSenderId,
|
|
195
250
|
canonicalSenderId: overrides.rawSenderId,
|
|
196
251
|
actorDisplayName: "Reactor",
|
|
197
252
|
actorUsername: undefined,
|
|
198
|
-
|
|
253
|
+
// The shape the gateway actually builds for a reaction: the reacted
|
|
254
|
+
// message's own ts stands in as `threadTs`, which is what the wake has
|
|
255
|
+
// to correct before delivering a reply.
|
|
256
|
+
replyCallbackUrl: `http://localhost:7830/deliver/slack?channel=${SLACK_CHANNEL_ID}&threadTs=1700000000.1`,
|
|
199
257
|
sourceMetadata: {
|
|
200
258
|
messageId: "1700000000.1",
|
|
201
259
|
chatType: "channel",
|
|
@@ -203,38 +261,43 @@ function buildParams(overrides: {
|
|
|
203
261
|
? { trustVerdict: overrides.trustVerdict }
|
|
204
262
|
: {}),
|
|
205
263
|
} as never,
|
|
206
|
-
approvalConversationGenerator: undefined,
|
|
207
264
|
};
|
|
208
265
|
}
|
|
209
266
|
|
|
210
267
|
function expectDropped(result: Record<string, unknown>): void {
|
|
211
268
|
expect(result.reaction).toBe("dropped_unknown_actor");
|
|
212
|
-
// Dropped before any write
|
|
213
|
-
// no approval-pipeline dispatch.
|
|
269
|
+
// Dropped before any write: no dedup record and no transcript row.
|
|
214
270
|
expect(recordInboundCalls.length).toBe(0);
|
|
215
271
|
expect(addMessageCalls).toBe(0);
|
|
216
|
-
expect(guardianReplyCalls.length).toBe(0);
|
|
217
272
|
}
|
|
218
273
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
274
|
+
function resetHarness(): void {
|
|
275
|
+
ipcCalls = [];
|
|
276
|
+
guardianDeliveryReads = 0;
|
|
277
|
+
setMemberVerdictCalls = 0;
|
|
278
|
+
contactLookups = 0;
|
|
279
|
+
recordInboundCalls = [];
|
|
280
|
+
inboundStore.clear();
|
|
281
|
+
outboundTargetConversationId = null;
|
|
282
|
+
outboundLookupChannels = [];
|
|
283
|
+
addMessageCalls = 0;
|
|
284
|
+
storedTarget = { messageId: "msg-target", conversationId: "conv-target" };
|
|
285
|
+
// Default target: a user-authored row, so reactions stay passive unless
|
|
286
|
+
// a test makes the target the assistant's own post.
|
|
287
|
+
targetRow = {
|
|
288
|
+
role: "user",
|
|
289
|
+
content: [{ type: "text", text: "the reacted-to message" }],
|
|
290
|
+
};
|
|
291
|
+
dispatchedTurns = [];
|
|
292
|
+
dispatchThrows = false;
|
|
293
|
+
storedPayloads.length = 0;
|
|
294
|
+
markProcessedCalls = 0;
|
|
295
|
+
}
|
|
234
296
|
|
|
235
|
-
|
|
236
|
-
|
|
297
|
+
describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
298
|
+
beforeEach(resetHarness);
|
|
237
299
|
|
|
300
|
+
test("a guardian's reaction is a transcript row, not an approval decision", async () => {
|
|
238
301
|
const result = await handleReactionIntercept(
|
|
239
302
|
buildParams({
|
|
240
303
|
rawSenderId: GUARDIAN_USER_ID,
|
|
@@ -242,15 +305,15 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
242
305
|
}),
|
|
243
306
|
);
|
|
244
307
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
expect(
|
|
250
|
-
expect(
|
|
308
|
+
// A guardian reacts like any other admitted actor: the emoji annotates
|
|
309
|
+
// the room. Approvals are answered by the card's buttons.
|
|
310
|
+
expect(result.accepted).toBe(true);
|
|
311
|
+
expect(result.reaction).toBeUndefined();
|
|
312
|
+
expect(addMessageCalls).toBe(1);
|
|
313
|
+
expect(recordInboundCalls).toEqual([{ conversationId: "conv-target" }]);
|
|
251
314
|
});
|
|
252
315
|
|
|
253
|
-
test("contact
|
|
316
|
+
test("a contact's reaction is recorded in the reacted message's conversation", async () => {
|
|
254
317
|
const result = await handleReactionIntercept(
|
|
255
318
|
buildParams({
|
|
256
319
|
rawSenderId: MEMBER_USER_ID,
|
|
@@ -258,11 +321,6 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
258
321
|
}),
|
|
259
322
|
);
|
|
260
323
|
|
|
261
|
-
// Dispatched with the contact class (guardian-reply-intercept self-gates
|
|
262
|
-
// on guardian), then falls through to transcript persistence.
|
|
263
|
-
expect(guardianReplyCalls).toEqual([
|
|
264
|
-
{ trustClass: "trusted_contact", guardianPrincipalId: undefined },
|
|
265
|
-
]);
|
|
266
324
|
expect(result.accepted).toBe(true);
|
|
267
325
|
expect(result.reaction).toBeUndefined();
|
|
268
326
|
expect(addMessageCalls).toBe(1);
|
|
@@ -270,10 +328,14 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
270
328
|
expect(recordInboundCalls).toEqual([{ conversationId: "conv-target" }]);
|
|
271
329
|
});
|
|
272
330
|
|
|
273
|
-
test("a reaction
|
|
331
|
+
test("a reaction added to the assistant's own post wakes a discretion turn", async () => {
|
|
274
332
|
// Outbound posts open no inbound event, so the id is only on the row.
|
|
275
333
|
storedTarget = null;
|
|
276
334
|
outboundTargetConversationId = "conv-assistant-post";
|
|
335
|
+
targetRow = {
|
|
336
|
+
role: "assistant",
|
|
337
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
338
|
+
};
|
|
277
339
|
|
|
278
340
|
const result = await handleReactionIntercept(
|
|
279
341
|
buildParams({
|
|
@@ -285,16 +347,40 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
285
347
|
expect(recordInboundCalls).toEqual([
|
|
286
348
|
{ conversationId: "conv-assistant-post" },
|
|
287
349
|
]);
|
|
288
|
-
expect(addMessageCalls).toBe(1);
|
|
289
|
-
expect(result).toMatchObject({ accepted: true, duplicate: false });
|
|
290
350
|
// The outbound-row lookup is scoped to the reaction's own channel: a
|
|
291
351
|
// Slack reaction never scans another channel's conversations.
|
|
292
352
|
expect(outboundLookupChannels).toEqual(["slack"]);
|
|
353
|
+
expect(result).toMatchObject({
|
|
354
|
+
accepted: true,
|
|
355
|
+
duplicate: false,
|
|
356
|
+
reaction: "wake_dispatched",
|
|
357
|
+
});
|
|
358
|
+
// The turn's own persisted row is the reaction row: no passive write,
|
|
359
|
+
// and the event is settled up front (wake turns opt out of sweep replay).
|
|
360
|
+
expect(addMessageCalls).toBe(0);
|
|
361
|
+
expect(markProcessedCalls).toBe(1);
|
|
362
|
+
expect(dispatchedTurns).toHaveLength(1);
|
|
363
|
+
const turn = dispatchedTurns[0];
|
|
364
|
+
expect(turn.conversationId).toBe("conv-assistant-post");
|
|
365
|
+
expect(turn.clientMessageId).toBe("reaction:evt-1");
|
|
366
|
+
expect(turn.skipUserMessageIndexing).toBe(true);
|
|
367
|
+
// The model's turn content is the same line the reload renderer
|
|
368
|
+
// produces, with the quoted target resolved.
|
|
369
|
+
expect(String(turn.content)).toContain("reacted with");
|
|
370
|
+
expect(String(turn.content)).toContain("Deploy finished cleanly.");
|
|
371
|
+
// The row's envelope matches what the passive path would have written,
|
|
372
|
+
// riding the transitional Slack-only carrier.
|
|
373
|
+
expect(typeof turn.slackReactionRowMeta).toBe("string");
|
|
374
|
+
expect(turn.channelInbound).toBeUndefined();
|
|
293
375
|
});
|
|
294
376
|
|
|
295
|
-
test("a non-Slack reaction
|
|
377
|
+
test("a non-Slack reaction on the assistant's post wakes with the neutral envelope", async () => {
|
|
296
378
|
storedTarget = null;
|
|
297
379
|
outboundTargetConversationId = "conv-discord-post";
|
|
380
|
+
targetRow = {
|
|
381
|
+
role: "assistant",
|
|
382
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
383
|
+
};
|
|
298
384
|
|
|
299
385
|
const params = {
|
|
300
386
|
...buildParams({
|
|
@@ -302,15 +388,186 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
302
388
|
trustVerdict: MEMBER_VERDICT,
|
|
303
389
|
}),
|
|
304
390
|
sourceChannel: "discord" as const,
|
|
305
|
-
sourceInterface:
|
|
391
|
+
sourceInterface: "discord" as const,
|
|
306
392
|
};
|
|
307
393
|
const result = await handleReactionIntercept(params);
|
|
308
394
|
|
|
309
395
|
expect(outboundLookupChannels).toEqual(["discord"]);
|
|
310
|
-
expect(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
396
|
+
expect(result).toMatchObject({
|
|
397
|
+
accepted: true,
|
|
398
|
+
duplicate: false,
|
|
399
|
+
reaction: "wake_dispatched",
|
|
400
|
+
});
|
|
401
|
+
expect(dispatchedTurns).toHaveLength(1);
|
|
402
|
+
// Non-Slack wake turns ride the ordinary neutral-envelope carrier, the
|
|
403
|
+
// same lane inbound messages use.
|
|
404
|
+
const channelInbound = dispatchedTurns[0].channelInbound as Record<
|
|
405
|
+
string,
|
|
406
|
+
unknown
|
|
407
|
+
>;
|
|
408
|
+
expect(channelInbound.eventKind).toBe("reaction");
|
|
409
|
+
expect(channelInbound.source).toBe("discord");
|
|
410
|
+
expect(dispatchedTurns[0].slackReactionRowMeta).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a Slack wake reply targets the thread the reacted message lives in", async () => {
|
|
414
|
+
// The reaction event's callback names the REACTED MESSAGE's ts as its
|
|
415
|
+
// thread (Slack gives a reaction no thread of its own), so delivering
|
|
416
|
+
// through it unchanged would root the reply at that message instead of
|
|
417
|
+
// in the conversation's own thread.
|
|
418
|
+
storedTarget = null;
|
|
419
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
420
|
+
targetRow = {
|
|
421
|
+
role: "assistant",
|
|
422
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
423
|
+
metadata: JSON.stringify({
|
|
424
|
+
slackMeta: JSON.stringify({
|
|
425
|
+
source: "slack",
|
|
426
|
+
channelId: SLACK_CHANNEL_ID,
|
|
427
|
+
channelTs: "1700000000.1",
|
|
428
|
+
threadTs: "1699999999.9",
|
|
429
|
+
eventKind: "message",
|
|
430
|
+
}),
|
|
431
|
+
}),
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
await handleReactionIntercept(
|
|
435
|
+
buildParams({
|
|
436
|
+
rawSenderId: MEMBER_USER_ID,
|
|
437
|
+
trustVerdict: MEMBER_VERDICT,
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const url = new URL(dispatchedTurns[0].replyCallbackUrl as string);
|
|
442
|
+
// Replaced, not merely present: the event arrived naming 1700000000.1.
|
|
443
|
+
expect(url.searchParams.get("threadTs")).toBe("1699999999.9");
|
|
444
|
+
expect(url.searchParams.get("channel")).toBe(SLACK_CHANNEL_ID);
|
|
445
|
+
// The stored delivery payload names the same corrected destination.
|
|
446
|
+
expect(
|
|
447
|
+
new URL(
|
|
448
|
+
storedPayloads[0].payload.replyCallbackUrl as string,
|
|
449
|
+
).searchParams.get("threadTs"),
|
|
450
|
+
).toBe("1699999999.9");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("a Slack wake reply on a channel-root post carries no thread", async () => {
|
|
454
|
+
storedTarget = null;
|
|
455
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
456
|
+
targetRow = {
|
|
457
|
+
role: "assistant",
|
|
458
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
459
|
+
metadata: JSON.stringify({
|
|
460
|
+
slackMeta: JSON.stringify({
|
|
461
|
+
source: "slack",
|
|
462
|
+
channelId: SLACK_CHANNEL_ID,
|
|
463
|
+
channelTs: "1700000000.1",
|
|
464
|
+
eventKind: "message",
|
|
465
|
+
}),
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
await handleReactionIntercept(
|
|
470
|
+
buildParams({
|
|
471
|
+
rawSenderId: MEMBER_USER_ID,
|
|
472
|
+
trustVerdict: MEMBER_VERDICT,
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const url = new URL(dispatchedTurns[0].replyCallbackUrl as string);
|
|
477
|
+
// The target sits at the channel root, so the reply belongs there too,
|
|
478
|
+
// never in a thread rooted at the reacted message.
|
|
479
|
+
expect(url.searchParams.get("threadTs")).toBeNull();
|
|
480
|
+
expect(url.searchParams.get("channel")).toBe(SLACK_CHANNEL_ID);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("the wake stores a delivery-only payload, never a replayable turn", async () => {
|
|
484
|
+
storedTarget = null;
|
|
485
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
486
|
+
targetRow = {
|
|
487
|
+
role: "assistant",
|
|
488
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
await handleReactionIntercept(
|
|
492
|
+
buildParams({
|
|
493
|
+
rawSenderId: MEMBER_USER_ID,
|
|
494
|
+
trustVerdict: MEMBER_VERDICT,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
expect(storedPayloads).toHaveLength(1);
|
|
499
|
+
const payload = storedPayloads[0].payload;
|
|
500
|
+
// Enough for the delivery lane to re-post a generated reply...
|
|
501
|
+
expect(typeof payload.replyCallbackUrl).toBe("string");
|
|
502
|
+
expect(payload.externalChatId).toBe(SLACK_CHANNEL_ID);
|
|
503
|
+
// ...and structurally incapable of being replayed as a message turn.
|
|
504
|
+
expect(payload.content).toBeUndefined();
|
|
505
|
+
expect(payload.sourceChannel).toBeUndefined();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("a wake whose dispatch fails to start still records the passive row", async () => {
|
|
509
|
+
storedTarget = null;
|
|
510
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
511
|
+
targetRow = {
|
|
512
|
+
role: "assistant",
|
|
513
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
514
|
+
};
|
|
515
|
+
dispatchThrows = true;
|
|
516
|
+
|
|
517
|
+
const result = await handleReactionIntercept(
|
|
518
|
+
buildParams({
|
|
519
|
+
rawSenderId: MEMBER_USER_ID,
|
|
520
|
+
trustVerdict: MEMBER_VERDICT,
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// The dedup record already exists, so a gateway retry would short-circuit;
|
|
525
|
+
// the reaction must not vanish because the turn could not start.
|
|
526
|
+
expect(addMessageCalls).toBe(1);
|
|
527
|
+
expect(result.reaction).toBeUndefined();
|
|
528
|
+
expect(result).toMatchObject({ accepted: true });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a reaction REMOVED from the assistant's post stays passive", async () => {
|
|
532
|
+
storedTarget = null;
|
|
533
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
534
|
+
targetRow = {
|
|
535
|
+
role: "assistant",
|
|
536
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const result = await handleReactionIntercept(
|
|
540
|
+
buildParams({
|
|
541
|
+
rawSenderId: MEMBER_USER_ID,
|
|
542
|
+
trustVerdict: MEMBER_VERDICT,
|
|
543
|
+
op: "removed",
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
expect(dispatchedTurns).toHaveLength(0);
|
|
548
|
+
expect(addMessageCalls).toBe(1);
|
|
549
|
+
expect(result.reaction).toBeUndefined();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("losing the lock after admission degrades the wake to the passive row", async () => {
|
|
553
|
+
storedTarget = null;
|
|
554
|
+
outboundTargetConversationId = "conv-assistant-post";
|
|
555
|
+
targetRow = {
|
|
556
|
+
role: "assistant",
|
|
557
|
+
content: [{ type: "text", text: "Deploy finished cleanly." }],
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
await handleReactionIntercept(
|
|
561
|
+
buildParams({
|
|
562
|
+
rawSenderId: MEMBER_USER_ID,
|
|
563
|
+
trustVerdict: MEMBER_VERDICT,
|
|
564
|
+
}),
|
|
565
|
+
);
|
|
566
|
+
expect(addMessageCalls).toBe(0);
|
|
567
|
+
const fallback = dispatchedTurns[0].onTurnLostToBusy as () => Promise<void>;
|
|
568
|
+
await fallback();
|
|
569
|
+
// Exactly the row the passive path would have written.
|
|
570
|
+
expect(addMessageCalls).toBe(1);
|
|
314
571
|
});
|
|
315
572
|
|
|
316
573
|
test("a reaction on a message that is not stored is dropped without minting", async () => {
|
|
@@ -328,8 +585,21 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
328
585
|
expect(addMessageCalls).toBe(0);
|
|
329
586
|
});
|
|
330
587
|
|
|
331
|
-
test("a
|
|
332
|
-
|
|
588
|
+
test("a reaction on a guardian card neither records nor wakes", async () => {
|
|
589
|
+
// A legacy channel delivery paired a message row before cards became
|
|
590
|
+
// projection-only, so such rows are still stored. The row is
|
|
591
|
+
// assistant-authored, so without the guardian-card gate the wake
|
|
592
|
+
// predicate would read a reaction on it as a signal addressed to the
|
|
593
|
+
// assistant. Content is shaped the way `isGuardianCardRow` reads it.
|
|
594
|
+
targetRow = {
|
|
595
|
+
role: "assistant",
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: "ui_surface",
|
|
599
|
+
surfaceId: "tool-approval-req-legacy-1",
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
};
|
|
333
603
|
|
|
334
604
|
const result = await handleReactionIntercept(
|
|
335
605
|
buildParams({
|
|
@@ -338,28 +608,30 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
338
608
|
}),
|
|
339
609
|
);
|
|
340
610
|
|
|
341
|
-
expect(result).
|
|
342
|
-
|
|
343
|
-
duplicate: true,
|
|
344
|
-
eventId: "evt-1",
|
|
345
|
-
});
|
|
346
|
-
expect(guardianReplyCalls.length).toBe(0);
|
|
347
|
-
expect(addMessageCalls).toBe(0);
|
|
611
|
+
expect(result.reaction).toBe("dropped_guardian_card");
|
|
612
|
+
expect(dispatchedTurns.length).toBe(0);
|
|
348
613
|
expect(recordInboundCalls.length).toBe(0);
|
|
614
|
+
expect(addMessageCalls).toBe(0);
|
|
349
615
|
});
|
|
350
616
|
|
|
351
|
-
test("a
|
|
352
|
-
|
|
353
|
-
|
|
617
|
+
test("a redelivered reaction is answered once, before any lookup", async () => {
|
|
618
|
+
const externalMessageId = `${SLACK_CHANNEL_ID}:1700000000.1:redelivered`;
|
|
619
|
+
seedRecordedEvent(externalMessageId, "evt-1");
|
|
354
620
|
|
|
355
621
|
const result = await handleReactionIntercept(
|
|
356
622
|
buildParams({
|
|
357
623
|
rawSenderId: GUARDIAN_USER_ID,
|
|
358
624
|
trustVerdict: GUARDIAN_VERDICT,
|
|
625
|
+
externalMessageId,
|
|
359
626
|
}),
|
|
360
627
|
);
|
|
361
628
|
|
|
362
|
-
expect(result).toEqual(
|
|
629
|
+
expect(result).toEqual({
|
|
630
|
+
accepted: true,
|
|
631
|
+
duplicate: true,
|
|
632
|
+
eventId: "evt-1",
|
|
633
|
+
});
|
|
634
|
+
expect(addMessageCalls).toBe(0);
|
|
363
635
|
expect(recordInboundCalls.length).toBe(0);
|
|
364
636
|
});
|
|
365
637
|
|
|
@@ -402,7 +674,6 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
402
674
|
});
|
|
403
675
|
|
|
404
676
|
test("classification is verdict-only: no IPC, cache, or local-store reads", async () => {
|
|
405
|
-
guardianReplyResponse = { accepted: true, canonicalRouter: "applied" };
|
|
406
677
|
await handleReactionIntercept(
|
|
407
678
|
buildParams({
|
|
408
679
|
rawSenderId: GUARDIAN_USER_ID,
|
|
@@ -422,3 +693,92 @@ describe("reaction intercept consumes the stamped verdict directly", () => {
|
|
|
422
693
|
expect(contactLookups).toBe(0);
|
|
423
694
|
});
|
|
424
695
|
});
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Add, remove, re-add: the sequence whose third event must survive dedup.
|
|
699
|
+
*
|
|
700
|
+
* The intercept returns before `persistPassively` on a duplicate, so an id
|
|
701
|
+
* that repeats across occurrences costs the re-add its transcript row and
|
|
702
|
+
* leaves the removal as the last recorded state. The ids here are the shapes
|
|
703
|
+
* the gateway normalizers build (`slack/reaction-normalizer.ts`,
|
|
704
|
+
* `discord/normalize.ts`), whose per-event component is the trailing segment.
|
|
705
|
+
*/
|
|
706
|
+
describe("a re-added reaction records again", () => {
|
|
707
|
+
const SLACK_IDS = [
|
|
708
|
+
`${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:Ev001`,
|
|
709
|
+
`${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:removed:Ev002`,
|
|
710
|
+
`${SLACK_CHANNEL_ID}:1700000000.1:white_check_mark:${MEMBER_USER_ID}:Ev003`,
|
|
711
|
+
];
|
|
712
|
+
const DISCORD_IDS = [
|
|
713
|
+
`1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:dispatch-0`,
|
|
714
|
+
`1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:removed:dispatch-1`,
|
|
715
|
+
`1700000000.1:reaction:\u2705:${MEMBER_USER_ID}:dispatch-2`,
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
beforeEach(resetHarness);
|
|
719
|
+
|
|
720
|
+
async function replay(
|
|
721
|
+
externalMessageIds: string[],
|
|
722
|
+
ops: Array<"added" | "removed">,
|
|
723
|
+
sourceChannel: "slack" | "discord" = "slack",
|
|
724
|
+
) {
|
|
725
|
+
const results: Array<Record<string, unknown>> = [];
|
|
726
|
+
for (const [index, externalMessageId] of externalMessageIds.entries()) {
|
|
727
|
+
results.push(
|
|
728
|
+
await handleReactionIntercept(
|
|
729
|
+
buildParams({
|
|
730
|
+
rawSenderId: MEMBER_USER_ID,
|
|
731
|
+
trustVerdict: MEMBER_VERDICT,
|
|
732
|
+
op: ops[index],
|
|
733
|
+
externalMessageId,
|
|
734
|
+
sourceChannel,
|
|
735
|
+
}),
|
|
736
|
+
),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
return results;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
test("Slack: three events, three transcript rows, none deduped away", async () => {
|
|
743
|
+
const results = await replay(SLACK_IDS, ["added", "removed", "added"]);
|
|
744
|
+
|
|
745
|
+
expect(results.map((result) => result.duplicate)).toEqual([
|
|
746
|
+
false,
|
|
747
|
+
false,
|
|
748
|
+
false,
|
|
749
|
+
]);
|
|
750
|
+
expect(inboundStore.size).toBe(3);
|
|
751
|
+
expect(addMessageCalls).toBe(3);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("Discord: three events, three transcript rows, none deduped away", async () => {
|
|
755
|
+
const results = await replay(
|
|
756
|
+
DISCORD_IDS,
|
|
757
|
+
["added", "removed", "added"],
|
|
758
|
+
"discord",
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
expect(results.map((result) => result.duplicate)).toEqual([
|
|
762
|
+
false,
|
|
763
|
+
false,
|
|
764
|
+
false,
|
|
765
|
+
]);
|
|
766
|
+
expect(inboundStore.size).toBe(3);
|
|
767
|
+
expect(addMessageCalls).toBe(3);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("a genuine redelivery of one event still collapses to one", async () => {
|
|
771
|
+
// What the dedup id is for: the gateway retries a forward with the same
|
|
772
|
+
// payload, so the same id arrives twice and the second writes nothing.
|
|
773
|
+
const [first, second] = await replay(
|
|
774
|
+
[SLACK_IDS[0]!, SLACK_IDS[0]!],
|
|
775
|
+
["added", "added"],
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
expect(first!.duplicate).toBe(false);
|
|
779
|
+
expect(second!.duplicate).toBe(true);
|
|
780
|
+
expect(second!.eventId).toBe(first!.eventId);
|
|
781
|
+
expect(inboundStore.size).toBe(1);
|
|
782
|
+
expect(addMessageCalls).toBe(1);
|
|
783
|
+
});
|
|
784
|
+
});
|