@vellumai/assistant 0.11.8 → 0.11.9-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +2 -2
- package/Dockerfile +8 -48
- package/docker-entrypoint.sh +5 -1
- package/docker-kata-apt-env.sh +3 -0
- package/docker-kata-apt-shims.sh +127 -0
- package/docker-kata-apt-wrapper.sh +45 -0
- package/docker-kata-chroot-exec.sh +35 -0
- package/docker-kata-pip.sh +8 -2
- package/docs/architecture/memory.md +8 -4
- package/docs/guardian-request-flow.md +18 -14
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/avatar-catalog/src/catalog.ts +7 -1
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +110 -5
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +5 -33
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +3 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +100 -9
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +9 -0
- package/node_modules/@vellumai/service-contracts/package.json +4 -1
- package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +18 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +60 -0
- package/openapi.yaml +193 -4
- package/package.json +2 -2
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +43 -82
- package/src/__tests__/always-loaded-tools-guard.test.ts +8 -2
- package/src/__tests__/attachment-stored-path-annotation.test.ts +80 -0
- package/src/__tests__/attachments-store.test.ts +27 -0
- package/src/__tests__/attachments.test.ts +43 -0
- package/src/__tests__/canned-reply-release.test.ts +48 -5
- package/src/__tests__/channel-delivery-store.test.ts +129 -18
- package/src/__tests__/channel-reply-delivery.test.ts +553 -93
- package/src/__tests__/channel-retry-sweep.test.ts +199 -0
- package/src/__tests__/client-os-metadata-persistence.test.ts +32 -3
- package/src/__tests__/conversation-error.test.ts +15 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/conversation-pairing.test.ts +141 -1
- package/src/__tests__/conversation-routes-disk-view.test.ts +28 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +125 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +79 -0
- package/src/__tests__/conversation-store-ephemeral.test.ts +323 -10
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +469 -0
- package/src/__tests__/dm-persistence.test.ts +16 -0
- package/src/__tests__/docker-kata-apt-shims.test.ts +135 -0
- package/src/__tests__/forbidden-legacy-symbols.test.ts +12 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-question-mode.test.ts +108 -0
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -12
- package/src/__tests__/host-proxy-interface.test.ts +10 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +16 -0
- package/src/__tests__/injector-v3-suppression.test.ts +43 -9
- package/src/__tests__/list-messages-page-latest.test.ts +127 -0
- package/src/__tests__/list-messages-system-card.test.ts +103 -0
- package/src/__tests__/media-resolve-image-validation.test.ts +77 -0
- package/src/__tests__/notification-decision-fallback.test.ts +199 -77
- package/src/__tests__/notification-decision-strategy.test.ts +198 -213
- package/src/__tests__/notification-discord-adapter.test.ts +25 -0
- package/src/__tests__/notification-slack-adapter.test.ts +133 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +36 -0
- package/src/__tests__/openai-provider.test.ts +5 -5
- package/src/__tests__/outbound-slack-persistence.test.ts +85 -72
- package/src/__tests__/persist-user-message-set-processing-failure.test.ts +83 -65
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -1
- package/src/__tests__/process-message-display-content.test.ts +20 -9
- package/src/__tests__/processing-acquire-fenced-guard.test.ts +56 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/reaction-persistence.test.ts +22 -198
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +16 -0
- package/src/__tests__/skill-load-tool.test.ts +27 -0
- package/src/__tests__/skills.test.ts +34 -1
- package/src/__tests__/strip-memory-injections.test.ts +3 -4
- package/src/__tests__/terminal-tools.test.ts +9 -0
- package/src/__tests__/thread-backfill.test.ts +5 -3
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/voice-session-bridge.test.ts +18 -0
- package/src/__tests__/watch-retro-report-payload.test.ts +185 -0
- package/src/__tests__/watch-retro-tool-availability.test.ts +82 -0
- package/src/__tests__/workspace-migration-151-repair-renamed-fireworks-deepseek-pro-model-id.test.ts +235 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/agent/attachments.ts +13 -2
- package/src/agent/loop.ts +3 -19
- package/src/api/README.md +9 -5
- package/src/api/index.ts +9 -8
- package/src/api/package.json +1 -0
- package/src/api/responses/conversation-message.ts +8 -0
- package/src/api/responses/home.ts +7 -18
- package/src/api/surfaces.ts +114 -7
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/__tests__/types.test.ts +22 -1
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/channels/types.ts +8 -6
- package/src/cli/__tests__/catalog-search-help.test.ts +12 -0
- package/src/cli/commands/channels/__tests__/channels.test.ts +26 -0
- package/src/cli/commands/channels/index.help.ts +15 -1
- package/src/cli/commands/channels/index.ts +6 -2
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/cli/commands/plugins.help.ts +6 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +0 -8
- package/src/cli/lib/__tests__/install-from-platform.test.ts +72 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +33 -0
- package/src/cli/lib/bundled-marketplace.json +14 -0
- package/src/cli/lib/install-from-github.ts +9 -5
- package/src/cli/lib/install-from-platform.ts +12 -1
- package/src/config/__tests__/assistant-initiated-threads-gate.test.ts +61 -0
- package/src/config/assistant-initiated-threads-gate.ts +50 -0
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/config/bundled-skills/schedule/SKILL.md +25 -11
- package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +3 -1
- package/src/config/call-site-defaults.ts +4 -1
- package/src/config/feature-flag-registry.json +21 -4
- package/src/config/schemas/memory-v3.ts +4 -3
- package/src/context/strip-injections.ts +17 -56
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +31 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +55 -59
- package/src/daemon/conversation-error.ts +2 -0
- package/src/daemon/conversation-messaging.ts +195 -47
- package/src/daemon/conversation-process.ts +20 -5
- package/src/daemon/conversation-runtime-assembly.ts +48 -39
- package/src/daemon/conversation-store.ts +159 -11
- package/src/daemon/conversation-tool-setup.ts +9 -2
- package/src/daemon/conversation.ts +290 -19
- package/src/daemon/dictation-text-processing.ts +2 -7
- package/src/daemon/handlers/config-channels.ts +33 -3
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/port-oversized-content.test.ts +117 -0
- package/src/daemon/port-oversized-content.ts +110 -0
- package/src/daemon/process-message.ts +87 -16
- package/src/daemon/reaction-record.test.ts +23 -11
- package/src/daemon/reaction-record.ts +6 -9
- package/src/documents/document-store.ts +1 -5
- package/src/home/conversation-starter-validation.ts +1 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +910 -50
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +31 -25
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-photo.ts +544 -61
- package/src/live-voice/live-voice-session.ts +6 -2
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/provider-message-metadata.ts +40 -1
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +10 -2
- package/src/messaging/providers/channel-transport.ts +28 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/slack/api.ts +11 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +133 -0
- package/src/messaging/providers/slack/message-metadata.ts +141 -1
- package/src/messaging/providers/slack/send.test.ts +58 -0
- package/src/messaging/providers/slack/send.ts +4 -4
- package/src/messaging/providers/slack/transport.ts +28 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +159 -0
- package/src/messaging/providers/telegram-bot/send.ts +93 -0
- package/src/messaging/providers/telegram-bot/transport.ts +71 -0
- package/src/messaging/reaction-envelopes.test.ts +73 -0
- package/src/messaging/reaction-envelopes.ts +33 -5
- package/src/messaging/read-provider-metadata.ts +4 -3
- package/src/monitoring/recovery/__tests__/stranded-delivery-events.test.ts +208 -0
- package/src/monitoring/recovery/db.ts +35 -0
- package/src/monitoring/recovery/orphaned-channel-events.ts +3 -22
- package/src/monitoring/recovery/run-recovery.ts +6 -2
- package/src/monitoring/recovery/stale-processing.ts +3 -20
- package/src/monitoring/recovery/stranded-delivery-events.ts +68 -0
- package/src/notifications/AGENTS.md +2 -2
- package/src/notifications/README.md +2 -2
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +11 -9
- package/src/notifications/__tests__/broadcaster.test.ts +35 -4
- package/src/notifications/__tests__/notification-utils.test.ts +31 -0
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/adapters/discord.ts +9 -5
- package/src/notifications/adapters/shared.ts +17 -4
- package/src/notifications/adapters/slack.ts +14 -4
- package/src/notifications/adapters/telegram.ts +8 -4
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/broadcaster.ts +57 -25
- package/src/notifications/conversation-pairing.ts +68 -2
- package/src/notifications/copy-composer.ts +13 -32
- package/src/notifications/decision-engine.ts +56 -237
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/notifications/guardian-feed-projection.ts +0 -15
- package/src/notifications/guardian-question-mode.ts +147 -6
- package/src/notifications/notification-utils.ts +117 -1
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/conversation-crud.ts +93 -14
- package/src/persistence/conversation-queries.ts +144 -17
- package/src/persistence/conversation-types.ts +39 -5
- package/src/persistence/delivery-crud.ts +224 -28
- package/src/persistence/delivery-status.ts +21 -2
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +71 -2
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/persistence/steps.ts +4 -0
- package/src/platform/client.ts +55 -0
- package/src/plugins/__tests__/mcp-servers.test.ts +46 -0
- package/src/plugins/defaults/injector-order.ts +2 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +2 -2
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +13 -15
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +10 -0
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/memory-marker.ts +17 -7
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +1 -5
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +54 -41
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +3 -3
- package/src/plugins/defaults/memory/v3/injector.ts +14 -15
- package/src/plugins/defaults/memory/v3/types.ts +15 -4
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/plugins/mcp-servers.ts +14 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +50 -0
- package/src/providers/__tests__/registry-native-web-search.test.ts +49 -2
- package/src/providers/anthropic/client.ts +21 -24
- package/src/providers/call-site-routing.ts +5 -4
- package/src/providers/connection-resolution.ts +29 -1
- package/src/providers/content-block-size.test.ts +82 -0
- package/src/providers/content-block-size.ts +99 -0
- package/src/providers/file-block-text.test.ts +64 -0
- package/src/providers/file-block-text.ts +28 -0
- package/src/providers/gemini/client.ts +12 -7
- package/src/providers/inference/auth.ts +6 -6
- package/src/providers/media-resolve.ts +14 -0
- package/src/providers/model-catalog.ts +31 -34
- package/src/providers/openai/chat-completions-provider.ts +9 -25
- package/src/providers/openai/responses-provider.ts +11 -18
- package/src/providers/registry.ts +10 -7
- package/src/providers/routing-identity.ts +2 -1
- package/src/providers/types.ts +1 -4
- package/src/providers/vellum-model-routing.ts +3 -2
- package/src/runtime/AGENTS.md +40 -15
- package/src/runtime/approval-message-composer.ts +1 -6
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/channel-reply-delivery.ts +182 -115
- package/src/runtime/{slack-reply-session.test.ts → channel-reply-session.test.ts} +294 -156
- package/src/runtime/{slack-reply-session.ts → channel-reply-session.ts} +107 -86
- package/src/runtime/channel-retry-sweep.ts +102 -1
- package/src/runtime/finalize-event-delivery.ts +4 -4
- package/src/runtime/guardian-action-message-composer.ts +1 -4
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/http-router.ts +1 -5
- package/src/runtime/question-request-guardian-bridge.ts +2 -3
- package/src/runtime/routes/__tests__/conversation-list-assistant-section.test.ts +359 -0
- package/src/runtime/routes/__tests__/dictation-command-mode.test.ts +120 -0
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +487 -0
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/canned-reply-release.ts +19 -9
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/channel-verification-routes.ts +4 -1
- package/src/runtime/routes/conversation-list-routes.ts +44 -3
- package/src/runtime/routes/conversation-management-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +138 -59
- package/src/runtime/routes/diagnostics-routes.ts +111 -60
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +170 -48
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +4 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +169 -8
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +82 -14
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +454 -94
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +309 -102
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/routes/sight-frame-routes.ts +136 -0
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/client-os.ts +11 -1
- package/src/tools/host-filesystem/edit.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/transfer.ts +2 -2
- package/src/tools/host-filesystem/write.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +2 -2
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/terminal/safe-env.ts +4 -0
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +4 -3
- package/src/tools/watch/watch-retro-report.ts +205 -0
- package/src/watch/__tests__/watch-retro.test.ts +268 -40
- package/src/watch/watch-retro.ts +190 -77
- package/src/workspace/migrations/151-repair-renamed-fireworks-deepseek-pro-model-id.ts +195 -0
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/__tests__/150-stt-flux-provider-to-model-family.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +4 -0
- package/docker-kata-pip-chroot.sh +0 -22
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
- package/src/api/events/conversation-list-invalidated.ts +0 -38
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { getDb, getSqliteFrom } from "../../../persistence/db-connection.js";
|
|
5
|
+
import { initializeDb } from "../../../persistence/db-init.js";
|
|
6
|
+
import {
|
|
7
|
+
getDaemonBootTimePath,
|
|
8
|
+
recordDaemonBootTime,
|
|
9
|
+
} from "../../daemon-boot-time.js";
|
|
10
|
+
import { recoverStrandedDeliveryEvents } from "../stranded-delivery-events.js";
|
|
11
|
+
|
|
12
|
+
await initializeDb();
|
|
13
|
+
|
|
14
|
+
/** Payload shape of an event whose turn ran and whose delivery is owed. */
|
|
15
|
+
const DELIVERABLE_PAYLOAD = JSON.stringify({
|
|
16
|
+
content: "hello",
|
|
17
|
+
replyCallbackUrl: "https://gateway.local/deliver/slack?threadTs=1.2",
|
|
18
|
+
externalChatId: "C123",
|
|
19
|
+
replyMessageId: "assistant-row-1",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Payload shape of an intercept-settled event (no reply delivery owed). */
|
|
23
|
+
const INTERCEPT_PAYLOAD = JSON.stringify({
|
|
24
|
+
content: "reaction",
|
|
25
|
+
replyCallbackUrl: "https://gateway.local/deliver/slack?threadTs=1.2",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Seed rows via raw SQL rather than delivery-crud / conversation-crud helpers:
|
|
29
|
+
// those modules are `mock.module`-replaced by other test files, and bun's mocks
|
|
30
|
+
// are process-global, so depending on them here makes this file fail when
|
|
31
|
+
// co-run with a mocking file. Raw SQL against the shared DB is isolation-proof.
|
|
32
|
+
function seedEvent(
|
|
33
|
+
id: string,
|
|
34
|
+
opts: {
|
|
35
|
+
createdAt: number;
|
|
36
|
+
processingStatus: string;
|
|
37
|
+
deliveryStatus: string;
|
|
38
|
+
rawPayload: string | null;
|
|
39
|
+
},
|
|
40
|
+
): string {
|
|
41
|
+
const db = getSqliteFrom(getDb());
|
|
42
|
+
const conversationId = `conv-${id}`;
|
|
43
|
+
db.query(
|
|
44
|
+
`INSERT INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)`,
|
|
45
|
+
).run(conversationId, opts.createdAt, opts.createdAt);
|
|
46
|
+
db.query(
|
|
47
|
+
`INSERT INTO channel_inbound_events
|
|
48
|
+
(id, source_channel, external_chat_id, external_message_id,
|
|
49
|
+
conversation_id, delivery_status, processing_status, processing_attempts,
|
|
50
|
+
delivery_attempts, retry_after, raw_payload, delivered_segment_count,
|
|
51
|
+
created_at, updated_at)
|
|
52
|
+
VALUES (?, 'slack', ?, ?, ?, ?, ?, 0, 0, NULL, ?, 0, ?, ?)`,
|
|
53
|
+
).run(
|
|
54
|
+
id,
|
|
55
|
+
`chat-${id}`,
|
|
56
|
+
`msg-${id}`,
|
|
57
|
+
conversationId,
|
|
58
|
+
opts.deliveryStatus,
|
|
59
|
+
opts.processingStatus,
|
|
60
|
+
opts.rawPayload,
|
|
61
|
+
opts.createdAt,
|
|
62
|
+
opts.createdAt,
|
|
63
|
+
);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function statusOf(eventId: string): {
|
|
68
|
+
delivery_status: string;
|
|
69
|
+
retry_after: number | null;
|
|
70
|
+
} {
|
|
71
|
+
return getSqliteFrom(getDb())
|
|
72
|
+
.query(
|
|
73
|
+
`SELECT delivery_status, retry_after
|
|
74
|
+
FROM channel_inbound_events WHERE id = ?`,
|
|
75
|
+
)
|
|
76
|
+
.get(eventId) as { delivery_status: string; retry_after: number | null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("recoverStrandedDeliveryEvents", () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
db.run("DELETE FROM channel_inbound_events");
|
|
83
|
+
db.run("DELETE FROM messages");
|
|
84
|
+
db.run("DELETE FROM conversation_keys");
|
|
85
|
+
db.run("DELETE FROM conversations");
|
|
86
|
+
rmSync(getDaemonBootTimePath(), { force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("promotes a pre-boot processed event whose delivery never finalized", () => {
|
|
90
|
+
const bootTime = Date.now();
|
|
91
|
+
recordDaemonBootTime(bootTime);
|
|
92
|
+
|
|
93
|
+
const eventId = seedEvent("stranded", {
|
|
94
|
+
createdAt: bootTime - 5_000,
|
|
95
|
+
processingStatus: "processed",
|
|
96
|
+
deliveryStatus: "pending",
|
|
97
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
recoverStrandedDeliveryEvents();
|
|
101
|
+
|
|
102
|
+
const row = statusOf(eventId);
|
|
103
|
+
expect(row.delivery_status).toBe("failed"); // the delivery-retry arm selects it
|
|
104
|
+
expect(row.retry_after).not.toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("leaves a processed+pending event created after boot (a live delivery owns it)", () => {
|
|
108
|
+
const bootTime = Date.now();
|
|
109
|
+
recordDaemonBootTime(bootTime);
|
|
110
|
+
|
|
111
|
+
const eventId = seedEvent("live", {
|
|
112
|
+
createdAt: bootTime + 5_000,
|
|
113
|
+
processingStatus: "processed",
|
|
114
|
+
deliveryStatus: "pending",
|
|
115
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
recoverStrandedDeliveryEvents();
|
|
119
|
+
|
|
120
|
+
expect(statusOf(eventId).delivery_status).toBe("pending");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("leaves intercept-settled events whose payload names no reply (no delivery owed)", () => {
|
|
124
|
+
const bootTime = Date.now();
|
|
125
|
+
recordDaemonBootTime(bootTime);
|
|
126
|
+
|
|
127
|
+
const eventId = seedEvent("intercept", {
|
|
128
|
+
createdAt: bootTime - 5_000,
|
|
129
|
+
processingStatus: "processed",
|
|
130
|
+
deliveryStatus: "pending",
|
|
131
|
+
rawPayload: INTERCEPT_PAYLOAD,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
recoverStrandedDeliveryEvents();
|
|
135
|
+
|
|
136
|
+
expect(statusOf(eventId).delivery_status).toBe("pending");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("leaves a stranded-looking event with no stored payload (cannot be redelivered)", () => {
|
|
140
|
+
const bootTime = Date.now();
|
|
141
|
+
recordDaemonBootTime(bootTime);
|
|
142
|
+
|
|
143
|
+
const eventId = seedEvent("no-payload", {
|
|
144
|
+
createdAt: bootTime - 5_000,
|
|
145
|
+
processingStatus: "processed",
|
|
146
|
+
deliveryStatus: "pending",
|
|
147
|
+
rawPayload: null,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
recoverStrandedDeliveryEvents();
|
|
151
|
+
|
|
152
|
+
expect(statusOf(eventId).delivery_status).toBe("pending");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("never touches events with a terminal delivery state", () => {
|
|
156
|
+
const bootTime = Date.now();
|
|
157
|
+
recordDaemonBootTime(bootTime);
|
|
158
|
+
|
|
159
|
+
const delivered = seedEvent("delivered", {
|
|
160
|
+
createdAt: bootTime - 5_000,
|
|
161
|
+
processingStatus: "processed",
|
|
162
|
+
deliveryStatus: "delivered",
|
|
163
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
164
|
+
});
|
|
165
|
+
const deadLetter = seedEvent("dead-letter", {
|
|
166
|
+
createdAt: bootTime - 5_000,
|
|
167
|
+
processingStatus: "processed",
|
|
168
|
+
deliveryStatus: "dead_letter",
|
|
169
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
recoverStrandedDeliveryEvents();
|
|
173
|
+
|
|
174
|
+
expect(statusOf(delivered).delivery_status).toBe("delivered");
|
|
175
|
+
expect(statusOf(deadLetter).delivery_status).toBe("dead_letter");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("leaves unprocessed events to the orphaned-channel-events step", () => {
|
|
179
|
+
const bootTime = Date.now();
|
|
180
|
+
recordDaemonBootTime(bootTime);
|
|
181
|
+
|
|
182
|
+
const eventId = seedEvent("still-pending", {
|
|
183
|
+
createdAt: bootTime - 5_000,
|
|
184
|
+
processingStatus: "pending",
|
|
185
|
+
deliveryStatus: "pending",
|
|
186
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
recoverStrandedDeliveryEvents();
|
|
190
|
+
|
|
191
|
+
expect(statusOf(eventId).delivery_status).toBe("pending");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("skips recovery when the daemon boot time is unavailable", () => {
|
|
195
|
+
// No recordDaemonBootTime(): without the fence a live daemon's in-flight
|
|
196
|
+
// delivery is indistinguishable from a dead process's stranded row.
|
|
197
|
+
const eventId = seedEvent("no-fence", {
|
|
198
|
+
createdAt: Date.now() - 5_000,
|
|
199
|
+
processingStatus: "processed",
|
|
200
|
+
deliveryStatus: "pending",
|
|
201
|
+
rawPayload: DELIVERABLE_PAYLOAD,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
recoverStrandedDeliveryEvents();
|
|
205
|
+
|
|
206
|
+
expect(statusOf(eventId).delivery_status).toBe("pending");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -12,6 +12,7 @@ import { Database } from "bun:sqlite";
|
|
|
12
12
|
|
|
13
13
|
import { getLogger } from "../../util/logger.js";
|
|
14
14
|
import { getDbPath } from "../../util/platform.js";
|
|
15
|
+
import { readDaemonBootTime } from "../daemon-boot-time.js";
|
|
15
16
|
|
|
16
17
|
const log = getLogger("recovery-db");
|
|
17
18
|
|
|
@@ -37,3 +38,37 @@ export function openRecoveryDb(): Database | null {
|
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run one recovery step against a boot-fenced database handle.
|
|
44
|
+
*
|
|
45
|
+
* Every step reconciles rows a dead process left behind, so every step needs
|
|
46
|
+
* the same two guards first: a daemon boot time to fence against (without it
|
|
47
|
+
* a live daemon's in-flight row is indistinguishable from a dead process's
|
|
48
|
+
* orphan) and an openable database. `run` is invoked only when both hold, and
|
|
49
|
+
* the handle is closed however it returns.
|
|
50
|
+
*
|
|
51
|
+
* Steps keep their own logger and their own result logging: this owns the
|
|
52
|
+
* preconditions and the handle's lifetime, not what a step does or says.
|
|
53
|
+
* A throw propagates to the orchestrator, which treats it as "schema not
|
|
54
|
+
* ready yet" and retries on the next monitor run.
|
|
55
|
+
*/
|
|
56
|
+
export function withBootFencedRecoveryDb(
|
|
57
|
+
step: string,
|
|
58
|
+
run: (db: Database, bootTime: number) => void,
|
|
59
|
+
): void {
|
|
60
|
+
const bootTime = readDaemonBootTime();
|
|
61
|
+
if (bootTime == null) {
|
|
62
|
+
log.warn({ step }, "Skipping recovery step: daemon boot time unavailable");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const db = openRecoveryDb();
|
|
66
|
+
if (db == null) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
run(db, bootTime);
|
|
71
|
+
} finally {
|
|
72
|
+
db.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -25,30 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { getLogger } from "../../util/logger.js";
|
|
28
|
-
import {
|
|
29
|
-
import { openRecoveryDb } from "./db.js";
|
|
28
|
+
import { withBootFencedRecoveryDb } from "./db.js";
|
|
30
29
|
|
|
31
30
|
const log = getLogger("recovery-orphaned-channel-events");
|
|
32
31
|
|
|
33
32
|
export function recoverOrphanedChannelEvents(): void {
|
|
34
|
-
|
|
35
|
-
if (bootTime == null) {
|
|
36
|
-
// Without the fence we cannot tell a dead process's orphan from a live
|
|
37
|
-
// daemon's just-arrived pending row, so skip; the next restart reconciles.
|
|
38
|
-
log.warn(
|
|
39
|
-
"Skipping orphaned-channel-event recovery — daemon boot time unavailable",
|
|
40
|
-
);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const db = openRecoveryDb();
|
|
45
|
-
if (db == null) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
try {
|
|
33
|
+
withBootFencedRecoveryDb("orphaned-channel-events", (db, bootTime) => {
|
|
49
34
|
const now = Date.now();
|
|
50
|
-
// Throws here (missing column) propagate to the orchestrator as "schema not
|
|
51
|
-
// ready yet" and retry on the next monitor run.
|
|
52
35
|
const result = db
|
|
53
36
|
.query(
|
|
54
37
|
`UPDATE channel_inbound_events
|
|
@@ -66,7 +49,5 @@ export function recoverOrphanedChannelEvents(): void {
|
|
|
66
49
|
"Promoted orphaned pending channel events onto the retry sweep",
|
|
67
50
|
);
|
|
68
51
|
}
|
|
69
|
-
}
|
|
70
|
-
db.close();
|
|
71
|
-
}
|
|
52
|
+
});
|
|
72
53
|
}
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* path runs here instead, in the resource-monitor process, off the daemon's
|
|
6
6
|
* event loop. It lists a set of recovery steps and runs each ONCE, shortly
|
|
7
7
|
* after the monitor starts — this is crash recovery, not a periodic sweep.
|
|
8
|
-
* Each step owns
|
|
8
|
+
* Each step owns its own reconciliation query and its own logging; the
|
|
9
9
|
* orchestrator only sequences them and isolates one step's failure from the
|
|
10
|
-
* next.
|
|
10
|
+
* next. The preconditions every step shares, a daemon boot time to fence
|
|
11
|
+
* against and an openable handle to close afterwards, live in
|
|
12
|
+
* `withBootFencedRecoveryDb`.
|
|
11
13
|
*
|
|
12
14
|
* Steps run in order; new reconciliations plug in by adding a
|
|
13
15
|
* {@link RecoveryStep} to `RECOVERY_STEPS`. `clear-stale-processing` runs
|
|
@@ -19,6 +21,7 @@ import { getLogger } from "../../util/logger.js";
|
|
|
19
21
|
import { recoverInflightContent } from "./inflight-content.js";
|
|
20
22
|
import { recoverOrphanedChannelEvents } from "./orphaned-channel-events.js";
|
|
21
23
|
import { clearStaleProcessing } from "./stale-processing.js";
|
|
24
|
+
import { recoverStrandedDeliveryEvents } from "./stranded-delivery-events.js";
|
|
22
25
|
|
|
23
26
|
const log = getLogger("recovery");
|
|
24
27
|
|
|
@@ -38,6 +41,7 @@ const RECOVERY_STEPS: RecoveryStep[] = [
|
|
|
38
41
|
{ name: "clear-stale-processing", run: clearStaleProcessing },
|
|
39
42
|
{ name: "inflight-content", run: recoverInflightContent },
|
|
40
43
|
{ name: "orphaned-channel-events", run: recoverOrphanedChannelEvents },
|
|
44
|
+
{ name: "stranded-delivery-events", run: recoverStrandedDeliveryEvents },
|
|
41
45
|
];
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -24,27 +24,12 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { getLogger } from "../../util/logger.js";
|
|
27
|
-
import {
|
|
28
|
-
import { openRecoveryDb } from "./db.js";
|
|
27
|
+
import { withBootFencedRecoveryDb } from "./db.js";
|
|
29
28
|
|
|
30
29
|
const log = getLogger("recovery-stale-processing");
|
|
31
30
|
|
|
32
31
|
export function clearStaleProcessing(): void {
|
|
33
|
-
|
|
34
|
-
if (bootTime == null) {
|
|
35
|
-
// Without the boot-time fence we can't distinguish a dead process's flag
|
|
36
|
-
// from a live turn's, so skip; the next daemon restart reconciles.
|
|
37
|
-
log.warn("Skipping stale-processing clear — daemon boot time unavailable");
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const db = openRecoveryDb();
|
|
42
|
-
if (db == null) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
// Throws here (missing `processing_started_at` column) propagate to the
|
|
47
|
-
// orchestrator as "schema not ready yet" and retry on the next monitor run.
|
|
32
|
+
withBootFencedRecoveryDb("stale-processing", (db, bootTime) => {
|
|
48
33
|
const result = db
|
|
49
34
|
.query(
|
|
50
35
|
`UPDATE conversations
|
|
@@ -59,7 +44,5 @@ export function clearStaleProcessing(): void {
|
|
|
59
44
|
"Cleared stale conversation processing flags from a previous process",
|
|
60
45
|
);
|
|
61
46
|
}
|
|
62
|
-
}
|
|
63
|
-
db.close();
|
|
64
|
-
}
|
|
47
|
+
});
|
|
65
48
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery step: promote channel inbound events stranded between processing
|
|
3
|
+
* and delivery onto the delivery-retry sweep after a crash.
|
|
4
|
+
*
|
|
5
|
+
* A channel turn is marked `processing_status = 'processed'` the moment its
|
|
6
|
+
* agent turn persists (`markProcessed` in `inbound-stages/background-dispatch.ts`),
|
|
7
|
+
* but its reply delivery finalizes afterwards, in memory, ending in
|
|
8
|
+
* `markDeliveryDelivered` / `recordDeliveryFailure`. A daemon that dies inside
|
|
9
|
+
* that window leaves the row `processed` + `delivery_status = 'pending'`, and
|
|
10
|
+
* nothing recovers it: the delivery-retry sweep selects only
|
|
11
|
+
* `delivery_status = 'failed'`, and `recoverOrphanedChannelEvents` only
|
|
12
|
+
* promotes `processing_status = 'pending'` rows. The user-visible symptom is
|
|
13
|
+
* either a reply that never reaches the channel (durable delivery had not run
|
|
14
|
+
* yet) or, when the reply had already streamed live into Slack, a delivered
|
|
15
|
+
* message whose assistant row never gains its sent-message id
|
|
16
|
+
* (`slackMeta.channelTs` / `providerMeta.messageId`), which drops the row out
|
|
17
|
+
* of every projection keyed on that id.
|
|
18
|
+
*
|
|
19
|
+
* This step, run once from the monitor process at startup, promotes those
|
|
20
|
+
* orphans to `delivery_status = 'failed'` with an immediate `retry_after` so
|
|
21
|
+
* the existing delivery-retry arm of the sweep re-delivers from the stored
|
|
22
|
+
* payload. That arm is idempotent against a reply that already streamed: the
|
|
23
|
+
* `slackStreamMessageTs` breadcrumb makes it edit the visible message in
|
|
24
|
+
* place, and the sent-message-id reconciliation stamps the row. Guards:
|
|
25
|
+
*
|
|
26
|
+
* - **Boot-time fence.** Only rows created BEFORE this daemon booted are
|
|
27
|
+
* touched. A newer row belongs to a live turn on the running daemon;
|
|
28
|
+
* promoting it would let the sweep race the in-flight delivery. Mirrors
|
|
29
|
+
* `recoverOrphanedChannelEvents`.
|
|
30
|
+
* - **Delivery must actually be owed.** `processed` + `pending` is the
|
|
31
|
+
* normal terminal state for events the inbound intercepts settle without
|
|
32
|
+
* a reply delivery (reactions, edits, deletes, admission denials), so the
|
|
33
|
+
* promotion requires the payload keys only the turn-and-deliver path
|
|
34
|
+
* writes: `replyCallbackUrl` (stored at ingress for deliverable turns)
|
|
35
|
+
* AND `replyMessageId` (stored by `storeReplyMessageId` right after the
|
|
36
|
+
* turn persists its reply row).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { getLogger } from "../../util/logger.js";
|
|
40
|
+
import { withBootFencedRecoveryDb } from "./db.js";
|
|
41
|
+
|
|
42
|
+
const log = getLogger("recovery-stranded-delivery-events");
|
|
43
|
+
|
|
44
|
+
export function recoverStrandedDeliveryEvents(): void {
|
|
45
|
+
withBootFencedRecoveryDb("stranded-delivery-events", (db, bootTime) => {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const result = db
|
|
48
|
+
.query(
|
|
49
|
+
`UPDATE channel_inbound_events
|
|
50
|
+
SET delivery_status = 'failed',
|
|
51
|
+
retry_after = ?,
|
|
52
|
+
updated_at = ?
|
|
53
|
+
WHERE processing_status = 'processed'
|
|
54
|
+
AND delivery_status = 'pending'
|
|
55
|
+
AND raw_payload IS NOT NULL
|
|
56
|
+
AND json_extract(raw_payload, '$.replyCallbackUrl') IS NOT NULL
|
|
57
|
+
AND json_extract(raw_payload, '$.replyMessageId') IS NOT NULL
|
|
58
|
+
AND created_at < ?`,
|
|
59
|
+
)
|
|
60
|
+
.run(now, now, bootTime);
|
|
61
|
+
if (result.changes > 0) {
|
|
62
|
+
log.info(
|
|
63
|
+
{ promoted: result.changes, bootTime },
|
|
64
|
+
"Promoted stranded processed channel events onto the delivery-retry sweep",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notification producers **MUST** go through `emitNotificationSignal()` in `notifications/emit-signal.ts`. Do not bypass the pipeline by broadcasting events directly -- the pipeline handles event persistence, deduplication, decision routing, and delivery audit.
|
|
4
4
|
|
|
5
|
-
Guardian-request cards (approvals, questions) ride this pipeline end to end -- the full lifecycle map is [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Card actions (`actions[]`) are built **once, centrally** in the broadcaster's context resolvers (`resolveApprovalContext` / `
|
|
5
|
+
Guardian-request cards (approvals, questions) ride this pipeline end to end -- the full lifecycle map is [docs/guardian-request-flow.md](../../docs/guardian-request-flow.md). Card actions (`actions[]`) are built **once, centrally** in the broadcaster's context resolvers (`resolveApprovalContext` / `resolveQuestionContext`); channel adapters render only. Adding buttons for a new request kind = a broadcaster context branch, never adapter parsing.
|
|
6
6
|
|
|
7
7
|
When a notification flow creates a server-side conversation (e.g. guardian question conversations, task run conversations), the conversation and initial message **MUST** be persisted before the conversation-created event is emitted. This ensures the macOS/iOS client can immediately fetch the conversation contents when it receives the event.
|
|
8
8
|
|
|
9
|
-
Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card
|
|
9
|
+
Guardian-request producers (access requests, tool approvals, tool-grant escalations, voice questions, trusted-contact confirmations) **MUST** record approval-card deliveries through `recordApprovalCardDelivery` / `recordGuardianRequestDeliveries` in `guardian-delivery-recorder.ts` -- never by calling the gateway client's `createGuardianRequestDelivery` directly. That sink is the single place the card-to-request addressing convention lives (conversation id for the in-app card; chat + channel-native `ts` for channel cards), so the path that writes a delivery row cannot drift from the paths that read it back to withdraw a card -- the drift that previously fanned this logic into four divergent copies. (Tests may seed delivery rows directly for fixtures.)
|
|
10
10
|
|
|
11
11
|
Approval-card **source references** (the link back to the channel message that triggered a request) resolve only through `resolveApprovalSourceReference()` in `runtime/approval-source-link.ts` -- producers spread the result into the `guardian.question` context payload and never hand-build links. Channel-format knowledge (id shapes, permalinks, mrkdwn) lives only in `messaging/providers/<channel>/` and `notifications/adapters/<channel>`; the four-layer ownership map is documented at the top of `approval-source-link.ts`. Exception: access-request cards predate the registry and still derive their Slack permalink from payload `messageTs` in `access-request-copy.ts` -- converge them onto the registry rather than adding a third resolution path.
|
|
12
12
|
|
|
@@ -57,8 +57,8 @@ Hard invariants that the LLM cannot override:
|
|
|
57
57
|
|
|
58
58
|
**Post-generation enforcement** (`decision-engine.ts`):
|
|
59
59
|
|
|
60
|
-
- **Guardian question
|
|
61
|
-
- **Access-request
|
|
60
|
+
- **Guardian question reply mechanics**: `enforceGuardianRequestCode()` applies one per-channel rule, `guardianCopyCarriesReplyMechanics()` in `guardian-question-mode.ts`, to every channel's `guardian.question` copy. A channel the guardian answers by typing (Telegram, Discord, Slack answer-mode questions) gets the request-code instruction (approve/reject or free-text answer) **enforced in**, even when the LLM omitted it. A channel that acts through the request card gets it **stripped**, bare code mentions included: the vellum copy (read by the bell detail, the OS banner, and the delivery audit row), the platform push, and Slack approval-mode cards with Approve/Reject buttons. The template composer applies the same rule, so copy reaches the same shape on the fallback path.
|
|
61
|
+
- **Access-request copy**: `enforceAccessRequestCopy()` strips a model's echo of the request-code directive (`Reply "X trust"`, `Reply "X verify"`, and the rest) from every channel's `ingress.access_request` copy, as whole sentences, so the directive lives only in the card's `plainTextFallback` (`buildAccessRequestReplyMechanics`), appended by a transport when it sends text without buttons. The invite-flow directive is context, not mechanics: no surface has a button for it, so it stays in `buildAccessRequestContextText` and is ensured into model-composed copy that leaves it out.
|
|
62
62
|
|
|
63
63
|
**Pre-send gate checks** (`deterministic-checks.ts`) — these all depend on the decision, so they run here, after it:
|
|
64
64
|
|
|
@@ -609,20 +609,22 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
609
609
|
});
|
|
610
610
|
|
|
611
611
|
// Control characters would otherwise ride into the APNs payload verbatim.
|
|
612
|
-
|
|
612
|
+
// Newlines stay: iOS (and other lock screens) render them as line breaks.
|
|
613
|
+
test("strips control characters and keeps line breaks in the preview", async () => {
|
|
613
614
|
assistantRow = makeAssistantRow([
|
|
614
615
|
{ type: "text", text: "Hello\n\tthere" },
|
|
615
616
|
] as ContentBlock[]);
|
|
616
617
|
|
|
617
618
|
await run();
|
|
618
619
|
|
|
619
|
-
expect(emitCalls[0].contextPayload.requestedMessage).toBe("Hello
|
|
620
|
+
expect(emitCalls[0].contextPayload.requestedMessage).toBe("Hello\nthere");
|
|
620
621
|
});
|
|
621
622
|
|
|
622
|
-
//
|
|
623
|
-
// length budget on whitespace.
|
|
624
|
-
//
|
|
625
|
-
|
|
623
|
+
// List indentation and extra blank lines would otherwise spend the preview's
|
|
624
|
+
// length budget on whitespace. Markdown list markers are already gone after
|
|
625
|
+
// flattening; the remaining line breaks are the structure a lock screen can
|
|
626
|
+
// render.
|
|
627
|
+
test("keeps paragraph breaks after collapsing horizontal whitespace", async () => {
|
|
626
628
|
assistantRow = makeAssistantRow([
|
|
627
629
|
{ type: "text", text: " Here:\n\n - item\n - other " },
|
|
628
630
|
] as ContentBlock[]);
|
|
@@ -630,7 +632,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
630
632
|
await run();
|
|
631
633
|
|
|
632
634
|
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
633
|
-
"Here
|
|
635
|
+
"Here:\n\nitem\nother",
|
|
634
636
|
);
|
|
635
637
|
});
|
|
636
638
|
|
|
@@ -684,7 +686,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
684
686
|
await run();
|
|
685
687
|
|
|
686
688
|
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
687
|
-
"Fixed it
|
|
689
|
+
"Fixed it:\n\nconst a = 1;",
|
|
688
690
|
);
|
|
689
691
|
});
|
|
690
692
|
|
|
@@ -699,7 +701,7 @@ describe("emitAssistantReplyNotification", () => {
|
|
|
699
701
|
await run();
|
|
700
702
|
|
|
701
703
|
expect(emitCalls[0].contextPayload.requestedMessage).toBe(
|
|
702
|
-
"Keys
|
|
704
|
+
"Keys\n\nEnv Key\ndev 4Y4L",
|
|
703
705
|
);
|
|
704
706
|
});
|
|
705
707
|
|
|
@@ -718,7 +718,7 @@ describe("NotificationBroadcaster question option actions", () => {
|
|
|
718
718
|
expect(approval?.plainTextFallback).toContain("your answer");
|
|
719
719
|
});
|
|
720
720
|
|
|
721
|
-
test("an option-less pending_question (voice) carries no
|
|
721
|
+
test("an option-less pending_question (voice) carries the typed-reply instruction and no actions", async () => {
|
|
722
722
|
const { adapter, sends } = makeCapturingAdapter("platform");
|
|
723
723
|
const broadcaster = new NotificationBroadcaster([adapter]);
|
|
724
724
|
|
|
@@ -735,9 +735,40 @@ describe("NotificationBroadcaster question option actions", () => {
|
|
|
735
735
|
);
|
|
736
736
|
|
|
737
737
|
expect(sends.length).toBe(1);
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
738
|
+
// No buttons to draw, so the transports send text and append the
|
|
739
|
+
// instruction; an approve/reject pair is never attached to a question.
|
|
740
|
+
const approval = sends[0]?.payload.approvalContext;
|
|
741
|
+
expect(approval?.requestId).toBe("req-v1");
|
|
742
|
+
expect(approval?.actions).toEqual([]);
|
|
743
|
+
expect(approval?.intent).toBe("question");
|
|
744
|
+
expect(approval?.plainTextFallback).toBe(
|
|
745
|
+
'Reference code: DEF456. Reply "DEF456 <your answer>".',
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("a coded question that fails strict parsing still carries its typed-reply instruction", async () => {
|
|
750
|
+
const { adapter, sends } = makeCapturingAdapter("platform");
|
|
751
|
+
const broadcaster = new NotificationBroadcaster([adapter]);
|
|
752
|
+
|
|
753
|
+
await broadcaster.broadcastDecision(
|
|
754
|
+
questionSignal({
|
|
755
|
+
requestKind: "pending_question",
|
|
756
|
+
requestId: "req-lenient-1",
|
|
757
|
+
requestCode: "abc999",
|
|
758
|
+
// A null requester id fails the strict schema (string | undefined).
|
|
759
|
+
requesterExternalUserId: null,
|
|
760
|
+
questionText: "What time works?",
|
|
761
|
+
}),
|
|
762
|
+
decisionForPlatform(),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
expect(sends.length).toBe(1);
|
|
766
|
+
const approval = sends[0]?.payload.approvalContext;
|
|
767
|
+
expect(approval?.requestId).toBe("req-lenient-1");
|
|
768
|
+
expect(approval?.actions).toEqual([]);
|
|
769
|
+
expect(approval?.plainTextFallback).toBe(
|
|
770
|
+
'Reference code: ABC999. Reply "ABC999 <your answer>".',
|
|
771
|
+
);
|
|
741
772
|
});
|
|
742
773
|
|
|
743
774
|
test("tool_approval payloads keep the approve/reject action pair", async () => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
collapseHorizontalWhitespace,
|
|
4
5
|
describeMedia,
|
|
5
6
|
mediaEmbeds,
|
|
7
|
+
sanitizeMultilineMessagePreview,
|
|
6
8
|
stripMarkdownForPreview,
|
|
7
9
|
} from "../notification-utils.js";
|
|
8
10
|
|
|
@@ -230,6 +232,35 @@ describe("mediaEmbeds", () => {
|
|
|
230
232
|
});
|
|
231
233
|
});
|
|
232
234
|
|
|
235
|
+
describe("collapseHorizontalWhitespace", () => {
|
|
236
|
+
test("keeps paragraph breaks and trims line padding", () => {
|
|
237
|
+
expect(collapseHorizontalWhitespace(" Here:\n\n item \n other ")).toBe(
|
|
238
|
+
"Here:\n\nitem\nother",
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("collapses three or more newlines to one blank line", () => {
|
|
243
|
+
expect(collapseHorizontalWhitespace("a\n\n\n\nb")).toBe("a\n\nb");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("sanitizeMultilineMessagePreview", () => {
|
|
248
|
+
test("keeps newlines and strips other control characters", () => {
|
|
249
|
+
expect(sanitizeMultilineMessagePreview("Hello\n\tWorld")).toBe(
|
|
250
|
+
"Hello\nWorld",
|
|
251
|
+
);
|
|
252
|
+
expect(sanitizeMultilineMessagePreview("Test\r\nMessage")).toBe(
|
|
253
|
+
"Test\nMessage",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("clamps to the shared preview budget", () => {
|
|
258
|
+
const result = sanitizeMultilineMessagePreview("A".repeat(250));
|
|
259
|
+
expect(result).toHaveLength(200);
|
|
260
|
+
expect(result.endsWith("…")).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
233
264
|
describe("describeMedia", () => {
|
|
234
265
|
test("names a single label", () => {
|
|
235
266
|
expect(describeMedia(["cut.mp4"])).toBe("Sent cut.mp4");
|