@vellumai/assistant 0.11.8 → 0.11.9-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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__/gemini-provider.test.ts +46 -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__/pricing.test.ts +11 -0
- 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 +18 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +19 -6
- 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/config-model.test.ts +1 -0
- 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/substrate/__tests__/skill-content.test.ts +17 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +7 -0
- 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/__tests__/render-injection.test.ts +22 -0
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/prune.test.ts +20 -2
- package/src/plugins/defaults/memory/v3/render-injection.ts +10 -1
- 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 +15 -9
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +47 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { REACTION_MESSAGE_KIND } from "../persistence/conversation-types.js";
|
|
3
4
|
import { resolveMessageContentBlocks } from "../persistence/message-content-file.js";
|
|
4
5
|
import type { RuntimeAttachmentMetadata } from "../runtime/http-types.js";
|
|
5
6
|
|
|
@@ -32,8 +33,17 @@ type UpdateMessageMetadataCall = {
|
|
|
32
33
|
};
|
|
33
34
|
const updateMessageMetadataCalls: UpdateMessageMetadataCall[] = [];
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Number of leading `updateMessageMetadata` calls that throw a transient
|
|
38
|
+
* SQLite error before the mock starts writing. Lets a test assert that a lost
|
|
39
|
+
* reconciliation write is recovered rather than forfeited.
|
|
40
|
+
*/
|
|
41
|
+
let metadataWriteFailuresRemaining = 0;
|
|
42
|
+
|
|
35
43
|
/** Per-test override for the synthetic Slack `ts` returned by deliverChannelReply. */
|
|
36
44
|
let nextDeliveryTs: string | null = null;
|
|
45
|
+
/** Per-segment ts values, consumed in order before `nextDeliveryTs`. */
|
|
46
|
+
const deliveryTsQueue: string[] = [];
|
|
37
47
|
|
|
38
48
|
type RenderedHistoryStub = {
|
|
39
49
|
text: string;
|
|
@@ -83,12 +93,12 @@ mock.module("../runtime/gateway-client.js", () => ({
|
|
|
83
93
|
throw new Error("Simulated delivery failure (502)");
|
|
84
94
|
}
|
|
85
95
|
deliveryCalls.push({ callbackUrl, payload });
|
|
96
|
+
const queued = deliveryTsQueue.shift();
|
|
97
|
+
if (queued !== undefined) {
|
|
98
|
+
return { ok: true, ts: queued };
|
|
99
|
+
}
|
|
86
100
|
if (nextDeliveryTs !== null) {
|
|
87
|
-
|
|
88
|
-
// Only the first segment of a multi-segment delivery should carry
|
|
89
|
-
// back a meaningful ts for `channelTs` reconciliation. Tests that
|
|
90
|
-
// need specific ts values per-segment can re-set this between calls.
|
|
91
|
-
return { ok: true, ts };
|
|
101
|
+
return { ok: true, ts: nextDeliveryTs };
|
|
92
102
|
}
|
|
93
103
|
return { ok: true };
|
|
94
104
|
},
|
|
@@ -141,10 +151,31 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
141
151
|
},
|
|
142
152
|
getMessageById: (messageId: string) =>
|
|
143
153
|
conversationMessages.find((m) => m.id === messageId) ?? null,
|
|
154
|
+
// Stands in for the schema-validated parse. The delivery reader pairs it
|
|
155
|
+
// with the real `isReactionMessageMetadata`, so the marker the assertions
|
|
156
|
+
// turn on is still the production predicate reading a production-shaped row.
|
|
157
|
+
parseMessageMetadata: (metadataJson: string | null) => {
|
|
158
|
+
if (!metadataJson) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(metadataJson) as Record<string, unknown>;
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
144
167
|
updateMessageMetadata: (
|
|
145
168
|
messageId: string,
|
|
146
169
|
updates: Record<string, unknown>,
|
|
147
170
|
) => {
|
|
171
|
+
if (metadataWriteFailuresRemaining > 0) {
|
|
172
|
+
metadataWriteFailuresRemaining -= 1;
|
|
173
|
+
// Shaped like bun:sqlite's error so `withSqliteRetry` classifies it as
|
|
174
|
+
// transient contention rather than a fatal write.
|
|
175
|
+
throw Object.assign(new Error("database is locked"), {
|
|
176
|
+
code: "SQLITE_BUSY",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
148
179
|
updateMessageMetadataCalls.push({ messageId, updates });
|
|
149
180
|
const row = conversationMessages.find((m) => m.id === messageId);
|
|
150
181
|
if (!row) {
|
|
@@ -159,15 +190,43 @@ mock.module("../persistence/conversation-crud.js", () => ({
|
|
|
159
190
|
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
160
191
|
}));
|
|
161
192
|
|
|
193
|
+
// The reconciler writes the outbound-posts index through delivery-crud;
|
|
194
|
+
// stub it so this suite stays DB-free while capturing the writes.
|
|
195
|
+
const recordedOutboundPosts: Array<Record<string, string>> = [];
|
|
196
|
+
/** Runs after each index write: a seam to observe or interleave a deletion before the envelope stamp. */
|
|
197
|
+
let onRecordOutboundPost: ((post: Record<string, string>) => void) | null =
|
|
198
|
+
null;
|
|
199
|
+
mock.module("../persistence/delivery-crud.js", () => ({
|
|
200
|
+
recordOutboundPost: (post: Record<string, string>) => {
|
|
201
|
+
recordedOutboundPosts.push(post);
|
|
202
|
+
onRecordOutboundPost?.(post);
|
|
203
|
+
},
|
|
204
|
+
}));
|
|
205
|
+
|
|
162
206
|
mock.module("../persistence/attachments-store.js", () => ({
|
|
163
207
|
getAttachmentMetadataForMessage: (messageId: string) =>
|
|
164
208
|
attachmentsByMessageId.get(messageId) ?? [],
|
|
165
209
|
getFilePathForAttachment: () => null,
|
|
166
210
|
}));
|
|
167
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Renders keyed by a row's raw string content, consulted ahead of the queue.
|
|
214
|
+
* The queue and the shared default are argument-blind, so a suite that mixes
|
|
215
|
+
* rows in one scan cannot otherwise tell them apart, and a row the code under
|
|
216
|
+
* test is supposed to skip would still render as its neighbour.
|
|
217
|
+
*/
|
|
218
|
+
const renderedHistoryContentByContent = new Map<string, RenderedHistoryStub>();
|
|
219
|
+
|
|
168
220
|
mock.module("../daemon/handlers/shared.js", () => ({
|
|
169
|
-
renderHistoryContent: () =>
|
|
170
|
-
|
|
221
|
+
renderHistoryContent: (content: unknown) => {
|
|
222
|
+
if (typeof content === "string") {
|
|
223
|
+
const keyed = renderedHistoryContentByContent.get(content);
|
|
224
|
+
if (keyed) {
|
|
225
|
+
return keyed;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return renderedHistoryContentQueue.shift() ?? renderedHistoryContent;
|
|
229
|
+
},
|
|
171
230
|
}));
|
|
172
231
|
|
|
173
232
|
const {
|
|
@@ -183,8 +242,13 @@ describe("channel-reply-delivery", () => {
|
|
|
183
242
|
conversationMessages.length = 0;
|
|
184
243
|
attachmentsByMessageId.clear();
|
|
185
244
|
updateMessageMetadataCalls.length = 0;
|
|
245
|
+
metadataWriteFailuresRemaining = 0;
|
|
186
246
|
nextDeliveryTs = null;
|
|
247
|
+
deliveryTsQueue.length = 0;
|
|
248
|
+
recordedOutboundPosts.length = 0;
|
|
249
|
+
onRecordOutboundPost = null;
|
|
187
250
|
renderedHistoryContentQueue.length = 0;
|
|
251
|
+
renderedHistoryContentByContent.clear();
|
|
188
252
|
renderedHistoryContent = {
|
|
189
253
|
text: "",
|
|
190
254
|
textSegments: [],
|
|
@@ -239,6 +303,129 @@ describe("channel-reply-delivery", () => {
|
|
|
239
303
|
);
|
|
240
304
|
});
|
|
241
305
|
|
|
306
|
+
describe("reaction rows are never a turn's reply", () => {
|
|
307
|
+
/** The body `persistReactionRecords` stores; never channel-visible text. */
|
|
308
|
+
const SENTINEL = "[reaction]";
|
|
309
|
+
|
|
310
|
+
const stubFor = (text: string): RenderedHistoryStub => ({
|
|
311
|
+
text,
|
|
312
|
+
textSegments: [text],
|
|
313
|
+
toolCalls: [],
|
|
314
|
+
toolCallsBeforeText: false,
|
|
315
|
+
contentOrder: ["text:0"],
|
|
316
|
+
surfaces: [],
|
|
317
|
+
thinkingSegments: [],
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The row `persistReactionRecords` writes at the turn boundary, rendering
|
|
322
|
+
* as the stored sentinel exactly as the real renderer would. Read
|
|
323
|
+
* literally that text is what reaches the channel, so these tests fail on
|
|
324
|
+
* a guard that only stops short of the delivery call.
|
|
325
|
+
*/
|
|
326
|
+
const reactionRow = (id: string): MockMessageRow => {
|
|
327
|
+
renderedHistoryContentByContent.set(SENTINEL, stubFor(SENTINEL));
|
|
328
|
+
return {
|
|
329
|
+
id,
|
|
330
|
+
role: "assistant",
|
|
331
|
+
content: SENTINEL,
|
|
332
|
+
metadata: JSON.stringify({
|
|
333
|
+
messageKind: REACTION_MESSAGE_KIND,
|
|
334
|
+
provenanceSourceChannel: "slack",
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** How the turn's one non-reaction assistant row renders. */
|
|
340
|
+
const renderAs = (text: string): void => {
|
|
341
|
+
renderedHistoryContent = stubFor(text);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
it("resolves a reaction-only turn to its silence marker, not the sentinel row", () => {
|
|
345
|
+
conversationMessages.push(
|
|
346
|
+
{ id: "user-target", role: "user", content: "target" },
|
|
347
|
+
{
|
|
348
|
+
id: "assistant-silence",
|
|
349
|
+
role: "assistant",
|
|
350
|
+
content: "<no_response/>",
|
|
351
|
+
},
|
|
352
|
+
// Drained after the turn's own rows, so it is the newest row and the
|
|
353
|
+
// one a newest-first scan reaches first.
|
|
354
|
+
reactionRow("assistant-reaction"),
|
|
355
|
+
);
|
|
356
|
+
renderAs("<no_response/>");
|
|
357
|
+
|
|
358
|
+
expect(findAssistantReplyMessageIdForTurn("conv-1", "user-target")).toBe(
|
|
359
|
+
"assistant-silence",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("posts nothing at all for a reaction-only turn", async () => {
|
|
364
|
+
conversationMessages.push(
|
|
365
|
+
{ id: "user-target", role: "user", content: "target" },
|
|
366
|
+
{
|
|
367
|
+
id: "assistant-silence",
|
|
368
|
+
role: "assistant",
|
|
369
|
+
content: "<no_response/>",
|
|
370
|
+
},
|
|
371
|
+
reactionRow("assistant-reaction"),
|
|
372
|
+
);
|
|
373
|
+
renderAs("<no_response/>");
|
|
374
|
+
|
|
375
|
+
await deliverReplyViaCallback(
|
|
376
|
+
"conv-1",
|
|
377
|
+
"chat-1",
|
|
378
|
+
"http://gateway/deliver/slack",
|
|
379
|
+
"assistant-1",
|
|
380
|
+
{ messageId: "assistant-silence", sinceMessageId: "user-target" },
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect(deliveryCalls).toEqual([]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("delivers the turn's real reply when a reaction row is newer than it", async () => {
|
|
387
|
+
conversationMessages.push(
|
|
388
|
+
{ id: "user-target", role: "user", content: "target" },
|
|
389
|
+
{ id: "assistant-real", role: "assistant", content: "real reply" },
|
|
390
|
+
reactionRow("assistant-reaction"),
|
|
391
|
+
);
|
|
392
|
+
renderAs("Done.");
|
|
393
|
+
|
|
394
|
+
await deliverReplyViaCallback(
|
|
395
|
+
"conv-1",
|
|
396
|
+
"chat-1",
|
|
397
|
+
"http://gateway/deliver/slack",
|
|
398
|
+
"assistant-1",
|
|
399
|
+
{ sinceMessageId: "user-target" },
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(deliveryCalls).toHaveLength(1);
|
|
403
|
+
expect(deliveryCalls[0].payload.text).toBe("Done.");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("falls through to the real reply when the stored reply id names a reaction row", async () => {
|
|
407
|
+
// The retry sweep persists whatever the turn scan returned, so a reply
|
|
408
|
+
// id latched before this guard existed still points at a reaction row.
|
|
409
|
+
conversationMessages.push(
|
|
410
|
+
{ id: "user-target", role: "user", content: "target" },
|
|
411
|
+
{ id: "assistant-real", role: "assistant", content: "real reply" },
|
|
412
|
+
reactionRow("assistant-reaction"),
|
|
413
|
+
);
|
|
414
|
+
renderAs("Done.");
|
|
415
|
+
|
|
416
|
+
await deliverReplyViaCallback(
|
|
417
|
+
"conv-1",
|
|
418
|
+
"chat-1",
|
|
419
|
+
"http://gateway/deliver/slack",
|
|
420
|
+
"assistant-1",
|
|
421
|
+
{ messageId: "assistant-reaction", sinceMessageId: "user-target" },
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(deliveryCalls).toHaveLength(1);
|
|
425
|
+
expect(deliveryCalls[0].payload.text).toBe("Done.");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
242
429
|
it("sends non-empty text segments as separate messages and puts attachments on the last segment", async () => {
|
|
243
430
|
const attachments: RuntimeAttachmentMetadata[] = [
|
|
244
431
|
{
|
|
@@ -787,7 +974,9 @@ describe("channel-reply-delivery", () => {
|
|
|
787
974
|
attachments,
|
|
788
975
|
startFromSegment: 1,
|
|
789
976
|
messageTs: "1700000000.000055",
|
|
790
|
-
onMessageTs: (ts) =>
|
|
977
|
+
onMessageTs: (ts) => {
|
|
978
|
+
seenTs.push(ts);
|
|
979
|
+
},
|
|
791
980
|
});
|
|
792
981
|
|
|
793
982
|
expect(deliveryCalls).toHaveLength(1);
|
|
@@ -1047,24 +1236,24 @@ describe("channel-reply-delivery", () => {
|
|
|
1047
1236
|
// reconciliation every outbound assistant row falls through to the
|
|
1048
1237
|
// legacy/flat fallback and is excluded from thread-tag rendering and the
|
|
1049
1238
|
// active-thread focus block.
|
|
1050
|
-
describe("
|
|
1051
|
-
/**
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const inner: Record<string, unknown> = {
|
|
1059
|
-
source: "slack",
|
|
1060
|
-
eventKind: "message",
|
|
1061
|
-
channelId,
|
|
1062
|
-
...(threadTs ? { threadTs } : {}),
|
|
1063
|
-
};
|
|
1239
|
+
describe("sent-message-id reconciliation", () => {
|
|
1240
|
+
/**
|
|
1241
|
+
* Build the outer envelope mirroring `buildAssistantChannelMetadata`'s
|
|
1242
|
+
* write for a Slack reply: the neutral envelope, Slack's own fields on
|
|
1243
|
+
* its passthrough, and no `messageId` yet, since the row is written
|
|
1244
|
+
* before the post.
|
|
1245
|
+
*/
|
|
1246
|
+
function partialEnvelope(channelId: string, threadId?: string): string {
|
|
1064
1247
|
return JSON.stringify({
|
|
1065
1248
|
userMessageChannel: "slack",
|
|
1066
1249
|
assistantMessageChannel: "slack",
|
|
1067
|
-
|
|
1250
|
+
providerMeta: JSON.stringify({
|
|
1251
|
+
source: "slack",
|
|
1252
|
+
conversationExternalId: channelId,
|
|
1253
|
+
eventKind: "message",
|
|
1254
|
+
...(threadId ? { threadId } : {}),
|
|
1255
|
+
timestampTimezone: "America/New_York",
|
|
1256
|
+
}),
|
|
1068
1257
|
});
|
|
1069
1258
|
}
|
|
1070
1259
|
|
|
@@ -1072,15 +1261,15 @@ describe("channel-reply-delivery", () => {
|
|
|
1072
1261
|
conversationId: string,
|
|
1073
1262
|
messageId: string,
|
|
1074
1263
|
channelId: string,
|
|
1075
|
-
|
|
1264
|
+
threadId?: string,
|
|
1076
1265
|
): void {
|
|
1077
1266
|
conversationMessages.push({
|
|
1078
1267
|
id: messageId,
|
|
1079
1268
|
role: "assistant",
|
|
1080
1269
|
content: '[{"type":"text","text":"hello"}]',
|
|
1081
|
-
metadata:
|
|
1270
|
+
metadata: partialEnvelope(channelId, threadId),
|
|
1082
1271
|
});
|
|
1083
|
-
//
|
|
1272
|
+
// One segment, so onMessageTs fires once.
|
|
1084
1273
|
renderedHistoryContent = {
|
|
1085
1274
|
text: "hello",
|
|
1086
1275
|
textSegments: ["hello"],
|
|
@@ -1092,7 +1281,25 @@ describe("channel-reply-delivery", () => {
|
|
|
1092
1281
|
};
|
|
1093
1282
|
}
|
|
1094
1283
|
|
|
1095
|
-
|
|
1284
|
+
function twoSegments(): void {
|
|
1285
|
+
renderedHistoryContent = {
|
|
1286
|
+
text: "AlphaBeta",
|
|
1287
|
+
textSegments: ["Alpha", "Beta"],
|
|
1288
|
+
toolCalls: [],
|
|
1289
|
+
toolCallsBeforeText: false,
|
|
1290
|
+
contentOrder: ["text:0", "tool:0", "text:1"],
|
|
1291
|
+
surfaces: [],
|
|
1292
|
+
thinkingSegments: [],
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function envelopeOf(messageId: string): Record<string, unknown> {
|
|
1297
|
+
const row = conversationMessages.find((m) => m.id === messageId);
|
|
1298
|
+
const outer = JSON.parse(row?.metadata ?? "{}") as Record<string, string>;
|
|
1299
|
+
return JSON.parse(outer.providerMeta) as Record<string, unknown>;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
it("writes the gateway-returned ts as the row's messageId (top-level reply)", async () => {
|
|
1096
1303
|
pushPartialAssistantRow("conv-recon-top", "msg-recon-top", "C123");
|
|
1097
1304
|
nextDeliveryTs = "1700000123.000456";
|
|
1098
1305
|
|
|
@@ -1104,19 +1311,25 @@ describe("channel-reply-delivery", () => {
|
|
|
1104
1311
|
);
|
|
1105
1312
|
|
|
1106
1313
|
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
expect(
|
|
1111
|
-
|
|
1112
|
-
expect(
|
|
1113
|
-
expect(
|
|
1114
|
-
|
|
1115
|
-
expect(
|
|
1116
|
-
expect(
|
|
1314
|
+
expect(updateMessageMetadataCalls[0].messageId).toBe("msg-recon-top");
|
|
1315
|
+
const envelope = envelopeOf("msg-recon-top");
|
|
1316
|
+
expect(envelope.source).toBe("slack");
|
|
1317
|
+
expect(envelope.conversationExternalId).toBe("C123");
|
|
1318
|
+
expect(envelope.eventKind).toBe("message");
|
|
1319
|
+
expect(envelope.messageId).toBe("1700000123.000456");
|
|
1320
|
+
expect(envelope.threadId).toBeUndefined();
|
|
1321
|
+
// Slack's own field survived the stamp.
|
|
1322
|
+
expect(envelope.timestampTimezone).toBe("America/New_York");
|
|
1323
|
+
expect(recordedOutboundPosts).toHaveLength(1);
|
|
1324
|
+
expect(recordedOutboundPosts[0]).toMatchObject({
|
|
1325
|
+
sourceChannel: "slack",
|
|
1326
|
+
externalChatId: "C123",
|
|
1327
|
+
providerMessageId: "1700000123.000456",
|
|
1328
|
+
messageId: "msg-recon-top",
|
|
1329
|
+
});
|
|
1117
1330
|
});
|
|
1118
1331
|
|
|
1119
|
-
it("preserves an existing
|
|
1332
|
+
it("preserves an existing threadId when stamping the id (threaded reply)", async () => {
|
|
1120
1333
|
pushPartialAssistantRow(
|
|
1121
1334
|
"conv-recon-thread",
|
|
1122
1335
|
"msg-recon-thread",
|
|
@@ -1132,16 +1345,14 @@ describe("channel-reply-delivery", () => {
|
|
|
1132
1345
|
"assistant-recon-thread",
|
|
1133
1346
|
);
|
|
1134
1347
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
expect(parsed.threadTs).toBe("1234.5678");
|
|
1139
|
-
expect(parsed.channelTs).toBe("1700000200.000700");
|
|
1348
|
+
const envelope = envelopeOf("msg-recon-thread");
|
|
1349
|
+
expect(envelope.threadId).toBe("1234.5678");
|
|
1350
|
+
expect(envelope.messageId).toBe("1700000200.000700");
|
|
1140
1351
|
});
|
|
1141
1352
|
|
|
1142
|
-
it("does NOT call updateMessageMetadata when the assistant row has no
|
|
1143
|
-
// vellum
|
|
1144
|
-
//
|
|
1353
|
+
it("does NOT call updateMessageMetadata when the assistant row has no envelope", async () => {
|
|
1354
|
+
// vellum outbound: the row's metadata carries no provider envelope.
|
|
1355
|
+
// The reconciler must short-circuit silently.
|
|
1145
1356
|
conversationMessages.push({
|
|
1146
1357
|
id: "msg-vellum",
|
|
1147
1358
|
role: "assistant",
|
|
@@ -1170,17 +1381,12 @@ describe("channel-reply-delivery", () => {
|
|
|
1170
1381
|
);
|
|
1171
1382
|
|
|
1172
1383
|
expect(updateMessageMetadataCalls.length).toBe(0);
|
|
1384
|
+
expect(recordedOutboundPosts.length).toBe(0);
|
|
1173
1385
|
});
|
|
1174
1386
|
|
|
1175
|
-
it("does NOT
|
|
1176
|
-
// Idempotency: a
|
|
1177
|
-
//
|
|
1178
|
-
const existingMeta = JSON.stringify({
|
|
1179
|
-
source: "slack",
|
|
1180
|
-
eventKind: "message",
|
|
1181
|
-
channelId: "C789",
|
|
1182
|
-
channelTs: "1699999999.000111",
|
|
1183
|
-
});
|
|
1387
|
+
it("does NOT write again when the row already names the ts", async () => {
|
|
1388
|
+
// Idempotency: a redelivery of the same message must not write the
|
|
1389
|
+
// row or the index again.
|
|
1184
1390
|
conversationMessages.push({
|
|
1185
1391
|
id: "msg-already",
|
|
1186
1392
|
role: "assistant",
|
|
@@ -1188,7 +1394,12 @@ describe("channel-reply-delivery", () => {
|
|
|
1188
1394
|
metadata: JSON.stringify({
|
|
1189
1395
|
userMessageChannel: "slack",
|
|
1190
1396
|
assistantMessageChannel: "slack",
|
|
1191
|
-
|
|
1397
|
+
providerMeta: JSON.stringify({
|
|
1398
|
+
source: "slack",
|
|
1399
|
+
conversationExternalId: "C789",
|
|
1400
|
+
eventKind: "message",
|
|
1401
|
+
messageId: "1699999999.000111",
|
|
1402
|
+
}),
|
|
1192
1403
|
}),
|
|
1193
1404
|
});
|
|
1194
1405
|
renderedHistoryContent = {
|
|
@@ -1200,7 +1411,7 @@ describe("channel-reply-delivery", () => {
|
|
|
1200
1411
|
surfaces: [],
|
|
1201
1412
|
thinkingSegments: [],
|
|
1202
1413
|
};
|
|
1203
|
-
nextDeliveryTs = "
|
|
1414
|
+
nextDeliveryTs = "1699999999.000111";
|
|
1204
1415
|
|
|
1205
1416
|
await deliverReplyViaCallback(
|
|
1206
1417
|
"conv-already",
|
|
@@ -1210,23 +1421,125 @@ describe("channel-reply-delivery", () => {
|
|
|
1210
1421
|
);
|
|
1211
1422
|
|
|
1212
1423
|
expect(updateMessageMetadataCalls.length).toBe(0);
|
|
1424
|
+
expect(recordedOutboundPosts.length).toBe(0);
|
|
1213
1425
|
});
|
|
1214
1426
|
|
|
1215
|
-
it("
|
|
1216
|
-
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1427
|
+
it("stamps a reply reserved with Slack's pre-send envelope and converges it onto the neutral one", async () => {
|
|
1428
|
+
// Transitional: such a row is pending for the retry sweep only when a
|
|
1429
|
+
// daemon that reserved Slack replies under `slackMeta` left it there.
|
|
1430
|
+
conversationMessages.push({
|
|
1431
|
+
id: "msg-presend-slack",
|
|
1432
|
+
role: "assistant",
|
|
1433
|
+
content: '[{"type":"text","text":"hello"}]',
|
|
1434
|
+
metadata: JSON.stringify({
|
|
1435
|
+
userMessageChannel: "slack",
|
|
1436
|
+
assistantMessageChannel: "slack",
|
|
1437
|
+
slackMeta: JSON.stringify({
|
|
1438
|
+
source: "slack",
|
|
1439
|
+
eventKind: "message",
|
|
1440
|
+
channelId: "C321",
|
|
1441
|
+
threadTs: "1700000000.000001",
|
|
1442
|
+
timestampTimezone: "America/New_York",
|
|
1443
|
+
timestampTimezoneLabel: "EST",
|
|
1444
|
+
}),
|
|
1445
|
+
}),
|
|
1446
|
+
});
|
|
1220
1447
|
renderedHistoryContent = {
|
|
1221
|
-
text: "
|
|
1222
|
-
textSegments: ["
|
|
1448
|
+
text: "hello",
|
|
1449
|
+
textSegments: ["hello"],
|
|
1223
1450
|
toolCalls: [],
|
|
1224
1451
|
toolCallsBeforeText: false,
|
|
1225
|
-
contentOrder: ["text:0"
|
|
1452
|
+
contentOrder: ["text:0"],
|
|
1453
|
+
surfaces: [],
|
|
1454
|
+
thinkingSegments: [],
|
|
1455
|
+
};
|
|
1456
|
+
nextDeliveryTs = "1700000321.000111";
|
|
1457
|
+
|
|
1458
|
+
await deliverReplyViaCallback(
|
|
1459
|
+
"conv-presend",
|
|
1460
|
+
"C321",
|
|
1461
|
+
"http://gateway/deliver/slack",
|
|
1462
|
+
"assistant-presend",
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
expect(recordedOutboundPosts).toHaveLength(1);
|
|
1466
|
+
expect(recordedOutboundPosts[0]).toMatchObject({
|
|
1467
|
+
sourceChannel: "slack",
|
|
1468
|
+
externalChatId: "C321",
|
|
1469
|
+
providerMessageId: "1700000321.000111",
|
|
1470
|
+
messageId: "msg-presend-slack",
|
|
1471
|
+
});
|
|
1472
|
+
const row = conversationMessages.find(
|
|
1473
|
+
(m) => m.id === "msg-presend-slack",
|
|
1474
|
+
);
|
|
1475
|
+
const outer = JSON.parse(row?.metadata ?? "{}") as Record<
|
|
1476
|
+
string,
|
|
1477
|
+
unknown
|
|
1478
|
+
>;
|
|
1479
|
+
// One envelope per row: the pre-send Slack one is gone, the turn's
|
|
1480
|
+
// other keys survive.
|
|
1481
|
+
expect(outer.slackMeta).toBeUndefined();
|
|
1482
|
+
expect(outer.userMessageChannel).toBe("slack");
|
|
1483
|
+
expect(envelopeOf("msg-presend-slack")).toEqual({
|
|
1484
|
+
source: "slack",
|
|
1485
|
+
conversationExternalId: "C321",
|
|
1486
|
+
eventKind: "message",
|
|
1487
|
+
threadId: "1700000000.000001",
|
|
1488
|
+
messageId: "1700000321.000111",
|
|
1489
|
+
timestampTimezone: "America/New_York",
|
|
1490
|
+
timestampTimezoneLabel: "EST",
|
|
1491
|
+
});
|
|
1492
|
+
const { readSlackMetadataFromMessageMetadata } =
|
|
1493
|
+
await import("../messaging/providers/slack/message-metadata.js");
|
|
1494
|
+
expect(readSlackMetadataFromMessageMetadata(row?.metadata)).toMatchObject(
|
|
1495
|
+
{
|
|
1496
|
+
channelTs: "1700000321.000111",
|
|
1497
|
+
threadTs: "1700000000.000001",
|
|
1498
|
+
timestampTimezoneLabel: "EST",
|
|
1499
|
+
},
|
|
1500
|
+
);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it("leaves a Slack reply that already names its post under slackMeta alone", async () => {
|
|
1504
|
+
conversationMessages.push({
|
|
1505
|
+
id: "msg-slack-complete",
|
|
1506
|
+
role: "assistant",
|
|
1507
|
+
content: '[{"type":"text","text":"hello"}]',
|
|
1508
|
+
metadata: JSON.stringify({
|
|
1509
|
+
slackMeta: JSON.stringify({
|
|
1510
|
+
source: "slack",
|
|
1511
|
+
eventKind: "message",
|
|
1512
|
+
channelId: "C321",
|
|
1513
|
+
channelTs: "1700000100.000001",
|
|
1514
|
+
}),
|
|
1515
|
+
}),
|
|
1516
|
+
});
|
|
1517
|
+
renderedHistoryContent = {
|
|
1518
|
+
text: "hello",
|
|
1519
|
+
textSegments: ["hello"],
|
|
1520
|
+
toolCalls: [],
|
|
1521
|
+
toolCallsBeforeText: false,
|
|
1522
|
+
contentOrder: ["text:0"],
|
|
1226
1523
|
surfaces: [],
|
|
1227
1524
|
thinkingSegments: [],
|
|
1228
1525
|
};
|
|
1229
|
-
nextDeliveryTs = "
|
|
1526
|
+
nextDeliveryTs = "1700000321.000222";
|
|
1527
|
+
|
|
1528
|
+
await deliverReplyViaCallback(
|
|
1529
|
+
"conv-complete",
|
|
1530
|
+
"C321",
|
|
1531
|
+
"http://gateway/deliver/slack",
|
|
1532
|
+
"assistant-complete",
|
|
1533
|
+
);
|
|
1534
|
+
|
|
1535
|
+
expect(updateMessageMetadataCalls.length).toBe(0);
|
|
1536
|
+
expect(recordedOutboundPosts.length).toBe(0);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it("records every segment of a split reply: the first as messageId, the rest as additional posts, all in the index", async () => {
|
|
1540
|
+
pushPartialAssistantRow("conv-multi", "msg-multi", "C999");
|
|
1541
|
+
twoSegments();
|
|
1542
|
+
deliveryTsQueue.push("1700000500.000111", "1700000500.000222");
|
|
1230
1543
|
|
|
1231
1544
|
await deliverReplyViaCallback(
|
|
1232
1545
|
"conv-multi",
|
|
@@ -1235,12 +1548,14 @@ describe("channel-reply-delivery", () => {
|
|
|
1235
1548
|
"assistant-multi",
|
|
1236
1549
|
);
|
|
1237
1550
|
|
|
1238
|
-
// Two delivery POSTs but only one metadata write — the first ts wins.
|
|
1239
1551
|
expect(deliveryCalls.length).toBe(2);
|
|
1240
|
-
expect(updateMessageMetadataCalls.length).toBe(
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
expect(
|
|
1552
|
+
expect(updateMessageMetadataCalls.length).toBe(2);
|
|
1553
|
+
const envelope = envelopeOf("msg-multi");
|
|
1554
|
+
expect(envelope.messageId).toBe("1700000500.000111");
|
|
1555
|
+
expect(envelope.additionalMessageIds).toEqual(["1700000500.000222"]);
|
|
1556
|
+
expect(
|
|
1557
|
+
recordedOutboundPosts.map((post) => post.providerMessageId),
|
|
1558
|
+
).toEqual(["1700000500.000111", "1700000500.000222"]);
|
|
1244
1559
|
});
|
|
1245
1560
|
|
|
1246
1561
|
it("composes with caller-supplied onMessageTs without losing either side-effect", async () => {
|
|
@@ -1254,24 +1569,24 @@ describe("channel-reply-delivery", () => {
|
|
|
1254
1569
|
"http://gateway/deliver/slack",
|
|
1255
1570
|
"assistant-compose",
|
|
1256
1571
|
{
|
|
1257
|
-
onMessageTs: (ts) =>
|
|
1572
|
+
onMessageTs: (ts) => {
|
|
1573
|
+
callerTsSeen.push(ts);
|
|
1574
|
+
},
|
|
1258
1575
|
},
|
|
1259
1576
|
);
|
|
1260
1577
|
|
|
1261
|
-
// Caller's onMessageTs still fires for the delivered segment.
|
|
1262
1578
|
expect(callerTsSeen).toEqual(["1700000600.000222"]);
|
|
1263
|
-
|
|
1264
|
-
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1265
|
-
const merged = updateMessageMetadataCalls[0].updates.slackMeta as string;
|
|
1266
|
-
const parsed = JSON.parse(merged) as Record<string, unknown>;
|
|
1267
|
-
expect(parsed.channelTs).toBe("1700000600.000222");
|
|
1579
|
+
expect(envelopeOf("msg-compose").messageId).toBe("1700000600.000222");
|
|
1268
1580
|
});
|
|
1269
1581
|
|
|
1270
|
-
it("after reconciliation,
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
// reconciliation runs, readSlackMetadata must accept the merged value.
|
|
1582
|
+
it("after reconciliation, the row has a Slack view the transcript renderer accepts", async () => {
|
|
1583
|
+
// The Slack renderers read the neutral envelope through its Slack
|
|
1584
|
+
// view; before the stamp there is none, after it there is.
|
|
1274
1585
|
pushPartialAssistantRow("conv-readback", "msg-readback", "C222");
|
|
1586
|
+
const { readSlackMetadataFromMessageMetadata } =
|
|
1587
|
+
await import("../messaging/providers/slack/message-metadata.js");
|
|
1588
|
+
const before = conversationMessages.find((m) => m.id === "msg-readback");
|
|
1589
|
+
expect(readSlackMetadataFromMessageMetadata(before?.metadata)).toBeNull();
|
|
1275
1590
|
nextDeliveryTs = "1700000700.000333";
|
|
1276
1591
|
|
|
1277
1592
|
await deliverReplyViaCallback(
|
|
@@ -1281,18 +1596,163 @@ describe("channel-reply-delivery", () => {
|
|
|
1281
1596
|
"assistant-readback",
|
|
1282
1597
|
);
|
|
1283
1598
|
|
|
1599
|
+
const after = conversationMessages.find((m) => m.id === "msg-readback");
|
|
1600
|
+
const view = readSlackMetadataFromMessageMetadata(after?.metadata);
|
|
1601
|
+
expect(view).not.toBeNull();
|
|
1602
|
+
expect(view?.channelTs).toBe("1700000700.000333");
|
|
1603
|
+
expect(view?.channelId).toBe("C222");
|
|
1604
|
+
expect(view?.source).toBe("slack");
|
|
1605
|
+
expect(view?.eventKind).toBe("message");
|
|
1606
|
+
expect(view?.timestampTimezone).toBe("America/New_York");
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
it("retries a reconciliation write that hits transient SQLite contention", async () => {
|
|
1610
|
+
// The stamp is the only durable record of the post's id: no later sweep
|
|
1611
|
+
// heals a miss once the event is marked delivered, so a SQLITE_BUSY on
|
|
1612
|
+
// the first attempt must not lose it.
|
|
1613
|
+
pushPartialAssistantRow("conv-busy", "msg-busy", "C333");
|
|
1614
|
+
nextDeliveryTs = "1700000800.000444";
|
|
1615
|
+
metadataWriteFailuresRemaining = 1;
|
|
1616
|
+
|
|
1617
|
+
await deliverReplyViaCallback(
|
|
1618
|
+
"conv-busy",
|
|
1619
|
+
"C333",
|
|
1620
|
+
"http://gateway/deliver/slack",
|
|
1621
|
+
"assistant-busy",
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1284
1624
|
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1625
|
+
expect(envelopeOf("msg-busy").messageId).toBe("1700000800.000444");
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
it("stamps from a later segment when the first segment's write is lost", async () => {
|
|
1629
|
+
// The row records the first id that reaches a DURABLE write, not the
|
|
1630
|
+
// first id observed: a first-invocation latch would forfeit the id
|
|
1631
|
+
// forever when that write fails, and the row would never gain an id
|
|
1632
|
+
// of its own.
|
|
1633
|
+
pushPartialAssistantRow("conv-lost", "msg-lost", "C444");
|
|
1634
|
+
twoSegments();
|
|
1635
|
+
deliveryTsQueue.push("1700000900.000555", "1700000900.000556");
|
|
1636
|
+
// Exhaust the initial attempt plus every `withSqliteRetry` retry, so the
|
|
1637
|
+
// first segment's stamp is genuinely swallowed.
|
|
1638
|
+
metadataWriteFailuresRemaining = 4;
|
|
1639
|
+
|
|
1640
|
+
await deliverReplyViaCallback(
|
|
1641
|
+
"conv-lost",
|
|
1642
|
+
"C444",
|
|
1643
|
+
"http://gateway/deliver/slack",
|
|
1644
|
+
"assistant-lost",
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
expect(deliveryCalls.length).toBe(2);
|
|
1648
|
+
expect(updateMessageMetadataCalls.length).toBe(1);
|
|
1649
|
+
expect(envelopeOf("msg-lost").messageId).toBe("1700000900.000556");
|
|
1650
|
+
// Both posts still reached the index, which does not depend on the
|
|
1651
|
+
// envelope write.
|
|
1652
|
+
expect(
|
|
1653
|
+
recordedOutboundPosts.map((post) => post.providerMessageId),
|
|
1654
|
+
).toEqual(["1700000900.000555", "1700000900.000556"]);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it("commits the reconciliation write before posting the next segment", async () => {
|
|
1658
|
+
// `onMessageTs` is awaited, so the row is durable at each segment
|
|
1659
|
+
// boundary. Otherwise a crash between two posts of a split reply loses
|
|
1660
|
+
// the stamp for a message the reader can already see.
|
|
1661
|
+
pushPartialAssistantRow("conv-order", "msg-order", "C555");
|
|
1662
|
+
twoSegments();
|
|
1663
|
+
deliveryTsQueue.push("1700001000.000666", "1700001000.000667");
|
|
1664
|
+
const seenAtSecondPost: unknown[] = [];
|
|
1665
|
+
onRecordOutboundPost = (post) => {
|
|
1666
|
+
if (post.providerMessageId === "1700001000.000667") {
|
|
1667
|
+
seenAtSecondPost.push(envelopeOf("msg-order").messageId);
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
await deliverReplyViaCallback(
|
|
1672
|
+
"conv-order",
|
|
1673
|
+
"C555",
|
|
1674
|
+
"http://gateway/deliver/slack",
|
|
1675
|
+
"assistant-order",
|
|
1676
|
+
);
|
|
1677
|
+
|
|
1678
|
+
expect(seenAtSecondPost).toEqual(["1700001000.000666"]);
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
it("keeps a deletion that lands between the index write and the envelope stamp", async () => {
|
|
1682
|
+
// Once the index names the post, a delete resolves to the row and
|
|
1683
|
+
// stamps the envelope that still lacks its id. The stamp that follows
|
|
1684
|
+
// must read that state, not the one it saw before the index write,
|
|
1685
|
+
// and keep the row fully deleted: its only post is gone.
|
|
1686
|
+
pushPartialAssistantRow("conv-window", "msg-window", "C666");
|
|
1687
|
+
nextDeliveryTs = "1700001100.000777";
|
|
1688
|
+
onRecordOutboundPost = () => {
|
|
1689
|
+
const row = conversationMessages.find((m) => m.id === "msg-window")!;
|
|
1690
|
+
const outer = JSON.parse(row.metadata!) as Record<string, string>;
|
|
1691
|
+
row.metadata = JSON.stringify({
|
|
1692
|
+
...outer,
|
|
1693
|
+
providerMeta: JSON.stringify({
|
|
1694
|
+
...(JSON.parse(outer.providerMeta) as Record<string, unknown>),
|
|
1695
|
+
deletedMessageIds: ["1700001100.000777"],
|
|
1696
|
+
deletedAt: 1700001101000,
|
|
1697
|
+
}),
|
|
1698
|
+
});
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
await deliverReplyViaCallback(
|
|
1702
|
+
"conv-window",
|
|
1703
|
+
"C666",
|
|
1704
|
+
"http://gateway/deliver/slack",
|
|
1705
|
+
"assistant-window",
|
|
1706
|
+
);
|
|
1707
|
+
|
|
1708
|
+
const envelope = envelopeOf("msg-window");
|
|
1709
|
+
expect(envelope.messageId).toBe("1700001100.000777");
|
|
1710
|
+
expect(envelope.deletedMessageIds).toEqual(["1700001100.000777"]);
|
|
1711
|
+
expect(envelope.deletedAt).toBe(1700001101000);
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
it("clears the row-level marker when a later post arrives after every known post was deleted", async () => {
|
|
1715
|
+
// The first post was deleted before the second segment reconciled, so
|
|
1716
|
+
// the row read as fully deleted. The second post is on the channel,
|
|
1717
|
+
// so the row is not; the per-post record of the first deletion stays.
|
|
1718
|
+
conversationMessages.push({
|
|
1719
|
+
id: "msg-revive",
|
|
1720
|
+
role: "assistant",
|
|
1721
|
+
content: '[{"type":"text","text":"hello"}]',
|
|
1722
|
+
metadata: JSON.stringify({
|
|
1723
|
+
assistantMessageChannel: "slack",
|
|
1724
|
+
providerMeta: JSON.stringify({
|
|
1725
|
+
source: "slack",
|
|
1726
|
+
conversationExternalId: "C777",
|
|
1727
|
+
eventKind: "message",
|
|
1728
|
+
messageId: "1700001200.000100",
|
|
1729
|
+
deletedMessageIds: ["1700001200.000100"],
|
|
1730
|
+
deletedAt: 1700001201000,
|
|
1731
|
+
}),
|
|
1732
|
+
}),
|
|
1733
|
+
});
|
|
1734
|
+
renderedHistoryContent = {
|
|
1735
|
+
text: "second",
|
|
1736
|
+
textSegments: ["second"],
|
|
1737
|
+
toolCalls: [],
|
|
1738
|
+
toolCallsBeforeText: false,
|
|
1739
|
+
contentOrder: ["text:0"],
|
|
1740
|
+
surfaces: [],
|
|
1741
|
+
thinkingSegments: [],
|
|
1742
|
+
};
|
|
1743
|
+
nextDeliveryTs = "1700001200.000200";
|
|
1744
|
+
|
|
1745
|
+
await deliverReplyViaCallback(
|
|
1746
|
+
"conv-revive",
|
|
1747
|
+
"C777",
|
|
1748
|
+
"http://gateway/deliver/slack",
|
|
1749
|
+
"assistant-revive",
|
|
1750
|
+
);
|
|
1751
|
+
|
|
1752
|
+
const envelope = envelopeOf("msg-revive");
|
|
1753
|
+
expect(envelope.additionalMessageIds).toEqual(["1700001200.000200"]);
|
|
1754
|
+
expect(envelope.deletedMessageIds).toEqual(["1700001200.000100"]);
|
|
1755
|
+
expect(envelope.deletedAt).toBeUndefined();
|
|
1296
1756
|
});
|
|
1297
1757
|
});
|
|
1298
1758
|
});
|