@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,8 +1,10 @@
|
|
|
1
1
|
import type { KnownBlock } from "@slack/types";
|
|
2
2
|
import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
|
|
3
3
|
|
|
4
|
+
import { extractThreadTsFromCallbackUrl } from "../../../channels/slack-callback-url.js";
|
|
4
5
|
import { getLogger } from "../../../util/logger.js";
|
|
5
6
|
import type { ChannelTransport } from "../channel-transport.js";
|
|
7
|
+
import { SLACK_STREAM_MARKDOWN_LIMIT } from "./api.js";
|
|
6
8
|
import {
|
|
7
9
|
sendSlackAgentSessionStatus,
|
|
8
10
|
sendSlackAttachments,
|
|
@@ -94,7 +96,31 @@ export const slackTransport: ChannelTransport = {
|
|
|
94
96
|
return { ok };
|
|
95
97
|
},
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
// `chat.startStream` and `chat.appendStream` both cap `markdown_text`, so
|
|
100
|
+
// the caller splits a wider delta and advances its delivered mark once per
|
|
101
|
+
// operation this transport confirms.
|
|
102
|
+
maxStreamTextChars: SLACK_STREAM_MARKDOWN_LIMIT,
|
|
103
|
+
|
|
104
|
+
// `chat.stopStream` finalizes the streamed message in place, so what the
|
|
105
|
+
// stream leaves behind IS the reply and durable delivery must not resend it.
|
|
106
|
+
streamPersists: true,
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `chat.startStream` streams into a thread, so a turn with no thread to
|
|
110
|
+
* open under cannot stream. Resolving that here, from this channel's own
|
|
111
|
+
* callback, is what keeps Slack's addressing out of the shared session:
|
|
112
|
+
* a start with no thread reports not-ok and the caller sends the finished
|
|
113
|
+
* reply instead.
|
|
114
|
+
*/
|
|
115
|
+
async streamReply(ctx, chatId, op) {
|
|
116
|
+
if (op.action !== "start") {
|
|
117
|
+
return sendSlackStreamOp(chatId, op);
|
|
118
|
+
}
|
|
119
|
+
const threadTs =
|
|
120
|
+
op.anchorMessageId ?? extractThreadTsFromCallbackUrl(ctx.callbackUrl);
|
|
121
|
+
if (!threadTs) {
|
|
122
|
+
return { ok: false };
|
|
123
|
+
}
|
|
124
|
+
return sendSlackStreamOp(chatId, { ...op, anchorMessageId: threadTs });
|
|
99
125
|
},
|
|
100
126
|
};
|
|
@@ -35,6 +35,7 @@ const {
|
|
|
35
35
|
sendTelegramReaction,
|
|
36
36
|
sendTelegramReply,
|
|
37
37
|
sendTelegramRichReply,
|
|
38
|
+
TELEGRAM_DRAFT_TEXT_LIMIT,
|
|
38
39
|
} = await import("./send.js");
|
|
39
40
|
const { telegramTransport } = await import("./transport.js");
|
|
40
41
|
|
|
@@ -444,3 +445,161 @@ describe("editTelegramMessage", () => {
|
|
|
444
445
|
expect(sendMessageCalls()).toHaveLength(0);
|
|
445
446
|
});
|
|
446
447
|
});
|
|
448
|
+
|
|
449
|
+
describe("telegramTransport.streamReply", () => {
|
|
450
|
+
const ctx: CallbackContext = {
|
|
451
|
+
callbackUrl: "https://example.test/deliver/telegram?chatId=123",
|
|
452
|
+
params: {},
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
test("opens a draft carrying the whole partial reply", async () => {
|
|
456
|
+
const result = await telegramTransport.streamReply?.(ctx, "123", {
|
|
457
|
+
action: "start",
|
|
458
|
+
text: "Looking that up",
|
|
459
|
+
appended: "Looking that up",
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const calls = callsTo("sendMessageDraft");
|
|
463
|
+
expect(calls).toHaveLength(1);
|
|
464
|
+
expect(calls[0]![1]).toMatchObject({
|
|
465
|
+
chat_id: 123,
|
|
466
|
+
text: "Looking that up",
|
|
467
|
+
});
|
|
468
|
+
// The id is minted here, not handed back by Telegram, and must be usable
|
|
469
|
+
// as the stream id the later append addresses.
|
|
470
|
+
expect(Number(result?.ts)).toBeGreaterThan(0);
|
|
471
|
+
expect(result?.ok).toBe(true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("advances one draft by resending the whole text under the same id", async () => {
|
|
475
|
+
await telegramTransport.streamReply?.(ctx, "123", {
|
|
476
|
+
action: "append",
|
|
477
|
+
streamId: "4242",
|
|
478
|
+
text: "Looking that up. Found it.",
|
|
479
|
+
appended: ". Found it.",
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const calls = callsTo("sendMessageDraft");
|
|
483
|
+
expect(calls).toHaveLength(1);
|
|
484
|
+
// Telegram animates between drafts sharing an id, so the call carries the
|
|
485
|
+
// whole reply so far rather than the delta.
|
|
486
|
+
expect(calls[0]![1]).toMatchObject({
|
|
487
|
+
chat_id: 123,
|
|
488
|
+
draft_id: 4242,
|
|
489
|
+
text: "Looking that up. Found it.",
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("draws a plan into the draft, since Telegram has no task primitive", async () => {
|
|
494
|
+
await telegramTransport.streamReply?.(ctx, "123", {
|
|
495
|
+
action: "append",
|
|
496
|
+
streamId: "4242",
|
|
497
|
+
text: "Working.",
|
|
498
|
+
plan: {
|
|
499
|
+
title: "Answering",
|
|
500
|
+
steps: [
|
|
501
|
+
{ label: "Search docs", status: "completed" },
|
|
502
|
+
{ label: "Summarize", status: "in_progress" },
|
|
503
|
+
{ label: "Reply", status: "pending" },
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const body = callsTo("sendMessageDraft")[0]![1] as { text: string };
|
|
509
|
+
expect(body.text).toBe(
|
|
510
|
+
"Working.\n\nAnswering\n✓ Search docs\n▸ Summarize\n· Reply",
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("stopping does nothing, because sending the reply clears the draft", async () => {
|
|
515
|
+
const result = await telegramTransport.streamReply?.(ctx, "123", {
|
|
516
|
+
action: "stop",
|
|
517
|
+
streamId: "4242",
|
|
518
|
+
text: "All done.",
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(callsTo("sendMessageDraft")).toHaveLength(0);
|
|
522
|
+
expect(result).toEqual({ ok: true, ts: "4242" });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("sends the chat id as the integer the draft method requires", async () => {
|
|
526
|
+
// `sendMessageDraft` takes an Integer chat_id, not the "Integer or String"
|
|
527
|
+
// most methods accept, so the string the transport carries has to become a
|
|
528
|
+
// number on the wire.
|
|
529
|
+
await telegramTransport.streamReply?.(ctx, "123", {
|
|
530
|
+
action: "start",
|
|
531
|
+
text: "Hi",
|
|
532
|
+
appended: "Hi",
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const body = callsTo("sendMessageDraft")[0]![1] as { chat_id: unknown };
|
|
536
|
+
expect(body.chat_id).toBe(123);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("refuses a chat id that cannot be an integer, without calling out", async () => {
|
|
540
|
+
const result = await telegramTransport.streamReply?.(ctx, "@somechannel", {
|
|
541
|
+
action: "start",
|
|
542
|
+
text: "Hi",
|
|
543
|
+
appended: "Hi",
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
expect(callsTo("sendMessageDraft")).toHaveLength(0);
|
|
547
|
+
expect(result).toEqual({ ok: false });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("a draft past the cap keeps its live tail, not a frozen prefix", async () => {
|
|
551
|
+
// Telegram caps a draft at 4096. Keeping the head would freeze the preview
|
|
552
|
+
// the moment the reply passed the cap, and would cut off anything drawn
|
|
553
|
+
// beneath it; the tail is the part still moving.
|
|
554
|
+
const body = "HEADMARK" + "a".repeat(5_000);
|
|
555
|
+
await telegramTransport.streamReply?.(ctx, "123", {
|
|
556
|
+
action: "append",
|
|
557
|
+
streamId: "4242",
|
|
558
|
+
text: body + "TAILMARK",
|
|
559
|
+
appended: "TAILMARK",
|
|
560
|
+
plan: { steps: [{ label: "Summarize", status: "in_progress" }] },
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const sent = (callsTo("sendMessageDraft")[0]![1] as { text: string }).text;
|
|
564
|
+
expect(sent.length).toBe(TELEGRAM_DRAFT_TEXT_LIMIT);
|
|
565
|
+
// The newest text and the plan beneath it survive; the stale head is what
|
|
566
|
+
// gets dropped, which is the opposite of a frozen prefix.
|
|
567
|
+
expect(sent).toContain("TAILMARK");
|
|
568
|
+
expect(sent).toContain("Summarize");
|
|
569
|
+
expect(sent).not.toContain("HEADMARK");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("a trimmed draft never begins with half of a character", async () => {
|
|
573
|
+
// The cap counts UTF-16 code units, so a tail cut can land between the
|
|
574
|
+
// halves of an emoji and send a lone surrogate.
|
|
575
|
+
const emoji = "\u{1F600}";
|
|
576
|
+
const body = emoji.repeat(3_000);
|
|
577
|
+
await telegramTransport.streamReply?.(ctx, "123", {
|
|
578
|
+
action: "append",
|
|
579
|
+
streamId: "4242",
|
|
580
|
+
text: body,
|
|
581
|
+
appended: emoji,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const sent = (callsTo("sendMessageDraft")[0]![1] as { text: string }).text;
|
|
585
|
+
const first = sent.charCodeAt(0);
|
|
586
|
+
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
|
|
587
|
+
expect(sent.length).toBeLessThanOrEqual(TELEGRAM_DRAFT_TEXT_LIMIT);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("a refused draft reports not-ok so the caller falls back", async () => {
|
|
591
|
+
// Telegram offers drafts in private chats only; anywhere else the call is
|
|
592
|
+
// rejected, and that rejection is the whole of the per-conversation rule.
|
|
593
|
+
callTelegramBotApiMock.mockImplementation(async () => {
|
|
594
|
+
throw new Error("Bad Request: chat type is not supported");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const result = await telegramTransport.streamReply?.(ctx, "123", {
|
|
598
|
+
action: "start",
|
|
599
|
+
text: "Looking that up",
|
|
600
|
+
appended: "Looking that up",
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
expect(result).toEqual({ ok: false });
|
|
604
|
+
});
|
|
605
|
+
});
|
|
@@ -444,3 +444,96 @@ export async function sendTelegramTypingIndicator(
|
|
|
444
444
|
return false;
|
|
445
445
|
}
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Live message drafts (sendMessageDraft)
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Telegram caps a message, and so a draft's text, at 4096 characters.
|
|
454
|
+
*
|
|
455
|
+
* A draft is a preview rather than the reply, so an over-long partial keeps
|
|
456
|
+
* its tail rather than being split across drafts: splitting would animate the
|
|
457
|
+
* reader back to the start of the reply every time it grew past the cap. The
|
|
458
|
+
* tail is also the live end of the draft, so a reply past the cap keeps
|
|
459
|
+
* moving instead of freezing on a prefix, and anything drawn beneath it
|
|
460
|
+
* stays visible.
|
|
461
|
+
*/
|
|
462
|
+
export const TELEGRAM_DRAFT_TEXT_LIMIT = 4096;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The tail of a draft that Telegram will accept, cut on a character boundary.
|
|
466
|
+
*
|
|
467
|
+
* The cap counts UTF-16 code units, so slicing to it can land between the two
|
|
468
|
+
* halves of an astral character (an emoji, which a plan's status glyphs and a
|
|
469
|
+
* reply's own text both carry) and send a lone surrogate. Dropping a leading
|
|
470
|
+
* low surrogate costs one character and keeps the text well-formed.
|
|
471
|
+
*/
|
|
472
|
+
function draftTail(text: string): string {
|
|
473
|
+
if (text.length <= TELEGRAM_DRAFT_TEXT_LIMIT) {
|
|
474
|
+
return text;
|
|
475
|
+
}
|
|
476
|
+
const tail = text.slice(-TELEGRAM_DRAFT_TEXT_LIMIT);
|
|
477
|
+
const first = tail.charCodeAt(0);
|
|
478
|
+
return first >= 0xdc00 && first <= 0xdfff ? tail.slice(1) : tail;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* How long a draft survives without being re-sent: Telegram describes it as
|
|
483
|
+
* "a temporary 30-second preview". A draft that stops being advanced
|
|
484
|
+
* disappears rather than lingering with stale text.
|
|
485
|
+
*/
|
|
486
|
+
export const TELEGRAM_DRAFT_TTL_MS = 30_000;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Show, or advance, the live draft of a reply still being written.
|
|
490
|
+
*
|
|
491
|
+
* `sendMessageDraft` takes the whole partial reply rather than a delta and
|
|
492
|
+
* lets Telegram's clients animate the difference, which is why every call
|
|
493
|
+
* passes the full text. Reusing one `draft_id` is what makes those calls read
|
|
494
|
+
* as one growing draft: a different id replaces the draft without animation.
|
|
495
|
+
* Empty text is meaningful, rendering Telegram's own "Thinking..." placeholder,
|
|
496
|
+
* so it is passed through rather than skipped.
|
|
497
|
+
*
|
|
498
|
+
* The draft is a preview and never the reply. Telegram's own words: "once the
|
|
499
|
+
* output is finalized, you must call sendMessage with the complete message to
|
|
500
|
+
* persist it". It also clears the moment the bot sends a real message, so
|
|
501
|
+
* nothing has to clear it, and it survives only {@link TELEGRAM_DRAFT_TTL_MS}
|
|
502
|
+
* without being re-sent.
|
|
503
|
+
*
|
|
504
|
+
* Private chats only, and `chat_id` here is an Integer rather than the
|
|
505
|
+
* "Integer or String" most methods accept, so a non-numeric chat id cannot
|
|
506
|
+
* address a draft at all and is refused rather than sent to be rejected.
|
|
507
|
+
*
|
|
508
|
+
* @see https://core.telegram.org/bots/api#sendmessagedraft
|
|
509
|
+
*/
|
|
510
|
+
export async function sendTelegramMessageDraft(
|
|
511
|
+
chatId: string,
|
|
512
|
+
draftId: number,
|
|
513
|
+
text: string,
|
|
514
|
+
opts?: TelegramSendOptions,
|
|
515
|
+
): Promise<boolean> {
|
|
516
|
+
const numericChatId = Number(chatId);
|
|
517
|
+
if (!Number.isSafeInteger(numericChatId)) {
|
|
518
|
+
log.debug(
|
|
519
|
+
{ chatId, draftId },
|
|
520
|
+
"Telegram drafts address a chat by integer id; skipping draft",
|
|
521
|
+
);
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
await callTelegramBotApi("sendMessageDraft", {
|
|
526
|
+
chat_id: numericChatId,
|
|
527
|
+
draft_id: draftId,
|
|
528
|
+
text: draftTail(text),
|
|
529
|
+
...threadIdPayloadFields(opts),
|
|
530
|
+
});
|
|
531
|
+
return true;
|
|
532
|
+
} catch (err) {
|
|
533
|
+
log.debug(
|
|
534
|
+
{ err, chatId, draftId },
|
|
535
|
+
"Failed to send Telegram message draft",
|
|
536
|
+
);
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StreamPlan, StreamPlanStep } from "@vellumai/gateway-client";
|
|
1
2
|
import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
|
|
2
3
|
|
|
3
4
|
import { getLogger } from "../../../util/logger.js";
|
|
@@ -10,6 +11,7 @@ import type { TelegramSendOptions } from "./send.js";
|
|
|
10
11
|
import {
|
|
11
12
|
editTelegramMessage,
|
|
12
13
|
sendTelegramAttachments,
|
|
14
|
+
sendTelegramMessageDraft,
|
|
13
15
|
sendTelegramReaction,
|
|
14
16
|
sendTelegramReply,
|
|
15
17
|
sendTelegramRichReply,
|
|
@@ -27,12 +29,50 @@ function threadOptions(ctx: CallbackContext): TelegramSendOptions | undefined {
|
|
|
27
29
|
return threadId ? { messageThreadId: threadId } : undefined;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A plan drawn into the draft's own text, because Telegram has no task
|
|
34
|
+
* primitive a bot may use: `sendChecklist` is business-account only. Rendering
|
|
35
|
+
* it here rather than above the seam is the point of the seam. The glyphs
|
|
36
|
+
* carry the status without a legend, and a step keeps its place as it
|
|
37
|
+
* advances so the reader watches one list move rather than a new one appear.
|
|
38
|
+
*/
|
|
39
|
+
function renderPlanAsText(plan: StreamPlan): string {
|
|
40
|
+
const glyph: Record<StreamPlanStep["status"], string> = {
|
|
41
|
+
completed: "\u2713",
|
|
42
|
+
in_progress: "\u25b8",
|
|
43
|
+
pending: "\u00b7",
|
|
44
|
+
failed: "\u2717",
|
|
45
|
+
};
|
|
46
|
+
const lines = plan.steps.map((step) => `${glyph[step.status]} ${step.label}`);
|
|
47
|
+
return [plan.title, ...lines].filter(Boolean).join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The draft's whole text: the reply so far, with any plan beneath it. */
|
|
51
|
+
function draftText(text: string, plan: StreamPlan | undefined): string {
|
|
52
|
+
const body = text.trim();
|
|
53
|
+
const planText = plan ? renderPlanAsText(plan) : "";
|
|
54
|
+
return [body, planText].filter(Boolean).join("\n\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Telegram's draft id is minted by the caller, unlike a stream id a platform
|
|
59
|
+
* hands back, and only has to be non-zero and stable for the life of one
|
|
60
|
+
* draft. The clock supplies that without any state to keep between calls.
|
|
61
|
+
*/
|
|
62
|
+
function mintDraftId(): number {
|
|
63
|
+
return Date.now();
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
export const telegramTransport: ChannelTransport = {
|
|
31
67
|
channel: "telegram",
|
|
32
68
|
|
|
33
69
|
// Telegram clears a chat action after about five seconds.
|
|
34
70
|
activityRefreshMs: 4_000,
|
|
35
71
|
|
|
72
|
+
// The draft is a preview: it expires on its own, and the moment the bot
|
|
73
|
+
// sends the real message, so the reply is still owed after the stream ends.
|
|
74
|
+
streamPersists: false,
|
|
75
|
+
|
|
36
76
|
async react(target) {
|
|
37
77
|
return sendTelegramReaction(
|
|
38
78
|
target.chatId,
|
|
@@ -86,6 +126,37 @@ export const telegramTransport: ChannelTransport = {
|
|
|
86
126
|
return { ok: true };
|
|
87
127
|
},
|
|
88
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Show the reply as it is written, in Telegram's own live draft.
|
|
131
|
+
*
|
|
132
|
+
* Every call carries the whole partial reply rather than a delta: Telegram
|
|
133
|
+
* animates the difference between drafts sharing a `draft_id`, so sending
|
|
134
|
+
* only what is new would replace the draft with the fragment. `stop` has
|
|
135
|
+
* nothing to do, since the draft clears itself when the real reply sends.
|
|
136
|
+
*
|
|
137
|
+
* A start that Telegram refuses (a chat that is not private, where drafts
|
|
138
|
+
* are not offered) reports not-ok, and the caller falls back to sending the
|
|
139
|
+
* finished reply. That is the whole of the per-conversation rule: it is
|
|
140
|
+
* answered here, where the platform's constraint lives.
|
|
141
|
+
*/
|
|
142
|
+
async streamReply(ctx, chatId, op) {
|
|
143
|
+
const opts = threadOptions(ctx);
|
|
144
|
+
if (op.action === "stop") {
|
|
145
|
+
return { ok: true, ts: op.streamId };
|
|
146
|
+
}
|
|
147
|
+
const draftId = op.action === "start" ? mintDraftId() : Number(op.streamId);
|
|
148
|
+
if (!Number.isFinite(draftId) || draftId === 0) {
|
|
149
|
+
return { ok: false };
|
|
150
|
+
}
|
|
151
|
+
const sent = await sendTelegramMessageDraft(
|
|
152
|
+
chatId,
|
|
153
|
+
draftId,
|
|
154
|
+
draftText(op.text, op.plan),
|
|
155
|
+
opts,
|
|
156
|
+
);
|
|
157
|
+
return sent ? { ok: true, ts: String(draftId) } : { ok: false };
|
|
158
|
+
},
|
|
159
|
+
|
|
89
160
|
async setActivity(ctx, target) {
|
|
90
161
|
// Telegram's chat action expires by itself after a few seconds, so a phase
|
|
91
162
|
// that is not running needs no clearing call.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { writeSlackMetadata } from "./providers/slack/message-metadata.js";
|
|
4
|
+
import {
|
|
5
|
+
buildNeutralReactionMeta,
|
|
6
|
+
buildSlackReactionMeta,
|
|
7
|
+
type ReactionEnvelopeFacts,
|
|
8
|
+
} from "./reaction-envelopes.js";
|
|
9
|
+
import { readProviderMetadata } from "./read-provider-metadata.js";
|
|
10
|
+
|
|
11
|
+
const typedFacts: ReactionEnvelopeFacts = {
|
|
12
|
+
channel: "slack",
|
|
13
|
+
chatId: "C1",
|
|
14
|
+
targetMessageId: "1700000000.000100",
|
|
15
|
+
emoji: "blob_wave",
|
|
16
|
+
emojiKind: "shortcode",
|
|
17
|
+
emojiName: "blob_wave",
|
|
18
|
+
op: "added",
|
|
19
|
+
actorExternalId: "U1",
|
|
20
|
+
actorDisplayName: "Alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("a reaction's typed emoji survives the stored envelope", () => {
|
|
24
|
+
test("the Slack envelope writes the fields and reads them back", () => {
|
|
25
|
+
const read = readProviderMetadata(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
slackMeta: writeSlackMetadata(buildSlackReactionMeta(typedFacts)),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(read?.reaction).toMatchObject({
|
|
32
|
+
emoji: "blob_wave",
|
|
33
|
+
emojiKind: "shortcode",
|
|
34
|
+
emojiName: "blob_wave",
|
|
35
|
+
op: "added",
|
|
36
|
+
targetMessageId: "1700000000.000100",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the neutral envelope writes the fields and reads them back", () => {
|
|
41
|
+
const stored = buildNeutralReactionMeta({
|
|
42
|
+
...typedFacts,
|
|
43
|
+
channel: "discord",
|
|
44
|
+
emoji: "<:party_blob:111>",
|
|
45
|
+
emojiKind: "custom",
|
|
46
|
+
emojiName: "party_blob",
|
|
47
|
+
emojiId: "111",
|
|
48
|
+
emojiAnimated: true,
|
|
49
|
+
});
|
|
50
|
+
const read = readProviderMetadata(
|
|
51
|
+
JSON.stringify({ providerMeta: JSON.stringify(stored) }),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(read?.reaction).toMatchObject({
|
|
55
|
+
emojiKind: "custom",
|
|
56
|
+
emojiName: "party_blob",
|
|
57
|
+
emojiId: "111",
|
|
58
|
+
emojiAnimated: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a Slack row carrying only the spelling still reads", () => {
|
|
63
|
+
const { emojiKind: _k, emojiName: _n, ...spellingOnly } = typedFacts;
|
|
64
|
+
const read = readProviderMetadata(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
slackMeta: writeSlackMetadata(buildSlackReactionMeta(spellingOnly)),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(read?.reaction?.emoji).toBe("blob_wave");
|
|
71
|
+
expect(read?.reaction?.emojiKind).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
* facts so the inbound intercept and the assistant's own reaction records
|
|
4
4
|
* cannot drift apart.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* The assistant's own reaction rows write the neutral shape on every channel,
|
|
7
|
+
* as every row the daemon authors does. Inbound Slack reaction rows still
|
|
8
|
+
* write Slack's own envelope, which `readProviderMetadata` maps on read; the
|
|
9
|
+
* Slack transcript reads the neutral envelope through its Slack view.
|
|
10
10
|
*/
|
|
11
|
+
import {
|
|
12
|
+
pickReactionEmojiFields,
|
|
13
|
+
type ReactionEmojiFields,
|
|
14
|
+
} from "@vellumai/service-contracts/reactions";
|
|
15
|
+
|
|
11
16
|
import type { ChannelId } from "../channels/types.js";
|
|
12
17
|
import type { ProviderMessageMetadata } from "./provider-message-metadata.js";
|
|
13
18
|
import type { SlackMessageMetadata } from "./providers/slack/message-metadata.js";
|
|
19
|
+
import { writeSlackMetadata } from "./providers/slack/message-metadata.js";
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The emoji's typed identity is optional on the facts because the assistant's
|
|
23
|
+
* own reaction carries only the spelling it chose: it names an emoji rather
|
|
24
|
+
* than reporting one a channel described.
|
|
25
|
+
*/
|
|
26
|
+
export interface ReactionEnvelopeFacts extends ReactionEmojiFields {
|
|
16
27
|
channel: ChannelId;
|
|
17
28
|
/** Provider id of the chat the reaction belongs to. */
|
|
18
29
|
chatId: string;
|
|
@@ -40,6 +51,7 @@ export function buildNeutralReactionMeta(
|
|
|
40
51
|
reaction: {
|
|
41
52
|
targetMessageId: facts.targetMessageId,
|
|
42
53
|
emoji: facts.emoji,
|
|
54
|
+
...pickReactionEmojiFields(facts),
|
|
43
55
|
op: facts.op,
|
|
44
56
|
...(facts.actorDisplayName
|
|
45
57
|
? { actorDisplayName: facts.actorDisplayName }
|
|
@@ -48,6 +60,21 @@ export function buildNeutralReactionMeta(
|
|
|
48
60
|
};
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The serialized metadata key an inbound reaction row stores, chosen per
|
|
65
|
+
* channel: Slack rows write `slackMeta`, every other channel the neutral
|
|
66
|
+
* `providerMeta`. The one owner of that choice, so the two inbound writers
|
|
67
|
+
* (the intercept and the reaction-wake turn) cannot drift. The assistant's
|
|
68
|
+
* own reaction records write `buildNeutralReactionMeta` directly.
|
|
69
|
+
*/
|
|
70
|
+
export function buildReactionRowEnvelope(
|
|
71
|
+
facts: ReactionEnvelopeFacts,
|
|
72
|
+
): { slackMeta: string } | { providerMeta: string } {
|
|
73
|
+
return facts.channel === "slack"
|
|
74
|
+
? { slackMeta: writeSlackMetadata(buildSlackReactionMeta(facts)) }
|
|
75
|
+
: { providerMeta: JSON.stringify(buildNeutralReactionMeta(facts)) };
|
|
76
|
+
}
|
|
77
|
+
|
|
51
78
|
export function buildSlackReactionMeta(
|
|
52
79
|
facts: ReactionEnvelopeFacts,
|
|
53
80
|
): SlackMessageMetadata {
|
|
@@ -69,6 +96,7 @@ export function buildSlackReactionMeta(
|
|
|
69
96
|
...(facts.actorDisplayName ? { displayName: facts.actorDisplayName } : {}),
|
|
70
97
|
reaction: {
|
|
71
98
|
emoji: facts.emoji,
|
|
99
|
+
...pickReactionEmojiFields(facts),
|
|
72
100
|
targetChannelTs: facts.targetMessageId,
|
|
73
101
|
op: facts.op,
|
|
74
102
|
...(facts.actorDisplayName
|
|
@@ -20,9 +20,10 @@ import {
|
|
|
20
20
|
* understands them. History assembly, the conversation route and the
|
|
21
21
|
* transcript renderer then work for a channel nobody here has heard of.
|
|
22
22
|
*
|
|
23
|
-
* `slackMeta` is Slack's own envelope, mapped on read.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* `slackMeta` is Slack's own envelope, mapped on read. Inbound Slack rows
|
|
24
|
+
* still write it; the Slack-only fields it holds have no neutral equivalent
|
|
25
|
+
* and ride the neutral envelope's passthrough on the rows the daemon
|
|
26
|
+
* authors. A channel that writes `providerMeta` needs no adapter at all.
|
|
26
27
|
*
|
|
27
28
|
* Normalizing on read is what allows both. Writing stays where provider
|
|
28
29
|
* detail legitimately lives, and nothing is stored twice.
|