@vellumai/assistant 0.11.7 → 0.11.8-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 +10 -10
- package/Dockerfile +1 -0
- package/docs/architecture/security.md +1 -1
- package/docs/error-handling.md +1 -2
- package/docs/guardian-request-flow.md +51 -5
- package/docs/trusted-contact-access.md +13 -12
- package/knip.json +1 -0
- package/node_modules/@vellumai/avatar-catalog/package.json +19 -0
- package/{src/avatar/character-components.ts → node_modules/@vellumai/avatar-catalog/src/catalog.ts} +12 -44
- package/node_modules/@vellumai/avatar-catalog/src/colors.ts +19 -0
- package/node_modules/@vellumai/avatar-catalog/src/index.ts +25 -0
- package/node_modules/@vellumai/avatar-catalog/src/types.ts +47 -0
- package/node_modules/@vellumai/avatar-catalog/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -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/no-response.ts +84 -0
- package/node_modules/@vellumai/credential-storage/src/static-credentials.ts +43 -12
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/test-path-guard.test.ts +89 -0
- package/node_modules/@vellumai/environments/src/test-path-guard.ts +113 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +7 -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/no-response.ts +84 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +8 -18
- package/node_modules/@vellumai/gateway-client/src/__tests__/inbound-event-kind.test.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +46 -0
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +62 -21
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +14 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-event-kind.ts +53 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +11 -5
- package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +1 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +7 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/no-response.ts +84 -0
- package/openapi.yaml +481 -9
- package/package.json +3 -1
- package/scripts/sync-llm-catalog.ts +3 -0
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +16 -0
- package/src/__tests__/agent-loop-compacted-history-transform.test.ts +304 -0
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +3 -0
- package/src/__tests__/agent-wake-override-profile.test.ts +3 -0
- package/src/__tests__/anthropic-provider.test.ts +56 -0
- package/src/__tests__/approval-cascade.test.ts +0 -13
- package/src/__tests__/attachments-store.test.ts +117 -6
- package/src/__tests__/attachments.test.ts +122 -0
- package/src/__tests__/bundled-asset.test.ts +22 -1
- package/src/__tests__/channel-approval.test.ts +14 -7
- package/src/__tests__/channel-approvals.test.ts +3 -3
- package/src/__tests__/channel-delivery-store.test.ts +58 -0
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +1 -0
- package/src/__tests__/channel-invite-transport.test.ts +1 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +3 -3
- package/src/__tests__/compaction-events.test.ts +0 -12
- package/src/__tests__/compactor-call-site-logging.test.ts +7 -1
- package/src/__tests__/compactor-retained-image-validation.test.ts +15 -0
- package/src/__tests__/config-schema.test.ts +35 -0
- package/src/__tests__/contacts-write.test.ts +141 -3
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -13
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +0 -12
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -13
- package/src/__tests__/conversation-agent-loop.test.ts +95 -13
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -13
- package/src/__tests__/conversation-load-history-repair.test.ts +418 -0
- package/src/__tests__/conversation-media-retry.test.ts +33 -2
- package/src/__tests__/conversation-pairing.test.ts +54 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -12
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -13
- package/src/__tests__/conversation-queue.test.ts +4 -13
- package/src/__tests__/conversation-retry-route.test.ts +5 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
- package/src/__tests__/conversation-sight-frames.test.ts +615 -0
- package/src/__tests__/conversation-slash-queue.test.ts +0 -13
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -13
- package/src/__tests__/conversation-speed-override.test.ts +0 -12
- package/src/__tests__/conversation-store-ephemeral.test.ts +0 -12
- package/src/__tests__/conversation-summarize-route.test.ts +5 -0
- package/src/__tests__/conversation-summarize-up-to.test.ts +102 -0
- package/src/__tests__/conversation-wait-for-idle.test.ts +0 -12
- package/src/__tests__/conversation-workspace-cache-state.test.ts +0 -5
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -17
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -17
- package/src/__tests__/credential-health-service.test.ts +45 -0
- package/src/__tests__/credential-record-retire.test.ts +256 -0
- package/src/__tests__/credential-record-write-through.test.ts +8 -4
- package/src/__tests__/db-conversation-fork-lineage-migration.test.ts +0 -4
- package/src/__tests__/db-conversation-inference-profile-migration.test.ts +0 -4
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +0 -8
- package/src/__tests__/delete-propagation.test.ts +121 -3
- package/src/__tests__/evict-conversations-for-reload.test.ts +46 -6
- package/src/__tests__/file-ops-service.test.ts +8 -3
- package/src/__tests__/gemini-provider.test.ts +64 -0
- package/src/__tests__/guardian-action-service-scope.test.ts +118 -0
- package/src/__tests__/guardian-card-withdrawal.test.ts +299 -5
- package/src/__tests__/guardian-expiry-notifier.test.ts +117 -13
- package/src/__tests__/guardian-gateway-sim.test.ts +119 -0
- package/src/__tests__/guardian-gateway-sim.ts +56 -20
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +6 -0
- package/src/__tests__/guardian-request-status-sync.test.ts +162 -0
- package/src/__tests__/guardian-wait-controller.test.ts +6 -0
- package/src/__tests__/headless-browser-mode.test.ts +27 -0
- package/src/__tests__/helpers/mock-conversation.ts +2 -0
- package/src/__tests__/list-messages-system-card.test.ts +70 -0
- package/src/__tests__/llm-catalog-parity.test.ts +2 -0
- package/src/__tests__/managed-profile-guard.test.ts +2 -2
- package/src/__tests__/media-resolve-image-validation.test.ts +106 -0
- package/src/__tests__/media-stream-server-integration.test.ts +6 -0
- package/src/__tests__/non-member-access-request.test.ts +12 -0
- package/src/__tests__/notification-discord-adapter.test.ts +76 -4
- package/src/__tests__/oauth-commands-routes.test.ts +21 -0
- package/src/__tests__/openai-provider.test.ts +63 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +114 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +63 -1
- package/src/__tests__/persist-unsendable-image.test.ts +19 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +2 -2
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/process-message-background-slack.test.ts +1 -1
- package/src/__tests__/process-message-display-content.test.ts +1 -1
- package/src/__tests__/processing-flag-persist-failure.test.ts +0 -5
- package/src/__tests__/prompt-cache-cross-turn-stability.test.ts +0 -17
- package/src/__tests__/qdrant-manager.test.ts +6 -0
- package/src/__tests__/reaction-persistence.test.ts +58 -62
- package/src/__tests__/retry-thinking-adaptive-only.test.ts +67 -0
- package/src/__tests__/server-history-render.test.ts +22 -0
- package/src/__tests__/sight-frame-retention.test.ts +362 -0
- package/src/__tests__/slack-reaction-approvals.test.ts +44 -24
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +29 -1
- package/src/__tests__/subagent-fork-notifications.test.ts +2 -0
- package/src/__tests__/subagent-manager-notify.test.ts +2 -0
- package/src/__tests__/subagent-notify-parent.test.ts +2 -0
- package/src/__tests__/subagent-notify-stale-rebuild.test.ts +85 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +4 -4
- package/src/__tests__/terminal-tools.test.ts +9 -9
- package/src/__tests__/tool-result-spool.test.ts +5 -8
- package/src/__tests__/usage-attribution.test.ts +26 -0
- package/src/acp/__tests__/acp-auth-marker-store.test.ts +191 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +262 -0
- package/src/acp/__tests__/acp-connect-card-state.test.ts +17 -0
- package/src/acp/__tests__/helpers/acp-history-db.ts +10 -3
- package/src/acp/acp-auth-marker-store.ts +196 -0
- package/src/acp/acp-claude-oauth.ts +154 -4
- package/src/acp/acp-connect-card-state.ts +49 -8
- package/src/acp/agent-process.ts +1 -0
- package/src/acp/auto-install.ts +1 -0
- package/src/acp/client-handler.ts +1 -0
- package/src/acp/prepare-agent-env.ts +79 -1
- package/src/acp/resolve-agent.ts +12 -0
- package/src/acp/session-manager.test.ts +183 -0
- package/src/acp/session-manager.ts +110 -6
- package/src/acp/types.ts +17 -0
- package/src/agent/attachments.ts +7 -2
- package/src/agent/loop.ts +59 -1
- package/src/api/events/contact-form-closed.ts +32 -0
- package/src/api/events/contact-record-request.ts +63 -0
- package/src/api/events/contact-request.ts +10 -3
- package/src/api/index.ts +20 -0
- package/src/api/responses/conversation-message.ts +20 -0
- package/src/api/responses/home.ts +101 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/approvals/guardian-card-withdrawal.ts +127 -30
- package/src/approvals/guardian-decision-primitive.ts +9 -0
- package/src/approvals/guardian-expiry-notifier.ts +22 -16
- package/src/approvals/guardian-request-status-sync.ts +98 -0
- package/src/avatar/svg-compositor.ts +1 -1
- package/src/avatar/traits-png-sync.ts +1 -1
- package/src/bundler/app-compiler.ts +1 -0
- package/src/bundler/compiler-tools.ts +1 -0
- package/src/bundler/package-resolver.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +183 -3
- package/src/calls/guardian-action-sweep.ts +6 -1
- package/src/calls/voice-session-bridge.ts +46 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +13 -18
- package/src/channels/gateway-guardian-requests.ts +37 -18
- package/src/cli/__tests__/contacts-record-prompt-cli.test.ts +329 -0
- package/src/cli/commands/__tests__/browser.test.ts +65 -1
- package/src/cli/commands/browser.ts +30 -10
- package/src/cli/commands/contacts.help.ts +173 -11
- package/src/cli/commands/contacts.ts +343 -4
- package/src/cli/commands/db/refresh.ts +1 -0
- package/src/cli/commands/oauth/index.help.ts +6 -3
- package/src/cli/commands/oauth/request.test.ts +37 -0
- package/src/cli/commands/oauth/request.ts +7 -6
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-github.ts +20 -19
- package/src/cli/lib/install-plugin-dependencies.ts +3 -5
- package/src/cli/lib/merge-plugin-tree.ts +1 -0
- package/src/cli/lib/publish-plugin.ts +4 -1
- package/src/config/__tests__/balanced-model-experiment.test.ts +63 -31
- package/src/config/__tests__/default-profile-catalog.test.ts +2 -2
- package/src/config/balanced-model-experiment.ts +15 -9
- package/src/config/bundled-skills/contacts/SKILL.md +61 -18
- package/src/config/default-profile-catalog.ts +12 -11
- package/src/config/feature-flag-registry.json +22 -13
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/live-voice.test.ts +13 -0
- package/src/config/schemas/live-voice.ts +7 -0
- package/src/config/schemas/sight.ts +45 -0
- package/src/config/skills.ts +2 -1
- package/src/contacts/__tests__/mirror-reconciler.test.ts +352 -0
- package/src/contacts/contact-store.ts +47 -4
- package/src/contacts/contacts-write.ts +5 -2
- package/src/contacts/mirror-reconciler.ts +210 -0
- package/src/contacts/mirror-tombstones.ts +42 -0
- package/src/contacts/notify-contacts-changed.ts +11 -2
- package/src/context/compactor.ts +47 -19
- package/src/context/tool-result-spool.ts +21 -14
- package/src/conversations/message-consolidation.ts +2 -2
- package/src/credential-health/credential-health-service.ts +37 -6
- package/src/daemon/conversation-agent-loop-handlers.ts +19 -2
- package/src/daemon/conversation-agent-loop.ts +100 -1
- package/src/daemon/conversation-media-retry.ts +11 -6
- package/src/daemon/conversation-messaging.ts +293 -30
- package/src/daemon/conversation-runtime-assembly.ts +67 -0
- package/src/daemon/conversation-sight-frames.ts +187 -0
- package/src/daemon/conversation-store.ts +18 -28
- package/src/daemon/conversation-surfaces.ts +12 -2
- package/src/daemon/conversation-tool-setup.ts +39 -10
- package/src/daemon/conversation.ts +159 -16
- package/src/daemon/daemon-control.ts +6 -17
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/conversations.ts +10 -11
- package/src/daemon/install-assistant-command.ts +1 -0
- package/src/daemon/lifecycle.ts +10 -13
- package/src/daemon/message-types/sync.ts +4 -0
- package/src/daemon/process-message.ts +2 -17
- package/src/daemon/reaction-history-render.test.ts +162 -0
- package/src/daemon/reaction-history-render.ts +115 -0
- package/src/daemon/reaction-record.test.ts +162 -0
- package/src/daemon/reaction-record.ts +83 -0
- package/src/daemon/shutdown-handlers.ts +0 -2
- package/src/daemon/summarize-boundary.ts +12 -0
- package/src/daemon/video-thumbnail.ts +1 -1
- package/src/home/feed-types.ts +4 -0
- package/src/home/feed-writer.ts +25 -0
- package/src/live-voice/__tests__/live-voice-attach-frame.test.ts +679 -0
- package/src/live-voice/__tests__/live-voice-attach-image.test.ts +8 -5
- package/src/live-voice/__tests__/live-voice-photo.test.ts +637 -1
- package/src/live-voice/__tests__/live-voice-sight-frame-inline.test.ts +787 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +347 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +109 -0
- package/src/live-voice/live-voice-photo.ts +420 -65
- package/src/live-voice/live-voice-session.ts +311 -27
- package/src/live-voice/protocol.ts +147 -11
- package/src/messaging/provider-message-metadata.ts +7 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +113 -1
- package/src/messaging/providers/channel-transport.ts +37 -0
- package/src/messaging/providers/discord/api.ts +4 -4
- package/src/messaging/providers/discord/send.test.ts +177 -2
- package/src/messaging/providers/discord/send.ts +197 -10
- package/src/messaging/providers/discord/transport.ts +10 -0
- package/src/messaging/providers/index.ts +42 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +129 -3
- package/src/messaging/providers/slack/__tests__/auth.test.ts +35 -0
- package/src/messaging/providers/slack/__tests__/message-content.test.ts +152 -0
- package/src/messaging/providers/slack/adapter.ts +112 -27
- package/src/messaging/providers/slack/api.test.ts +10 -0
- package/src/messaging/providers/slack/auth.ts +32 -6
- package/src/messaging/providers/slack/client.ts +0 -15
- package/src/messaging/providers/slack/message-content.ts +137 -0
- package/src/messaging/providers/slack/send.test.ts +43 -2
- package/src/messaging/providers/slack/send.ts +18 -5
- package/src/messaging/providers/slack/transport.ts +10 -0
- package/src/messaging/providers/slack/types.ts +21 -2
- package/src/messaging/providers/telegram-bot/adapter.ts +5 -1
- package/src/messaging/providers/telegram-bot/send.test.ts +49 -2
- package/src/messaging/providers/telegram-bot/send.ts +41 -1
- package/src/messaging/providers/telegram-bot/transport.ts +10 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +13 -5
- package/src/messaging/reaction-envelopes.ts +79 -0
- package/src/monitoring/control.ts +2 -2
- package/src/monitoring/page-cache.ts +5 -1
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/guardian-feed-projection.test.ts +360 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +8 -0
- package/src/notifications/adapters/discord.ts +62 -9
- package/src/notifications/conversation-pairing.ts +31 -4
- package/src/notifications/emit-signal.ts +8 -5
- package/src/notifications/guardian-delivery-recorder.ts +47 -6
- package/src/notifications/guardian-feed-projection.ts +492 -0
- package/src/notifications/home-feed-side-effect.ts +59 -11
- package/src/oauth/byo-connection.test.ts +22 -0
- package/src/oauth/byo-connection.ts +11 -5
- package/src/oauth/connection.ts +3 -2
- package/src/oauth/platform-connection.test.ts +35 -0
- package/src/oauth/platform-connection.ts +18 -13
- package/src/oauth/scope-utils.ts +48 -0
- package/src/oauth/seed-providers.ts +12 -0
- package/src/outbound-proxy/certs.ts +5 -4
- package/src/permissions/question-guardian-request.test.ts +7 -1
- package/src/persistence/attachments-store.ts +134 -31
- package/src/persistence/conversation-crud.ts +137 -4
- package/src/persistence/conversation-types.ts +109 -0
- package/src/persistence/db-connection.ts +3 -21
- package/src/persistence/delivery-crud.ts +42 -12
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +15 -2
- package/src/persistence/embeddings/embedding-local.ts +7 -112
- package/src/persistence/embeddings/qdrant-manager.ts +4 -1
- package/src/persistence/llm-request-log-store.ts +1 -1
- package/src/persistence/migrations/370-acp-session-history-auth-error-code.ts +36 -0
- package/src/persistence/migrations/371-acp-session-history-auth-error-credential.ts +40 -0
- package/src/persistence/migrations/372-create-acp-refused-credentials.ts +34 -0
- package/src/persistence/migrations/373-acp-auth-marker-index.ts +27 -0
- package/src/persistence/migrations/__tests__/370-acp-session-history-auth-error-code.test.ts +65 -0
- package/src/persistence/migrations/__tests__/371-acp-session-history-auth-error-credential.test.ts +68 -0
- package/src/persistence/migrations/__tests__/372-create-acp-refused-credentials.test.ts +64 -0
- package/src/persistence/migrations/__tests__/373-acp-auth-marker-index.test.ts +82 -0
- package/src/persistence/schema/acp.ts +25 -0
- package/src/persistence/steps.ts +8 -0
- package/src/platform/platform-patch-queue.test.ts +43 -41
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/defaults/compaction/window-manager.ts +2 -0
- package/src/plugins/defaults/image-recovery/recover.ts +12 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +166 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +3 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +3 -0
- package/src/plugins/defaults/memory/jobs-worker.ts +1 -2
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +53 -9
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +4 -7
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
- package/src/plugins/defaults/memory/v1/job-handlers/index-maintenance.ts +15 -1
- package/src/plugins/defaults/memory/worker-control.ts +1 -2
- package/src/providers/content-block-schema.ts +1 -0
- package/src/providers/fireworks/client.ts +9 -1
- package/src/providers/gemini/client.ts +22 -15
- package/src/providers/media-resolve.ts +24 -10
- package/src/providers/model-catalog.ts +91 -0
- package/src/providers/openai/chat-completions-provider.ts +44 -5
- package/src/providers/retry.ts +16 -11
- package/src/providers/types.ts +56 -0
- package/src/routes/__tests__/control.test.ts +4 -3
- package/src/routes/control.ts +1 -1
- package/src/routes/route-host-client.ts +1 -1
- package/src/runtime/AGENTS.md +30 -12
- package/src/runtime/__tests__/agent-wake.test.ts +71 -6
- package/src/runtime/__tests__/guardian-form-registry.test.ts +242 -0
- package/src/runtime/access-request-helper.ts +15 -7
- package/src/runtime/agent-wake.ts +16 -4
- package/src/runtime/channel-approval-types.ts +8 -5
- package/src/runtime/channel-invite-transport.ts +2 -0
- package/src/runtime/channel-invite-transports/discord.ts +27 -0
- package/src/runtime/channel-readiness-service.ts +4 -4
- package/src/runtime/channel-reply-delivery.ts +72 -28
- package/src/runtime/guardian-action-service.ts +9 -5
- package/src/runtime/guardian-form-registry.ts +256 -0
- package/src/runtime/guardian-reply-router.ts +33 -14
- package/src/runtime/invite-instruction-generator.ts +14 -2
- package/src/runtime/migrations/vbundle-metadata-merge.ts +4 -7
- package/src/runtime/no-response.ts +13 -74
- package/src/runtime/routes/__tests__/acp-routes.test.ts +527 -0
- package/src/runtime/routes/__tests__/app-management-shared-apps.test.ts +74 -0
- package/src/runtime/routes/__tests__/contact-form-kinds.test.ts +22 -0
- package/src/runtime/routes/__tests__/contact-record-prompt-route.test.ts +303 -0
- package/src/runtime/routes/__tests__/monitoring-routes.test.ts +3 -4
- package/src/runtime/routes/__tests__/pending-interactions-route.test.ts +6 -0
- package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +3 -4
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +5 -6
- package/src/runtime/routes/acp-routes-list.test.ts +58 -0
- package/src/runtime/routes/acp-routes.ts +253 -47
- package/src/runtime/routes/app-management-routes.ts +80 -47
- package/src/runtime/routes/approval-routes.ts +9 -0
- package/src/runtime/routes/archive-utils.ts +6 -1
- package/src/runtime/routes/avatar-routes.ts +1 -1
- package/src/runtime/routes/channel-route-shared.ts +18 -20
- package/src/runtime/routes/contact-prompt-routes.ts +271 -105
- package/src/runtime/routes/conversation-routes.ts +41 -32
- package/src/runtime/routes/credential-prompt-routes.ts +10 -1
- package/src/runtime/routes/debug-bash-routes.ts +1 -0
- package/src/runtime/routes/guardian-expiry-sweep.ts +89 -31
- package/src/runtime/routes/guardian-form-routes.ts +115 -0
- package/src/runtime/routes/inbound-message-handler.ts +82 -29
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +6 -0
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +6 -0
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +6 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +50 -17
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +97 -94
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/integrations/slack/channel.ts +39 -18
- package/src/runtime/routes/migration-routes.ts +1 -3
- package/src/runtime/routes/monitoring-routes.ts +0 -3
- package/src/runtime/routes/oauth-commands-routes.ts +18 -5
- package/src/runtime/routes/route-host-worker-routes.ts +1 -1
- package/src/runtime/routes/schedule-worker-routes.ts +1 -1
- package/src/runtime/tool-grant-request-helper.ts +4 -0
- package/src/schedule/run-script.ts +1 -0
- package/src/schedule/scheduler.ts +1 -1
- package/src/schedule/worker-control.ts +2 -2
- package/src/security/oauth2.ts +2 -11
- package/src/security/secure-keys.ts +55 -8
- package/src/skills/catalog-install.ts +7 -4
- package/src/skills/clawhub.ts +1 -0
- package/src/skills/inline-command-runner.ts +1 -0
- package/src/stt/__tests__/room-noise-floor.test.ts +108 -0
- package/src/stt/room-noise-floor.ts +140 -0
- package/src/subagent/notify.ts +49 -3
- package/src/tools/__tests__/tool-input-schemas.test.ts +2 -0
- package/src/tools/acp/spawn.ts +2 -2
- package/src/tools/browser/__tests__/browser-status.test.ts +9 -0
- package/src/tools/browser/browser-execution.ts +10 -43
- package/src/tools/browser/browser-manager.ts +1 -0
- package/src/tools/browser/browser-status-constants.ts +6 -5
- package/src/tools/browser/runtime-check.ts +2 -0
- package/src/tools/channel/react-to-message.test.ts +193 -0
- package/src/tools/channel/react-to-message.ts +159 -0
- package/src/tools/credentials/metadata-store.ts +115 -19
- package/src/tools/credentials/store.ts +0 -2
- package/src/tools/filesystem/read.ts +2 -2
- package/src/tools/host-filesystem/read.ts +2 -2
- package/src/tools/host-terminal/host-shell.test.ts +24 -0
- package/src/tools/host-terminal/host-shell.ts +11 -38
- package/src/tools/shared/filesystem/file-ops-service.ts +10 -7
- package/src/tools/shared/shell-output.test.ts +104 -0
- package/src/tools/shared/shell-output.ts +112 -39
- package/src/tools/skills/sandbox-runner.ts +1 -0
- package/src/tools/system/request-permission.test.ts +34 -0
- package/src/tools/system/request-permission.ts +29 -5
- package/src/tools/terminal/shell.test.ts +63 -0
- package/src/tools/terminal/shell.ts +12 -44
- package/src/tools/tool-input-schemas.ts +2 -0
- package/src/tools/tool-manifest.ts +2 -0
- package/src/usage/attribution.ts +12 -4
- package/src/util/__tests__/worker-ownership.test.ts +77 -0
- package/src/util/__tests__/worker-process-command.test.ts +246 -1
- package/src/util/bun-runtime.ts +44 -13
- package/src/util/bundled-asset.ts +9 -1
- package/src/util/disk-usage.ts +1 -0
- package/src/util/file-use.ts +4 -1
- package/src/util/guardian-form-timeouts.ts +50 -0
- package/src/util/image-conversion.ts +1 -0
- package/src/util/oauth-request-body.test.ts +59 -0
- package/src/util/oauth-request-body.ts +56 -5
- package/src/util/platform.ts +69 -73
- package/src/util/process-table.ts +16 -5
- package/src/util/read-stdin.ts +5 -0
- package/src/util/spawn.ts +1 -0
- package/src/util/sqlite3-runtime.ts +25 -5
- package/src/util/worker-ownership.ts +193 -0
- package/src/util/worker-process.ts +375 -37
- package/src/workspace/git-service.ts +4 -12
- package/src/workspace/migrations/010-app-dir-rename.ts +1 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Camera frames whose store write fails recoverably.
|
|
3
|
+
*
|
|
4
|
+
* Such a frame keeps its bytes inline in `messages.content` instead of
|
|
5
|
+
* referencing an attachment row, which makes it the heaviest thing the
|
|
6
|
+
* conversation resends on every later request. It is therefore the frame
|
|
7
|
+
* retention most needs to be able to stub, and it is attributable only by the
|
|
8
|
+
* id its block carries rather than by a link that was never written.
|
|
9
|
+
*
|
|
10
|
+
* Kept apart from `live-voice-photo.test.ts` because forcing the failure means
|
|
11
|
+
* replacing an attachment-store export for the whole file, and the sibling
|
|
12
|
+
* suite's clone case depends on that same export behaving for real.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
import { eq } from "drizzle-orm";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createMockProvider,
|
|
21
|
+
textResponse,
|
|
22
|
+
} from "../../__tests__/helpers/mock-provider.js";
|
|
23
|
+
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
24
|
+
|
|
25
|
+
setConfig("memory", { enabled: false });
|
|
26
|
+
|
|
27
|
+
// Scoping an attachment into the conversation is the step that fails
|
|
28
|
+
// recoverably, and a null return is how the persist path learns it did. Only
|
|
29
|
+
// the ids a test names are refused; everything else runs for real.
|
|
30
|
+
import * as attachmentsStoreNamespace from "../../persistence/attachments-store.js";
|
|
31
|
+
|
|
32
|
+
// Snapshotted BEFORE the mock is installed. The namespace binding is live, so
|
|
33
|
+
// it points at the replacement once `mock.module` runs, and delegating through
|
|
34
|
+
// it would call this wrapper again forever.
|
|
35
|
+
const realStore = { ...attachmentsStoreNamespace };
|
|
36
|
+
|
|
37
|
+
const unstorableAttachmentIds = new Set<string>();
|
|
38
|
+
|
|
39
|
+
// Armed for exactly one link, which is how the persist path's repair branch is
|
|
40
|
+
// reached without breaking the linking a test's own setup depends on.
|
|
41
|
+
let failNextLink = false;
|
|
42
|
+
|
|
43
|
+
// Armed for exactly one attachment resolve, which fails the persist before it
|
|
44
|
+
// reaches the message insert.
|
|
45
|
+
let failNextResolve = false;
|
|
46
|
+
|
|
47
|
+
// Ids conversation scoping handed back, so a test can name the clone it made.
|
|
48
|
+
// A clone is deleted by the failure path, so it cannot be read back after.
|
|
49
|
+
const scopedAttachmentIds: string[] = [];
|
|
50
|
+
|
|
51
|
+
// Armed for exactly one attachment byte read, which is how a throw is reached
|
|
52
|
+
// while the reference block is being built: after the row was cloned and
|
|
53
|
+
// before the prepared list records it.
|
|
54
|
+
let failNextContentRead = false;
|
|
55
|
+
|
|
56
|
+
mock.module("../../persistence/attachments-store.js", () => ({
|
|
57
|
+
...realStore,
|
|
58
|
+
resolveAttachmentsForPersist: (ids: string[]) => {
|
|
59
|
+
if (failNextResolve) {
|
|
60
|
+
failNextResolve = false;
|
|
61
|
+
throw new Error("simulated attachment read failure");
|
|
62
|
+
}
|
|
63
|
+
return realStore.resolveAttachmentsForPersist(ids);
|
|
64
|
+
},
|
|
65
|
+
linkAttachmentToMessage: (
|
|
66
|
+
messageId: string,
|
|
67
|
+
attachmentId: string,
|
|
68
|
+
position: number,
|
|
69
|
+
) => {
|
|
70
|
+
if (failNextLink) {
|
|
71
|
+
failNextLink = false;
|
|
72
|
+
throw new Error("simulated message_attachments write failure");
|
|
73
|
+
}
|
|
74
|
+
return realStore.linkAttachmentToMessage(messageId, attachmentId, position);
|
|
75
|
+
},
|
|
76
|
+
getAttachmentContent: (attachmentId: string) => {
|
|
77
|
+
if (failNextContentRead) {
|
|
78
|
+
failNextContentRead = false;
|
|
79
|
+
throw new Error("simulated attachment byte read failure");
|
|
80
|
+
}
|
|
81
|
+
return realStore.getAttachmentContent(attachmentId);
|
|
82
|
+
},
|
|
83
|
+
scopeAttachmentToMessageConversation: (
|
|
84
|
+
conversationId: string,
|
|
85
|
+
conversationCreatedAt: number,
|
|
86
|
+
attachmentId: string,
|
|
87
|
+
) => {
|
|
88
|
+
if (unstorableAttachmentIds.has(attachmentId)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const scoped = realStore.scopeAttachmentToMessageConversation(
|
|
92
|
+
conversationId,
|
|
93
|
+
conversationCreatedAt,
|
|
94
|
+
attachmentId,
|
|
95
|
+
);
|
|
96
|
+
if (scoped) {
|
|
97
|
+
scopedAttachmentIds.push(scoped.id);
|
|
98
|
+
}
|
|
99
|
+
return scoped;
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
// The content rewrite the repair branch performs, which is the statement that
|
|
104
|
+
// can throw AFTER the message row is already inserted.
|
|
105
|
+
import * as conversationCrudNamespace from "../../persistence/conversation-crud.js";
|
|
106
|
+
|
|
107
|
+
const realCrud = { ...conversationCrudNamespace };
|
|
108
|
+
|
|
109
|
+
let failNextContentUpdate = false;
|
|
110
|
+
|
|
111
|
+
// Armed for exactly one by-id read, which is how the "cannot tell whether the
|
|
112
|
+
// row landed" branch is reached.
|
|
113
|
+
let failNextMessageLookup = false;
|
|
114
|
+
|
|
115
|
+
// Armed for exactly one insert, which fails the persist after materialization
|
|
116
|
+
// has already cloned but before any row exists.
|
|
117
|
+
let failNextAddMessage = false;
|
|
118
|
+
|
|
119
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
120
|
+
...realCrud,
|
|
121
|
+
addMessage: (
|
|
122
|
+
conversationId: string,
|
|
123
|
+
role: string,
|
|
124
|
+
content: string,
|
|
125
|
+
options?: unknown,
|
|
126
|
+
) => {
|
|
127
|
+
if (failNextAddMessage) {
|
|
128
|
+
failNextAddMessage = false;
|
|
129
|
+
throw new Error("simulated message insert failure");
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
realCrud.addMessage as (
|
|
133
|
+
...args: unknown[]
|
|
134
|
+
) => ReturnType<typeof realCrud.addMessage>
|
|
135
|
+
)(conversationId, role, content, options);
|
|
136
|
+
},
|
|
137
|
+
updateMessageContent: (messageId: string, content: string) => {
|
|
138
|
+
if (failNextContentUpdate) {
|
|
139
|
+
failNextContentUpdate = false;
|
|
140
|
+
throw new Error("simulated content rewrite failure");
|
|
141
|
+
}
|
|
142
|
+
return realCrud.updateMessageContent(messageId, content);
|
|
143
|
+
},
|
|
144
|
+
getMessageById: (messageId: string, conversationId?: string) => {
|
|
145
|
+
if (failNextMessageLookup) {
|
|
146
|
+
failNextMessageLookup = false;
|
|
147
|
+
throw new Error("simulated message lookup failure");
|
|
148
|
+
}
|
|
149
|
+
return realCrud.getMessageById(messageId, conversationId);
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
154
|
+
import { tmpdir } from "node:os";
|
|
155
|
+
import { dirname, join } from "node:path";
|
|
156
|
+
|
|
157
|
+
// The messages-changed invalidation, which is how a client learns a row it was
|
|
158
|
+
// never told about is there. Recorded so a recovery can be shown to announce
|
|
159
|
+
// itself rather than only returning ok.
|
|
160
|
+
import * as resourceSyncNamespace from "../../runtime/sync/resource-sync-events.js";
|
|
161
|
+
|
|
162
|
+
const realResourceSync = { ...resourceSyncNamespace };
|
|
163
|
+
|
|
164
|
+
const messagesChangedFor: string[] = [];
|
|
165
|
+
|
|
166
|
+
mock.module("../../runtime/sync/resource-sync-events.js", () => ({
|
|
167
|
+
...realResourceSync,
|
|
168
|
+
publishConversationMessagesChanged: (conversationId: string) => {
|
|
169
|
+
messagesChangedFor.push(conversationId);
|
|
170
|
+
return realResourceSync.publishConversationMessagesChanged(conversationId);
|
|
171
|
+
},
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// Lexical indexing, the host-infrastructure half of the indexing gate that runs
|
|
175
|
+
// whatever the memory config says. Recorded so a row can be shown to stay out
|
|
176
|
+
// of the extraction pipeline entirely.
|
|
177
|
+
import * as messageLexicalNamespace from "../../persistence/job-handlers/message-lexical.js";
|
|
178
|
+
|
|
179
|
+
const realMessageLexical = { ...messageLexicalNamespace };
|
|
180
|
+
|
|
181
|
+
const lexicallyIndexed: string[] = [];
|
|
182
|
+
|
|
183
|
+
mock.module("../../persistence/job-handlers/message-lexical.js", () => ({
|
|
184
|
+
...realMessageLexical,
|
|
185
|
+
enqueueLexicalIndexForMessage: (messageId: string) => {
|
|
186
|
+
lexicallyIndexed.push(messageId);
|
|
187
|
+
return realMessageLexical.enqueueLexicalIndexForMessage(messageId);
|
|
188
|
+
},
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
import { Conversation } from "../../daemon/conversation.js";
|
|
192
|
+
import {
|
|
193
|
+
deleteConversation,
|
|
194
|
+
setConversation,
|
|
195
|
+
} from "../../daemon/conversation-registry.js";
|
|
196
|
+
import { applySightFrameRetention } from "../../daemon/conversation-runtime-assembly.js";
|
|
197
|
+
import {
|
|
198
|
+
addMessage,
|
|
199
|
+
createConversation,
|
|
200
|
+
getMessages,
|
|
201
|
+
type MessageRow,
|
|
202
|
+
selectSightFrameCaptureTimes,
|
|
203
|
+
} from "../../persistence/conversation-crud.js";
|
|
204
|
+
import { getConversationAttachmentsDirPath } from "../../persistence/conversation-directories.js";
|
|
205
|
+
import { sightFrameAttachmentIdsFromMetadata } from "../../persistence/conversation-types.js";
|
|
206
|
+
import { getDb } from "../../persistence/db-connection.js";
|
|
207
|
+
import { initializeDb } from "../../persistence/db-init.js";
|
|
208
|
+
import { attachments } from "../../persistence/schema.js";
|
|
209
|
+
import { mediaBlockAttachmentId, type Message } from "../../providers/types.js";
|
|
210
|
+
import {
|
|
211
|
+
persistLiveVoicePhoto,
|
|
212
|
+
persistLiveVoiceSightFrame,
|
|
213
|
+
} from "../live-voice-photo.js";
|
|
214
|
+
|
|
215
|
+
await initializeDb();
|
|
216
|
+
|
|
217
|
+
const IMAGE_BASE64 =
|
|
218
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
|
|
219
|
+
|
|
220
|
+
function liveConversation(title: string) {
|
|
221
|
+
const conversation = createConversation(title);
|
|
222
|
+
const { provider } = createMockProvider([textResponse("")]);
|
|
223
|
+
const activeConversation = new Conversation(
|
|
224
|
+
conversation.id,
|
|
225
|
+
provider,
|
|
226
|
+
"system prompt",
|
|
227
|
+
() => {},
|
|
228
|
+
"/tmp",
|
|
229
|
+
{ maxTokens: 4096 },
|
|
230
|
+
);
|
|
231
|
+
activeConversation.setTrustContext({
|
|
232
|
+
trustClass: "guardian",
|
|
233
|
+
sourceChannel: "vellum",
|
|
234
|
+
});
|
|
235
|
+
setConversation(conversation.id, activeConversation);
|
|
236
|
+
return {
|
|
237
|
+
id: conversation.id,
|
|
238
|
+
createdAt: conversation.createdAt,
|
|
239
|
+
activeConversation,
|
|
240
|
+
dispose: () => {
|
|
241
|
+
deleteConversation(conversation.id);
|
|
242
|
+
activeConversation.dispose();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function uploadFrame(name: string): Promise<string> {
|
|
248
|
+
const attachment = await realStore.uploadAttachment(
|
|
249
|
+
name,
|
|
250
|
+
"image/png",
|
|
251
|
+
IMAGE_BASE64,
|
|
252
|
+
);
|
|
253
|
+
return attachment.id;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A frame whose store write fails recoverably, so it persists inline. */
|
|
257
|
+
async function uploadUnstorableFrame(name: string): Promise<string> {
|
|
258
|
+
const attachmentId = await uploadFrame(name);
|
|
259
|
+
unstorableAttachmentIds.add(attachmentId);
|
|
260
|
+
return attachmentId;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function metadataOf(row: MessageRow): Record<string, unknown> {
|
|
264
|
+
return JSON.parse(row.metadata ?? "{}") as Record<string, unknown>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** True when the row kept its bytes rather than referencing an attachment. */
|
|
268
|
+
function hasInlineImage(row: MessageRow): boolean {
|
|
269
|
+
return row.content.some(
|
|
270
|
+
(block) => block.type === "image" && block.source.type === "base64",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The attachment id the row's persisted image block is attributable by. */
|
|
275
|
+
function attributableImageId(row: MessageRow): string | undefined {
|
|
276
|
+
for (const block of row.content) {
|
|
277
|
+
if (block.type !== "image") {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const id = mediaBlockAttachmentId(block);
|
|
281
|
+
if (id !== undefined) {
|
|
282
|
+
return id;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
describe("a camera frame that falls back to inline bytes", () => {
|
|
289
|
+
test("is tagged by the id its own block carries", async () => {
|
|
290
|
+
const live = liveConversation("Live voice inline fallback tag");
|
|
291
|
+
try {
|
|
292
|
+
const inlineFrame = await uploadUnstorableFrame("inline.png");
|
|
293
|
+
|
|
294
|
+
expect((await persistLiveVoiceSightFrame(live.id, inlineFrame)).ok).toBe(
|
|
295
|
+
true,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const [row] = getMessages(live.id);
|
|
299
|
+
// The scenario really fired: bytes in the row, no reference to a row of
|
|
300
|
+
// their own, and the block naming the id the caller held.
|
|
301
|
+
expect(hasInlineImage(row)).toBe(true);
|
|
302
|
+
expect(attributableImageId(row)).toBe(inlineFrame);
|
|
303
|
+
|
|
304
|
+
expect(sightFrameAttachmentIdsFromMetadata(metadataOf(row))).toEqual([
|
|
305
|
+
inlineFrame,
|
|
306
|
+
]);
|
|
307
|
+
// Capture times come from the tag on the row, so an id with no
|
|
308
|
+
// `message_attachments` row still resolves.
|
|
309
|
+
expect([...selectSightFrameCaptureTimes(live.id).keys()]).toEqual([
|
|
310
|
+
inlineFrame,
|
|
311
|
+
]);
|
|
312
|
+
} finally {
|
|
313
|
+
live.dispose();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("is stubbed once it ages past the budget", async () => {
|
|
318
|
+
// The reason the tag matters. Untagged, the one frame carrying its full
|
|
319
|
+
// base64 is the one frame retention cannot touch, so it rides every later
|
|
320
|
+
// request for the rest of the call.
|
|
321
|
+
const live = liveConversation("Live voice inline fallback retention");
|
|
322
|
+
try {
|
|
323
|
+
const inlineFrame = await uploadUnstorableFrame("inline-aged.png");
|
|
324
|
+
expect((await persistLiveVoiceSightFrame(live.id, inlineFrame)).ok).toBe(
|
|
325
|
+
true,
|
|
326
|
+
);
|
|
327
|
+
for (const name of ["fresh-a.png", "fresh-b.png"]) {
|
|
328
|
+
const fresh = await uploadFrame(name);
|
|
329
|
+
expect((await persistLiveVoiceSightFrame(live.id, fresh)).ok).toBe(
|
|
330
|
+
true,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const assembled: Message[] = getMessages(live.id).map((row) => ({
|
|
335
|
+
role: "user" as const,
|
|
336
|
+
content: row.content,
|
|
337
|
+
}));
|
|
338
|
+
expect(assembled).toHaveLength(3);
|
|
339
|
+
|
|
340
|
+
const retained = applySightFrameRetention(assembled, live.id);
|
|
341
|
+
|
|
342
|
+
// Three frames, a budget of two: the inline one is the oldest, so its
|
|
343
|
+
// bytes give way to the timestamped stub.
|
|
344
|
+
expect(retained[0].content.some((b) => b.type === "image")).toBe(false);
|
|
345
|
+
expect(
|
|
346
|
+
retained[0].content.some(
|
|
347
|
+
(b) =>
|
|
348
|
+
b.type === "text" &&
|
|
349
|
+
b.text.startsWith("[Camera frame omitted from context:"),
|
|
350
|
+
),
|
|
351
|
+
).toBe(true);
|
|
352
|
+
expect(retained[1].content.some((b) => b.type === "image")).toBe(true);
|
|
353
|
+
expect(retained[2].content.some((b) => b.type === "image")).toBe(true);
|
|
354
|
+
} finally {
|
|
355
|
+
live.dispose();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("a camera frame whose message link fails", () => {
|
|
361
|
+
test("is repaired to inline bytes naming the id its tag names", async () => {
|
|
362
|
+
// The tag is written with the row, before the link is attempted, so it
|
|
363
|
+
// already names the CLONE that scoping stored. The repair rebuilds the
|
|
364
|
+
// block from the caller's attachment, which names the id the session held.
|
|
365
|
+
// Left disagreeing, the repaired row's frame never ages out.
|
|
366
|
+
const source = liveConversation("Live voice repair source");
|
|
367
|
+
const live = liveConversation("Live voice repair");
|
|
368
|
+
try {
|
|
369
|
+
// Linked under another conversation, so materialization clones it.
|
|
370
|
+
const arrivedAs = await uploadFrame("repair.png");
|
|
371
|
+
const elsewhere = await addMessage(source.id, "user", "look at this");
|
|
372
|
+
realStore.linkAttachmentToMessage(elsewhere.id, arrivedAs, 0);
|
|
373
|
+
|
|
374
|
+
failNextLink = true;
|
|
375
|
+
expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
expect(failNextLink).toBe(false);
|
|
379
|
+
|
|
380
|
+
const [row] = getMessages(live.id);
|
|
381
|
+
// The repair really fired: bytes in the row rather than a reference.
|
|
382
|
+
expect(hasInlineImage(row)).toBe(true);
|
|
383
|
+
|
|
384
|
+
const tagged = sightFrameAttachmentIdsFromMetadata(metadataOf(row));
|
|
385
|
+
expect(tagged).toHaveLength(1);
|
|
386
|
+
expect(tagged[0]).not.toBe(arrivedAs);
|
|
387
|
+
// The block the retention pass reads names what the tag names.
|
|
388
|
+
expect(attributableImageId(row)).toBe(tagged[0]);
|
|
389
|
+
} finally {
|
|
390
|
+
live.dispose();
|
|
391
|
+
source.dispose();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("a camera frame whose persist throws", () => {
|
|
397
|
+
test("keeps its attachment when the message row already exists", async () => {
|
|
398
|
+
// A persist can fail well after the insert: the link write fails, the
|
|
399
|
+
// repair rewrites the content, and that rewrite throws in turn. The row
|
|
400
|
+
// then references the attachment with no link protecting it, which is
|
|
401
|
+
// exactly what a link-aware delete reads as collectible, so reclaiming
|
|
402
|
+
// would strip the image out of a message the transcript still shows.
|
|
403
|
+
const live = liveConversation("Live voice throw after insert");
|
|
404
|
+
try {
|
|
405
|
+
const frame = await uploadFrame("throws-late.png");
|
|
406
|
+
// Load once so the resident history is not stale for an unrelated
|
|
407
|
+
// reason: without this the actor-scope check would reload anyway.
|
|
408
|
+
await live.activeConversation.ensureActorScopedHistory();
|
|
409
|
+
expect(live.activeConversation.getMessages()).toHaveLength(0);
|
|
410
|
+
|
|
411
|
+
messagesChangedFor.length = 0;
|
|
412
|
+
failNextLink = true;
|
|
413
|
+
failNextContentUpdate = true;
|
|
414
|
+
const result = await persistLiveVoiceSightFrame(live.id, frame);
|
|
415
|
+
expect(failNextLink).toBe(false);
|
|
416
|
+
expect(failNextContentUpdate).toBe(false);
|
|
417
|
+
|
|
418
|
+
// The row landed, so the result says so: the client keeps the frame it
|
|
419
|
+
// showed instead of retracting a message the next reload would reveal.
|
|
420
|
+
const [row] = getMessages(live.id);
|
|
421
|
+
expect(result).toEqual({ ok: true, messageId: row.id });
|
|
422
|
+
expect(realStore.getAttachmentById(frame)).not.toBeNull();
|
|
423
|
+
// And the clients rendering this conversation were told.
|
|
424
|
+
expect(messagesChangedFor).toContain(live.id);
|
|
425
|
+
|
|
426
|
+
// The persist unwound its own push, so the resident history is missing
|
|
427
|
+
// the row it just committed.
|
|
428
|
+
expect(live.activeConversation.getMessages()).toHaveLength(0);
|
|
429
|
+
// Marked stale, so the next turn rehydrates and the model sees it. The
|
|
430
|
+
// trust class is unchanged from the load above, so nothing but the stale
|
|
431
|
+
// mark can cause this reload.
|
|
432
|
+
await live.activeConversation.ensureActorScopedHistory();
|
|
433
|
+
expect(live.activeConversation.getMessages()).toHaveLength(1);
|
|
434
|
+
} finally {
|
|
435
|
+
live.dispose();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("recovers a photo the same way, since the row is what decides", async () => {
|
|
440
|
+
// Pre-existing duplicate-photo bug: told the snap failed, the user takes
|
|
441
|
+
// it again and the first one was in the transcript all along. The caller
|
|
442
|
+
// sends its retract-style error frame only on ok:false, so reporting the
|
|
443
|
+
// truth here is all it takes.
|
|
444
|
+
const live = liveConversation("Live voice photo throw after insert");
|
|
445
|
+
try {
|
|
446
|
+
const photo = await uploadFrame("photo-throws-late.png");
|
|
447
|
+
|
|
448
|
+
messagesChangedFor.length = 0;
|
|
449
|
+
failNextLink = true;
|
|
450
|
+
failNextContentUpdate = true;
|
|
451
|
+
const result = await persistLiveVoicePhoto(live.id, photo);
|
|
452
|
+
expect(failNextLink).toBe(false);
|
|
453
|
+
expect(failNextContentUpdate).toBe(false);
|
|
454
|
+
|
|
455
|
+
const [row] = getMessages(live.id);
|
|
456
|
+
expect(result).toEqual({ ok: true, messageId: row.id });
|
|
457
|
+
expect(realStore.getAttachmentById(photo)).not.toBeNull();
|
|
458
|
+
expect(messagesChangedFor).toContain(live.id);
|
|
459
|
+
} finally {
|
|
460
|
+
live.dispose();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("gives up its attachment when it threw before the insert", async () => {
|
|
465
|
+
// Nothing was written, so the upload is stranded exactly as a superseded
|
|
466
|
+
// or timed-out keep's is.
|
|
467
|
+
const live = liveConversation("Live voice throw before insert");
|
|
468
|
+
try {
|
|
469
|
+
const frame = await uploadFrame("throws-early.png");
|
|
470
|
+
|
|
471
|
+
failNextResolve = true;
|
|
472
|
+
expect(await persistLiveVoiceSightFrame(live.id, frame)).toEqual({
|
|
473
|
+
ok: false,
|
|
474
|
+
});
|
|
475
|
+
expect(failNextResolve).toBe(false);
|
|
476
|
+
|
|
477
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
478
|
+
expect(realStore.getAttachmentById(frame)).toBeNull();
|
|
479
|
+
} finally {
|
|
480
|
+
live.dispose();
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("keeps its attachment when it cannot tell whether the row landed", async () => {
|
|
485
|
+
// Nothing was inserted here, so the frame would have been reclaimed. The
|
|
486
|
+
// read that would prove it fails, and an unanswerable question resolves
|
|
487
|
+
// toward leaking a row rather than risking one a message references.
|
|
488
|
+
const live = liveConversation("Live voice throw with unreadable row");
|
|
489
|
+
try {
|
|
490
|
+
const frame = await uploadFrame("throws-unreadable.png");
|
|
491
|
+
|
|
492
|
+
failNextResolve = true;
|
|
493
|
+
failNextMessageLookup = true;
|
|
494
|
+
// No success claim either: doubt blocks the delete and the ok alike.
|
|
495
|
+
expect(await persistLiveVoiceSightFrame(live.id, frame)).toEqual({
|
|
496
|
+
ok: false,
|
|
497
|
+
});
|
|
498
|
+
expect(failNextMessageLookup).toBe(false);
|
|
499
|
+
|
|
500
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
501
|
+
expect(realStore.getAttachmentById(frame)).not.toBeNull();
|
|
502
|
+
} finally {
|
|
503
|
+
live.dispose();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("attachments a failed persist attempt cloned for itself", () => {
|
|
509
|
+
/** Link the upload under another conversation, so scoping has to clone it. */
|
|
510
|
+
async function attachmentLinkedElsewhere(
|
|
511
|
+
otherConversationId: string,
|
|
512
|
+
name: string,
|
|
513
|
+
): Promise<string> {
|
|
514
|
+
const attachmentId = await uploadFrame(name);
|
|
515
|
+
const elsewhere = await addMessage(
|
|
516
|
+
otherConversationId,
|
|
517
|
+
"user",
|
|
518
|
+
"look at this",
|
|
519
|
+
);
|
|
520
|
+
realStore.linkAttachmentToMessage(elsewhere.id, attachmentId, 0);
|
|
521
|
+
return attachmentId;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
test("the clone is given up when the insert never happened", async () => {
|
|
525
|
+
// Scoping clones before the row is written, and the caller only ever holds
|
|
526
|
+
// the id it sent, so a failure here strands the clone and its copied file
|
|
527
|
+
// where nothing can reach them.
|
|
528
|
+
const source = liveConversation("Live voice clone leak source");
|
|
529
|
+
const live = liveConversation("Live voice clone leak");
|
|
530
|
+
try {
|
|
531
|
+
const arrivedAs = await attachmentLinkedElsewhere(
|
|
532
|
+
source.id,
|
|
533
|
+
"clone-leak.png",
|
|
534
|
+
);
|
|
535
|
+
scopedAttachmentIds.length = 0;
|
|
536
|
+
|
|
537
|
+
failNextAddMessage = true;
|
|
538
|
+
expect(await persistLiveVoiceSightFrame(live.id, arrivedAs)).toEqual({
|
|
539
|
+
ok: false,
|
|
540
|
+
});
|
|
541
|
+
expect(failNextAddMessage).toBe(false);
|
|
542
|
+
|
|
543
|
+
const cloned = scopedAttachmentIds.at(-1);
|
|
544
|
+
expect(cloned).toBeDefined();
|
|
545
|
+
expect(cloned).not.toBe(arrivedAs);
|
|
546
|
+
|
|
547
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
548
|
+
expect(realStore.getAttachmentById(cloned!)).toBeNull();
|
|
549
|
+
// The caller's own upload is untouched, still held by its other
|
|
550
|
+
// conversation's message.
|
|
551
|
+
expect(realStore.getAttachmentById(arrivedAs)).not.toBeNull();
|
|
552
|
+
} finally {
|
|
553
|
+
live.dispose();
|
|
554
|
+
source.dispose();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("the clone is kept when the row already references it", async () => {
|
|
559
|
+
// Past the insert the persisted content names the clone while no link
|
|
560
|
+
// protects it yet, so cleaning up here would strip the image out of a
|
|
561
|
+
// message the transcript still shows.
|
|
562
|
+
const source = liveConversation("Live voice clone kept source");
|
|
563
|
+
const live = liveConversation("Live voice clone kept");
|
|
564
|
+
try {
|
|
565
|
+
const arrivedAs = await attachmentLinkedElsewhere(
|
|
566
|
+
source.id,
|
|
567
|
+
"clone-kept.png",
|
|
568
|
+
);
|
|
569
|
+
scopedAttachmentIds.length = 0;
|
|
570
|
+
|
|
571
|
+
failNextLink = true;
|
|
572
|
+
failNextContentUpdate = true;
|
|
573
|
+
// Committed despite the throw, which is what makes the clone the row's.
|
|
574
|
+
expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
|
|
575
|
+
true,
|
|
576
|
+
);
|
|
577
|
+
expect(failNextLink).toBe(false);
|
|
578
|
+
expect(failNextContentUpdate).toBe(false);
|
|
579
|
+
|
|
580
|
+
const cloned = scopedAttachmentIds.at(-1);
|
|
581
|
+
expect(cloned).toBeDefined();
|
|
582
|
+
expect(cloned).not.toBe(arrivedAs);
|
|
583
|
+
|
|
584
|
+
expect(getMessages(live.id)).toHaveLength(1);
|
|
585
|
+
expect(realStore.getAttachmentById(cloned!)).not.toBeNull();
|
|
586
|
+
expect(realStore.getAttachmentById(arrivedAs)).not.toBeNull();
|
|
587
|
+
} finally {
|
|
588
|
+
live.dispose();
|
|
589
|
+
source.dispose();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("a clone is given up when materialization itself throws", async () => {
|
|
594
|
+
// The reference block is built between creating the row and recording it,
|
|
595
|
+
// so a throw there leaves a clone the returned list never mentions. Only
|
|
596
|
+
// the materialization step still knows about it, which is why it cleans up
|
|
597
|
+
// its own partial work rather than leaving that to the caller.
|
|
598
|
+
const source = liveConversation("Live voice partial clone source");
|
|
599
|
+
const live = liveConversation("Live voice partial clone");
|
|
600
|
+
try {
|
|
601
|
+
const arrivedAs = await attachmentLinkedElsewhere(
|
|
602
|
+
source.id,
|
|
603
|
+
"partial-clone.png",
|
|
604
|
+
);
|
|
605
|
+
scopedAttachmentIds.length = 0;
|
|
606
|
+
|
|
607
|
+
failNextContentRead = true;
|
|
608
|
+
expect(await persistLiveVoiceSightFrame(live.id, arrivedAs)).toEqual({
|
|
609
|
+
ok: false,
|
|
610
|
+
});
|
|
611
|
+
expect(failNextContentRead).toBe(false);
|
|
612
|
+
|
|
613
|
+
const cloned = scopedAttachmentIds.at(-1);
|
|
614
|
+
expect(cloned).toBeDefined();
|
|
615
|
+
expect(cloned).not.toBe(arrivedAs);
|
|
616
|
+
|
|
617
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
618
|
+
expect(realStore.getAttachmentById(cloned!)).toBeNull();
|
|
619
|
+
expect(realStore.getAttachmentById(arrivedAs)).not.toBeNull();
|
|
620
|
+
} finally {
|
|
621
|
+
live.dispose();
|
|
622
|
+
source.dispose();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("an uncloned upload survives a failed attempt", async () => {
|
|
627
|
+
// Materialization stored this one under the id it arrived with, so the row
|
|
628
|
+
// IS the caller's upload and the retry they are about to make needs it.
|
|
629
|
+
// A photo rather than a keep, so the live-voice reclaim (keeps only) stays
|
|
630
|
+
// out of the way and the assertion isolates the persist's own cleanup.
|
|
631
|
+
const live = liveConversation("Live voice uncloned survives");
|
|
632
|
+
try {
|
|
633
|
+
const photo = await uploadFrame("uncloned.png");
|
|
634
|
+
|
|
635
|
+
failNextAddMessage = true;
|
|
636
|
+
expect(await persistLiveVoicePhoto(live.id, photo)).toEqual({
|
|
637
|
+
ok: false,
|
|
638
|
+
});
|
|
639
|
+
expect(failNextAddMessage).toBe(false);
|
|
640
|
+
|
|
641
|
+
expect(getMessages(live.id)).toHaveLength(0);
|
|
642
|
+
expect(realStore.getAttachmentById(photo)).not.toBeNull();
|
|
643
|
+
} finally {
|
|
644
|
+
live.dispose();
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe("a clone whose materialization fails", () => {
|
|
650
|
+
/** Rows in the attachment store, so a leaked clone shows up as a count. */
|
|
651
|
+
function attachmentRowCount(): number {
|
|
652
|
+
return getDb().select({ id: attachments.id }).from(attachments).all()
|
|
653
|
+
.length;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Give a row a real file on disk, the shape a clone inherits its path from. */
|
|
657
|
+
function backWithFile(attachmentId: string, name: string): string {
|
|
658
|
+
const filePath = join(tmpdir(), `sight-frame-${name}`);
|
|
659
|
+
writeFileSync(filePath, Buffer.from(IMAGE_BASE64, "base64"));
|
|
660
|
+
getDb()
|
|
661
|
+
.update(attachments)
|
|
662
|
+
.set({ filePath })
|
|
663
|
+
.where(eq(attachments.id, attachmentId))
|
|
664
|
+
.run();
|
|
665
|
+
return filePath;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
test("leaves no row behind and costs the source nothing", async () => {
|
|
669
|
+
// Materialization is fallible AFTER the clone row is inserted: it copies
|
|
670
|
+
// the bytes and repoints the row, and until that lands the clone still
|
|
671
|
+
// names the file the bytes came FROM. A failure used to leave that row for
|
|
672
|
+
// good, because the persist recovers by inlining and no error ever reached
|
|
673
|
+
// the callers that clean up.
|
|
674
|
+
const source = liveConversation("Live voice clone materialize source");
|
|
675
|
+
const live = liveConversation("Live voice clone materialize");
|
|
676
|
+
const attachDir = getConversationAttachmentsDirPath(
|
|
677
|
+
live.id,
|
|
678
|
+
live.createdAt,
|
|
679
|
+
);
|
|
680
|
+
let sourceFile: string | null = null;
|
|
681
|
+
try {
|
|
682
|
+
const arrivedAs = await uploadFrame("clone-materialize.png");
|
|
683
|
+
const elsewhere = await addMessage(source.id, "user", "look at this");
|
|
684
|
+
realStore.linkAttachmentToMessage(elsewhere.id, arrivedAs, 0);
|
|
685
|
+
// The clone is inserted carrying exactly this path.
|
|
686
|
+
sourceFile = backWithFile(arrivedAs, "source.png");
|
|
687
|
+
|
|
688
|
+
const rowsBefore = attachmentRowCount();
|
|
689
|
+
|
|
690
|
+
// A plain file where the destination's attachments directory belongs, so
|
|
691
|
+
// the very first thing materialization does fails.
|
|
692
|
+
mkdirSync(dirname(attachDir), { recursive: true });
|
|
693
|
+
writeFileSync(attachDir, "");
|
|
694
|
+
|
|
695
|
+
expect((await persistLiveVoiceSightFrame(live.id, arrivedAs)).ok).toBe(
|
|
696
|
+
true,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
// The recovery is unchanged: the frame still lands, inline.
|
|
700
|
+
const [row] = getMessages(live.id);
|
|
701
|
+
expect(hasInlineImage(row)).toBe(true);
|
|
702
|
+
|
|
703
|
+
// No clone row survived the failure.
|
|
704
|
+
expect(attachmentRowCount()).toBe(rowsBefore);
|
|
705
|
+
|
|
706
|
+
// The source keeps its row, the file the clone was aliasing, and its own
|
|
707
|
+
// message's link.
|
|
708
|
+
expect(realStore.getAttachmentById(arrivedAs)).not.toBeNull();
|
|
709
|
+
expect(existsSync(sourceFile)).toBe(true);
|
|
710
|
+
expect(realStore.getAttachmentsForMessage(elsewhere.id)).toHaveLength(1);
|
|
711
|
+
} finally {
|
|
712
|
+
rmSync(attachDir, { force: true });
|
|
713
|
+
if (sourceFile) {
|
|
714
|
+
rmSync(sourceFile, { force: true });
|
|
715
|
+
}
|
|
716
|
+
live.dispose();
|
|
717
|
+
source.dispose();
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("orphan collection spares a file another row still names", async () => {
|
|
722
|
+
// The same aliasing seen from the other side. A clone carries the source's
|
|
723
|
+
// path until materialization repoints it, so a row being collected can
|
|
724
|
+
// share its file with one that outlives it, and unlinking then takes the
|
|
725
|
+
// survivor's bytes.
|
|
726
|
+
const kept = await uploadFrame("shared-kept.png");
|
|
727
|
+
const alias = await uploadFrame("shared-alias.png");
|
|
728
|
+
const sharedFile = backWithFile(kept, "shared.png");
|
|
729
|
+
getDb()
|
|
730
|
+
.update(attachments)
|
|
731
|
+
.set({ filePath: sharedFile })
|
|
732
|
+
.where(eq(attachments.id, alias))
|
|
733
|
+
.run();
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
expect(realStore.deleteOrphanAttachments([alias])).toBe(1);
|
|
737
|
+
|
|
738
|
+
expect(realStore.getAttachmentById(alias)).toBeNull();
|
|
739
|
+
expect(realStore.getAttachmentById(kept)).not.toBeNull();
|
|
740
|
+
// The survivor's bytes are still there.
|
|
741
|
+
expect(existsSync(sharedFile)).toBe(true);
|
|
742
|
+
} finally {
|
|
743
|
+
rmSync(sharedFile, { force: true });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
describe("ambient frames stay out of the indexing pipeline", () => {
|
|
749
|
+
test("a kept frame is never handed to the indexer", async () => {
|
|
750
|
+
// The camera sampled it, so indexing would feed extraction a frame every
|
|
751
|
+
// few seconds of whatever the room contains, and commit those visuals to
|
|
752
|
+
// memory with no consent surface. The transcript is the record the design
|
|
753
|
+
// signed off on.
|
|
754
|
+
const live = liveConversation("Live voice keep not indexed");
|
|
755
|
+
try {
|
|
756
|
+
const frame = await uploadFrame("not-indexed.png");
|
|
757
|
+
|
|
758
|
+
lexicallyIndexed.length = 0;
|
|
759
|
+
expect((await persistLiveVoiceSightFrame(live.id, frame)).ok).toBe(true);
|
|
760
|
+
|
|
761
|
+
const [row] = getMessages(live.id);
|
|
762
|
+
expect(row).toBeDefined();
|
|
763
|
+
expect(lexicallyIndexed).not.toContain(row.id);
|
|
764
|
+
expect(lexicallyIndexed).toHaveLength(0);
|
|
765
|
+
} finally {
|
|
766
|
+
live.dispose();
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("a photo is still indexed, being something the user sent", async () => {
|
|
771
|
+
// Deliberate user content, and its indexing is pre-existing behavior that
|
|
772
|
+
// this change must not quietly take away.
|
|
773
|
+
const live = liveConversation("Live voice photo still indexed");
|
|
774
|
+
try {
|
|
775
|
+
const photo = await uploadFrame("still-indexed.png");
|
|
776
|
+
|
|
777
|
+
lexicallyIndexed.length = 0;
|
|
778
|
+
expect((await persistLiveVoicePhoto(live.id, photo)).ok).toBe(true);
|
|
779
|
+
|
|
780
|
+
const [row] = getMessages(live.id);
|
|
781
|
+
expect(row).toBeDefined();
|
|
782
|
+
expect(lexicallyIndexed).toContain(row.id);
|
|
783
|
+
} finally {
|
|
784
|
+
live.dispose();
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
});
|