@vellumai/assistant 0.6.3 → 0.6.5
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/.prettierignore +5 -0
- package/ARCHITECTURE.md +298 -39
- package/Dockerfile +14 -3
- package/README.md +3 -4
- package/bun.lock +13 -16
- package/docs/architecture/integrations.md +1 -20
- package/docs/architecture/security.md +16 -16
- package/docs/backup-troubleshooting.md +52 -0
- package/docs/browser-use-architecture-phase2.md +174 -0
- package/docs/error-handling.md +111 -0
- package/docs/skills.md +10 -10
- package/docs/stt-provider-onboarding.md +121 -0
- package/knip.json +20 -3
- package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
- package/node_modules/@vellumai/ces-contracts/package.json +5 -4
- package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +471 -0
- package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +398 -4
- package/node_modules/@vellumai/credential-storage/bun.lock +2 -2
- package/node_modules/@vellumai/credential-storage/package.json +2 -2
- package/node_modules/@vellumai/credential-storage/src/oauth-runtime.ts +20 -2
- package/node_modules/@vellumai/egress-proxy/bun.lock +2 -2
- package/node_modules/@vellumai/egress-proxy/package.json +2 -2
- package/openapi.yaml +1094 -72
- package/package.json +9 -8
- package/scripts/generate-openapi.ts +50 -12
- package/scripts/test.sh +73 -18
- package/src/__tests__/agent-image-optimize.test.ts +28 -0
- package/src/__tests__/agent-loop-callsite-precedence.test.ts +318 -0
- package/src/__tests__/agent-loop-sentry-hygiene.test.ts +137 -0
- package/src/__tests__/agent-loop.test.ts +235 -1
- package/src/__tests__/anthropic-error-formatting.test.ts +98 -0
- package/src/__tests__/anthropic-provider.test.ts +434 -12
- package/src/__tests__/approval-cascade.test.ts +31 -10
- package/src/__tests__/approval-routes-http.test.ts +134 -10
- package/src/__tests__/assistant-attachments.test.ts +44 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +29 -0
- package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
- package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
- package/src/__tests__/browser-fill-credential.test.ts +12 -1
- package/src/__tests__/browser-identifier-parity-guard.test.ts +53 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +23 -33
- package/src/__tests__/browser-skill-endstate.test.ts +52 -159
- package/src/__tests__/btw-routes.test.ts +54 -1
- package/src/__tests__/call-controller.test.ts +582 -22
- package/src/__tests__/call-site-routing-provider.test.ts +214 -0
- package/src/__tests__/catalog-cache.test.ts +27 -4
- package/src/__tests__/catalog-files.test.ts +138 -0
- package/src/__tests__/channel-approval-routes.test.ts +4 -4
- package/src/__tests__/channel-invite-transport.test.ts +2 -2
- package/src/__tests__/channel-readiness-routes.test.ts +16 -20
- package/src/__tests__/channel-readiness-service.test.ts +12 -7
- package/src/__tests__/channel-reply-delivery.test.ts +300 -2
- package/src/__tests__/checker.test.ts +576 -502
- package/src/__tests__/clawhub-files.test.ts +347 -0
- package/src/__tests__/cli-command-risk-guard.test.ts +30 -33
- package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
- package/src/__tests__/compaction-circuit-breaker.test.ts +336 -0
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/config-analysis.test.ts +83 -0
- package/src/__tests__/config-loader-backfill.test.ts +174 -0
- package/src/__tests__/config-loader-corrupt.test.ts +183 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +202 -0
- package/src/__tests__/config-schema-cmd.test.ts +11 -5
- package/src/__tests__/config-schema.test.ts +1458 -198
- package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
- package/src/__tests__/config-watcher.test.ts +45 -10
- package/src/__tests__/contact-store-user-file.test.ts +511 -0
- package/src/__tests__/contacts-write.test.ts +197 -0
- package/src/__tests__/context-token-estimator.test.ts +191 -1
- package/src/__tests__/context-window-manager.test.ts +618 -2
- package/src/__tests__/conversation-abort-tool-results.test.ts +32 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +62 -17
- package/src/__tests__/conversation-agent-loop.test.ts +510 -84
- package/src/__tests__/conversation-attachments.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +165 -9
- package/src/__tests__/conversation-error.test.ts +102 -1
- package/src/__tests__/conversation-history-web-search.test.ts +17 -4
- package/src/__tests__/conversation-init.benchmark.test.ts +42 -1
- package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
- package/src/__tests__/conversation-lifecycle.test.ts +336 -0
- package/src/__tests__/conversation-list-source.test.ts +145 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +27 -10
- package/src/__tests__/conversation-pre-run-repair.test.ts +32 -16
- package/src/__tests__/conversation-process-callsite.test.ts +306 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +32 -16
- package/src/__tests__/conversation-queue.test.ts +932 -76
- package/src/__tests__/conversation-routes-disk-view.test.ts +299 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +31 -3
- package/src/__tests__/conversation-runtime-assembly.test.ts +2790 -55
- package/src/__tests__/conversation-runtime-workspace.test.ts +12 -12
- package/src/__tests__/conversation-skill-tools.test.ts +12 -143
- package/src/__tests__/conversation-slash-commands.test.ts +33 -0
- package/src/__tests__/conversation-slash-queue.test.ts +120 -34
- package/src/__tests__/conversation-slash-unknown.test.ts +32 -16
- package/src/__tests__/conversation-speed-override.test.ts +30 -11
- package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1035 -0
- package/src/__tests__/conversation-surfaces-standalone.test.ts +630 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -2
- package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
- package/src/__tests__/conversation-unread-route.test.ts +2 -2
- package/src/__tests__/conversation-usage.test.ts +3 -1
- package/src/__tests__/conversation-workspace-cache-state.test.ts +31 -10
- package/src/__tests__/conversation-workspace-injection.test.ts +45 -15
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +46 -16
- package/src/__tests__/credential-broker-browser-fill.test.ts +110 -0
- package/src/__tests__/credential-health-service.test.ts +352 -0
- package/src/__tests__/credential-security-invariants.test.ts +8 -3
- package/src/__tests__/credential-storage-oauth-compat.test.ts +18 -0
- package/src/__tests__/credential-storage-static-compat.test.ts +28 -0
- package/src/__tests__/credential-vault-unit.test.ts +495 -3
- package/src/__tests__/credentials-cli.test.ts +32 -16
- package/src/__tests__/cross-provider-web-search.test.ts +230 -35
- package/src/__tests__/daemon-server-persist-and-process-callsite.test.ts +92 -0
- package/src/__tests__/delete-propagation.test.ts +437 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
- package/src/__tests__/device-id.test.ts +112 -0
- package/src/__tests__/dm-backfill.test.ts +417 -0
- package/src/__tests__/dm-persistence.test.ts +227 -0
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
- package/src/__tests__/edit-propagation.test.ts +280 -0
- package/src/__tests__/email-html-renderer.test.ts +71 -0
- package/src/__tests__/email-invite-adapter.test.ts +36 -32
- package/src/__tests__/emit-event-signal.test.ts +71 -0
- package/src/__tests__/ephemeral-permissions.test.ts +93 -3
- package/src/__tests__/estimator-calibration-integration.test.ts +208 -0
- package/src/__tests__/estimator-calibration.test.ts +213 -0
- package/src/__tests__/extension-id-sync-guard.test.ts +101 -15
- package/src/__tests__/file-write-tool.test.ts +151 -1
- package/src/__tests__/filing-service.test.ts +255 -0
- package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/gemini-provider.test.ts +64 -3
- package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
- package/src/__tests__/guardian-grant-minting.test.ts +8 -0
- package/src/__tests__/headless-browser-interactions.test.ts +44 -1
- package/src/__tests__/headless-browser-mode.test.ts +614 -0
- package/src/__tests__/headless-browser-navigate.test.ts +142 -5
- package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
- package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
- package/src/__tests__/heartbeat-service.test.ts +166 -32
- package/src/__tests__/home-state-routes.test.ts +162 -0
- package/src/__tests__/host-bash-proxy.test.ts +0 -5
- package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
- package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
- package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
- package/src/__tests__/host-cu-proxy.test.ts +0 -5
- package/src/__tests__/host-shell-tool.test.ts +124 -18
- package/src/__tests__/http-user-message-parity.test.ts +29 -1
- package/src/__tests__/identity-intro-cache.test.ts +40 -10
- package/src/__tests__/inbound-slack-persistence.test.ts +340 -0
- package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
- package/src/__tests__/intent-routing.test.ts +1 -40
- package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
- package/src/__tests__/llm-catalog-parity.test.ts +174 -0
- package/src/__tests__/llm-context-normalization.test.ts +609 -0
- package/src/__tests__/llm-context-route-provider.test.ts +86 -5
- package/src/__tests__/llm-resolver.test.ts +214 -0
- package/src/__tests__/llm-schema.test.ts +223 -0
- package/src/__tests__/llm-usage-store.test.ts +363 -0
- package/src/__tests__/managed-proxy-context.test.ts +6 -2
- package/src/__tests__/media-stream-output.test.ts +555 -0
- package/src/__tests__/media-stream-parser.test.ts +374 -0
- package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
- package/src/__tests__/media-stream-stt-session.test.ts +588 -0
- package/src/__tests__/media-turn-detector.test.ts +440 -0
- package/src/__tests__/message-queue.test.ts +125 -0
- package/src/__tests__/messaging-skill-split.test.ts +3 -34
- package/src/__tests__/migration-export-http.test.ts +6 -6
- package/src/__tests__/migration-import-commit-http.test.ts +8 -6
- package/src/__tests__/migration-import-from-url.test.ts +684 -0
- package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
- package/src/__tests__/migration-validate-http.test.ts +3 -3
- package/src/__tests__/mock-gateway-ipc.ts +151 -0
- package/src/__tests__/model-intents.test.ts +10 -84
- package/src/__tests__/notification-decision-fallback.test.ts +0 -10
- package/src/__tests__/notification-decision-identity.test.ts +0 -9
- package/src/__tests__/notification-decision-recipient-context.test.ts +0 -9
- package/src/__tests__/oauth-apps-routes.test.ts +1 -0
- package/src/__tests__/oauth-cli.test.ts +2 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
- package/src/__tests__/oauth-providers-routes.test.ts +2 -0
- package/src/__tests__/oauth-store.test.ts +95 -7
- package/src/__tests__/oauth2-gateway-transport.test.ts +257 -9
- package/src/__tests__/oauth2-refresh-retry.test.ts +279 -0
- package/src/__tests__/onboarding-template-contract.test.ts +6 -13
- package/src/__tests__/openai-provider.test.ts +183 -0
- package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
- package/src/__tests__/openai-responses-provider.test.ts +1501 -0
- package/src/__tests__/openrouter-provider-only.test.ts +135 -0
- package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +293 -0
- package/src/__tests__/permission-checker-host-gate.test.ts +1 -1
- package/src/__tests__/permission-mode.test.ts +16 -0
- package/src/__tests__/permission-types.test.ts +0 -1
- package/src/__tests__/persona-resolver.test.ts +251 -0
- package/src/__tests__/pkb-autoinject.test.ts +37 -1
- package/src/__tests__/platform-bash-auto-approve.test.ts +5 -1
- package/src/__tests__/platform.test.ts +92 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
- package/src/__tests__/pricing.test.ts +224 -3
- package/src/__tests__/profiler-routes.test.ts +1 -1
- package/src/__tests__/provider-commit-message-generator.test.ts +14 -84
- package/src/__tests__/provider-env-vars-scope.test.ts +52 -0
- package/src/__tests__/provider-error-scenarios.test.ts +135 -6
- package/src/__tests__/provider-managed-proxy-integration.test.ts +42 -11
- package/src/__tests__/provider-registry-ollama.test.ts +1 -2
- package/src/__tests__/proxy-approval-callback.test.ts +0 -1
- package/src/__tests__/qdrant-manager.test.ts +29 -8
- package/src/__tests__/reaction-persistence.test.ts +560 -0
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
- package/src/__tests__/relationship-state-contract.test.ts +175 -0
- package/src/__tests__/relay-server.test.ts +424 -6
- package/src/__tests__/require-fresh-approval.test.ts +1 -1
- package/src/__tests__/retry-openrouter-only-normalization.test.ts +136 -0
- package/src/__tests__/retry-thinking-tool-choice.test.ts +226 -0
- package/src/__tests__/risk-classifier-parity.test.ts +230 -0
- package/src/__tests__/sanitize-config-for-transfer.test.ts +78 -1
- package/src/__tests__/search-skills-unified.test.ts +118 -0
- package/src/__tests__/secret-ingress-http.test.ts +28 -0
- package/src/__tests__/secret-prompter-channel-fallback.test.ts +125 -0
- package/src/__tests__/secret-routes-managed-proxy.test.ts +2 -3
- package/src/__tests__/secret-scanner-executor.test.ts +5 -1
- package/src/__tests__/secure-keys.test.ts +107 -0
- package/src/__tests__/send-endpoint-busy.test.ts +34 -2
- package/src/__tests__/sequence-store.test.ts +1 -1
- package/src/__tests__/server-history-render.test.ts +80 -0
- package/src/__tests__/settings-routes.test.ts +201 -0
- package/src/__tests__/shell-parser-property.test.ts +13 -13
- package/src/__tests__/skill-cache-store.test.ts +182 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
- package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
- package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
- package/src/__tests__/skills.test.ts +19 -30
- package/src/__tests__/skillssh-files.test.ts +446 -0
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
- package/src/__tests__/slack-block-formatting.test.ts +110 -0
- package/src/__tests__/slack-channel-config.test.ts +564 -1
- package/src/__tests__/slack-skill.test.ts +3 -8
- package/src/__tests__/starter-bundle.test.ts +35 -0
- package/src/__tests__/stt-catalog-parity.test.ts +282 -0
- package/src/__tests__/stt-stream-session.test.ts +535 -0
- package/src/__tests__/subagent-call-site-routing.test.ts +280 -0
- package/src/__tests__/suggestion-routes.test.ts +160 -3
- package/src/__tests__/system-prompt.test.ts +126 -53
- package/src/__tests__/task-runner.test.ts +3 -1
- package/src/__tests__/tcc-sandbox-deny.test.ts +198 -0
- package/src/__tests__/telephony-stt-routing.test.ts +329 -0
- package/src/__tests__/terminal-tools.test.ts +26 -7
- package/src/__tests__/test-preload.ts +18 -0
- package/src/__tests__/test-support/browser-skill-harness.ts +2 -49
- package/src/__tests__/thread-backfill.test.ts +941 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -2
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +10 -6
- package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +88 -113
- package/src/__tests__/tool-result-truncation.test.ts +36 -0
- package/src/__tests__/trust-store.test.ts +442 -103
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
- package/src/__tests__/tts-catalog-parity.test.ts +345 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
- package/src/__tests__/twilio-routes.test.ts +376 -0
- package/src/__tests__/unicode.test.ts +293 -0
- package/src/__tests__/update-bulletin-job.test.ts +389 -0
- package/src/__tests__/usage-cache-backfill-migration.test.ts +3 -1
- package/src/__tests__/usage-routes.test.ts +25 -4
- package/src/__tests__/user-reference.test.ts +46 -61
- package/src/__tests__/verification-control-plane-policy.test.ts +5 -22
- package/src/__tests__/voice-config-update.test.ts +403 -0
- package/src/__tests__/voice-quality.test.ts +434 -19
- package/src/__tests__/voice-session-bridge.test.ts +39 -0
- package/src/__tests__/volume-security-guard.test.ts +3 -2
- package/src/__tests__/web-search-history.test.ts +337 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
- package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
- package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
- package/src/__tests__/workspace-migration-039-drop-legacy-llm-keys.test.ts +343 -0
- package/src/__tests__/workspace-migration-043-release-notes-latex-rendering.test.ts +202 -0
- package/src/__tests__/workspace-migration-045-release-notes-meet-avatar.test.ts +210 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
- package/src/__tests__/workspace-migration-meets.test.ts +244 -0
- package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
- package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +841 -0
- package/src/__tests__/workspace-policy.test.ts +1 -11
- package/src/acp/client-handler.ts +1 -2
- package/src/agent/image-optimize.ts +24 -12
- package/src/agent/loop.ts +251 -19
- package/src/avatar/resvg-lazy.test.ts +136 -0
- package/src/avatar/resvg-lazy.ts +82 -9
- package/src/avatar/traits-png-sync.ts +21 -1
- package/src/backup/__tests__/backup-key.test.ts +152 -0
- package/src/backup/__tests__/backup-worker.test.ts +767 -0
- package/src/backup/__tests__/list-snapshots.test.ts +87 -0
- package/src/backup/__tests__/local-writer.test.ts +218 -0
- package/src/backup/__tests__/offsite-writer.test.ts +641 -0
- package/src/backup/__tests__/paths.test.ts +300 -0
- package/src/backup/__tests__/restore.test.ts +498 -0
- package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
- package/src/backup/__tests__/stream-crypt.test.ts +228 -0
- package/src/backup/backup-key.ts +137 -0
- package/src/backup/backup-worker.ts +459 -0
- package/src/backup/list-snapshots.ts +147 -0
- package/src/backup/local-writer.ts +133 -0
- package/src/backup/offsite-writer.ts +222 -0
- package/src/backup/paths.ts +226 -0
- package/src/backup/restore.ts +322 -0
- package/src/backup/snapshot-lock.ts +431 -0
- package/src/backup/stream-crypt.ts +263 -0
- package/src/browser/__tests__/operations.test.ts +163 -0
- package/src/browser/identifiers.ts +51 -0
- package/src/browser/operations.ts +660 -0
- package/src/browser/types.ts +81 -0
- package/src/bundler/package-resolver.ts +4 -0
- package/src/calls/audio-store.ts +11 -5
- package/src/calls/call-controller.ts +226 -71
- package/src/calls/call-domain.ts +9 -0
- package/src/calls/call-speech-output.ts +190 -0
- package/src/calls/call-transport.ts +77 -0
- package/src/calls/guardian-question-copy.ts +2 -2
- package/src/calls/media-stream-audio-transcode.ts +173 -0
- package/src/calls/media-stream-output.ts +660 -0
- package/src/calls/media-stream-parser.ts +300 -0
- package/src/calls/media-stream-protocol.ts +166 -0
- package/src/calls/media-stream-server.ts +592 -0
- package/src/calls/media-stream-stt-session.ts +460 -0
- package/src/calls/media-turn-detector.ts +230 -0
- package/src/calls/relay-server.ts +90 -75
- package/src/calls/resolve-call-tts-provider.ts +136 -0
- package/src/calls/telephony-stt-routing.ts +145 -0
- package/src/calls/tts-call-strategy.ts +161 -0
- package/src/calls/tts-text-sanitizer.ts +32 -16
- package/src/calls/twilio-routes.ts +281 -17
- package/src/calls/voice-quality.ts +78 -35
- package/src/calls/voice-session-bridge.ts +9 -1
- package/src/channels/types.ts +16 -0
- package/src/cli/AGENTS.md +1 -1
- package/src/cli/__tests__/run-assistant-command.ts +11 -1
- package/src/cli/commands/__tests__/attachment.test.ts +438 -0
- package/src/cli/commands/__tests__/backup.test.ts +1165 -0
- package/src/cli/commands/__tests__/browser.test.ts +554 -0
- package/src/cli/commands/__tests__/cache.test.ts +623 -0
- package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
- package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
- package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
- package/src/cli/commands/__tests__/email-download.test.ts +16 -1
- package/src/cli/commands/__tests__/email-list.test.ts +28 -4
- package/src/cli/commands/__tests__/email-register.test.ts +4 -4
- package/src/cli/commands/__tests__/email-send.test.ts +130 -5
- package/src/cli/commands/__tests__/email-status.test.ts +5 -1
- package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
- package/src/cli/commands/__tests__/image-generation.test.ts +666 -0
- package/src/cli/commands/__tests__/inference-send.test.ts +451 -0
- package/src/cli/commands/__tests__/stt-transcribe.test.ts +454 -0
- package/src/cli/commands/__tests__/task.test.ts +913 -0
- package/src/cli/commands/__tests__/tts-synthesize.test.ts +594 -0
- package/src/cli/commands/__tests__/ui-confirm.test.ts +650 -0
- package/src/cli/commands/__tests__/ui.test.ts +1215 -0
- package/src/cli/commands/__tests__/watchers.test.ts +716 -0
- package/src/cli/commands/attachment.ts +182 -0
- package/src/cli/commands/backup.ts +993 -0
- package/src/cli/commands/browser.ts +350 -0
- package/src/cli/commands/cache.ts +341 -0
- package/src/cli/commands/completions.ts +0 -3
- package/src/cli/commands/config.ts +6 -6
- package/src/cli/commands/conversations-import.ts +347 -0
- package/src/cli/commands/conversations.ts +90 -0
- package/src/cli/commands/credentials.ts +0 -1
- package/src/cli/commands/domain.ts +210 -0
- package/src/cli/commands/email.ts +308 -16
- package/src/cli/commands/image-generation.ts +300 -0
- package/src/cli/commands/inference.ts +200 -0
- package/src/cli/commands/memory.ts +127 -17
- package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
- package/src/cli/commands/oauth/mode.ts +12 -3
- package/src/cli/commands/oauth/providers.ts +15 -0
- package/src/cli/commands/oauth/shared.ts +2 -1
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -10
- package/src/cli/commands/platform/__tests__/connect.test.ts +6 -1
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -2
- package/src/cli/commands/platform/__tests__/status.test.ts +6 -1
- package/src/cli/commands/stt.ts +339 -0
- package/src/cli/commands/task.ts +795 -0
- package/src/cli/commands/trust.ts +50 -19
- package/src/cli/commands/tts.ts +273 -0
- package/src/cli/commands/ui.ts +670 -0
- package/src/cli/commands/watchers.ts +509 -0
- package/src/cli/lib/daemon-credential-client.ts +0 -19
- package/src/cli/program.ts +53 -8
- package/src/cli.ts +0 -37
- package/src/config/__tests__/backup-schema.test.ts +134 -0
- package/src/config/assistant-feature-flags.ts +61 -62
- package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
- package/src/config/bundled-skills/contacts/SKILL.md +2 -2
- package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +23 -1
- package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
- package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
- package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
- package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
- package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
- package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
- package/src/config/bundled-skills/media-processing/services/reduce.ts +1 -1
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
- package/src/config/bundled-skills/messaging/SKILL.md +5 -5
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -0
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +9 -2
- package/src/config/bundled-skills/messaging/tools/messaging-read.ts +15 -1
- package/src/config/bundled-skills/messaging/tools/messaging-search.ts +21 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +11 -12
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +28 -18
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
- package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
- package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
- package/src/config/bundled-tool-registry.ts +0 -167
- package/src/config/env-registry.ts +24 -0
- package/src/config/env.ts +39 -10
- package/src/config/feature-flag-registry.json +63 -15
- package/src/config/llm-resolver.ts +128 -0
- package/src/config/loader.ts +220 -22
- package/src/config/raw-config-utils.ts +30 -2
- package/src/config/sanitize-for-transfer.ts +35 -0
- package/src/config/schema.ts +65 -51
- package/src/config/schemas/__tests__/stt.test.ts +43 -0
- package/src/config/schemas/analysis.ts +32 -0
- package/src/config/schemas/backup.ts +72 -0
- package/src/config/schemas/calls.ts +1 -30
- package/src/config/schemas/elevenlabs.ts +0 -59
- package/src/config/schemas/filing.ts +49 -14
- package/src/config/schemas/heartbeat.ts +27 -10
- package/src/config/schemas/host-browser.ts +47 -1
- package/src/config/schemas/inference.ts +3 -23
- package/src/config/schemas/llm.ts +318 -0
- package/src/config/schemas/memory-lifecycle.ts +14 -2
- package/src/config/schemas/memory-processing.ts +1 -9
- package/src/config/schemas/notifications.ts +4 -11
- package/src/config/schemas/platform.ts +3 -9
- package/src/config/schemas/security.ts +33 -0
- package/src/config/schemas/services.ts +53 -4
- package/src/config/schemas/stt.ts +60 -0
- package/src/config/schemas/tts.ts +283 -0
- package/src/config/schemas/updates.ts +14 -0
- package/src/config/schemas/workspace-git.ts +3 -40
- package/src/config/skills.ts +6 -2
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +56 -11
- package/src/contacts/contacts-write.ts +38 -1
- package/src/context/__tests__/compact-prompt.test.ts +45 -0
- package/src/context/__tests__/microcompact.test.ts +805 -0
- package/src/context/estimator-calibration.ts +136 -0
- package/src/context/microcompact.ts +443 -0
- package/src/context/post-turn-tool-result-truncation.ts +3 -2
- package/src/context/prompts/compact.md +12 -0
- package/src/context/token-estimator.ts +61 -3
- package/src/context/tool-result-truncation.ts +2 -1
- package/src/context/window-manager.ts +272 -35
- package/src/credential-execution/approval-bridge.ts +0 -1
- package/src/credential-execution/executable-discovery.ts +23 -2
- package/src/credential-execution/process-manager.test.ts +109 -0
- package/src/credential-execution/process-manager.ts +96 -2
- package/src/credential-health/credential-health-service.ts +366 -0
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
- package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
- package/src/daemon/approval-generators.ts +29 -4
- package/src/daemon/assistant-attachments.ts +24 -13
- package/src/daemon/classifier.ts +2 -2
- package/src/daemon/config-watcher.ts +99 -6
- package/src/daemon/context-overflow-reducer.ts +4 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +85 -12
- package/src/daemon/conversation-agent-loop.ts +563 -104
- package/src/daemon/conversation-attachments.ts +2 -6
- package/src/daemon/conversation-error.ts +46 -0
- package/src/daemon/conversation-history.ts +40 -6
- package/src/daemon/conversation-launch.ts +220 -0
- package/src/daemon/conversation-lifecycle.ts +85 -11
- package/src/daemon/conversation-messaging.ts +110 -7
- package/src/daemon/conversation-notifiers.ts +5 -0
- package/src/daemon/conversation-process.ts +591 -23
- package/src/daemon/conversation-queue-manager.ts +27 -0
- package/src/daemon/conversation-runtime-assembly.ts +769 -28
- package/src/daemon/conversation-slash.ts +38 -2
- package/src/daemon/conversation-surfaces.ts +483 -5
- package/src/daemon/conversation-tool-setup.ts +35 -5
- package/src/daemon/conversation-usage.ts +8 -5
- package/src/daemon/conversation.ts +193 -47
- package/src/daemon/external-skills-bootstrap.ts +41 -0
- package/src/daemon/guardian-action-generators.ts +34 -14
- package/src/daemon/handlers/config-model.test.ts +86 -0
- package/src/daemon/handlers/config-model.ts +54 -12
- package/src/daemon/handlers/config-slack-channel.ts +269 -94
- package/src/daemon/handlers/conversations.ts +13 -3
- package/src/daemon/handlers/shared.ts +51 -1
- package/src/daemon/handlers/skills.ts +323 -79
- package/src/daemon/handlers/slack-channel-oauth-install.ts +197 -0
- package/src/daemon/host-browser-proxy.ts +2 -1
- package/src/daemon/lifecycle.ts +185 -26
- package/src/daemon/message-protocol.ts +6 -0
- package/src/daemon/message-types/conversations.ts +48 -1
- package/src/daemon/message-types/home.ts +40 -0
- package/src/daemon/message-types/meet.ts +143 -0
- package/src/daemon/message-types/messages.ts +23 -1
- package/src/daemon/message-types/schedules.ts +34 -2
- package/src/daemon/message-types/skills.ts +16 -0
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/daemon/message-types/trust.ts +0 -2
- package/src/daemon/parse-actual-tokens-from-error.test.ts +57 -1
- package/src/daemon/parse-actual-tokens-from-error.ts +66 -0
- package/src/daemon/pkb-context-tracker.test.ts +169 -0
- package/src/daemon/pkb-context-tracker.ts +125 -0
- package/src/daemon/pkb-reminder-builder.test.ts +70 -0
- package/src/daemon/pkb-reminder-builder.ts +31 -0
- package/src/daemon/providers-setup.ts +6 -0
- package/src/daemon/server.ts +463 -10
- package/src/daemon/shutdown-handlers.ts +32 -4
- package/src/daemon/shutdown-registry.ts +40 -0
- package/src/daemon/tool-side-effects.ts +9 -9
- package/src/daemon/watch-handler.ts +4 -4
- package/src/daemon/web-search-history.ts +126 -0
- package/src/email/html-renderer.ts +76 -0
- package/src/events/domain-events.ts +0 -1
- package/src/filing/filing-service.ts +9 -10
- package/src/heartbeat/heartbeat-service.ts +156 -22
- package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
- package/src/home/__tests__/emit-feed-event.test.ts +169 -0
- package/src/home/__tests__/feed-scheduler.test.ts +222 -0
- package/src/home/__tests__/feed-types.test.ts +275 -0
- package/src/home/__tests__/feed-writer.test.ts +688 -0
- package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
- package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
- package/src/home/__tests__/progress-formula.test.ts +213 -0
- package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
- package/src/home/__tests__/rollup-producer.test.ts +442 -0
- package/src/home/assistant-feed-authoring.ts +128 -0
- package/src/home/emit-feed-event.ts +162 -0
- package/src/home/feed-scheduler.ts +263 -0
- package/src/home/feed-types.ts +235 -0
- package/src/home/feed-writer.ts +469 -0
- package/src/home/platform-gmail-digest.ts +163 -0
- package/src/home/progress-formula.ts +86 -0
- package/src/home/relationship-state-writer.ts +824 -0
- package/src/home/relationship-state.ts +143 -0
- package/src/home/rollup-producer.ts +413 -0
- package/src/home/suggested-prompts.ts +101 -0
- package/src/hooks/runner.ts +7 -0
- package/src/inbound/platform-callback-registration.ts +12 -3
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/instrument.ts +1 -1
- package/src/ipc/__tests__/attachment-ipc.test.ts +213 -0
- package/src/ipc/__tests__/browser-ipc.test.ts +339 -0
- package/src/ipc/__tests__/cache-ipc.test.ts +266 -0
- package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
- package/src/ipc/__tests__/socket-path.test.ts +73 -0
- package/src/ipc/__tests__/task-ipc.test.ts +577 -0
- package/src/ipc/__tests__/ui-request-route.test.ts +495 -0
- package/src/ipc/__tests__/watcher-ipc.test.ts +295 -0
- package/src/ipc/cli-client.ts +152 -0
- package/src/ipc/cli-server.ts +252 -0
- package/src/ipc/gateway-client.ts +180 -0
- package/src/ipc/routes/attachment.ts +114 -0
- package/src/ipc/routes/browser-context.ts +61 -0
- package/src/ipc/routes/browser.ts +96 -0
- package/src/ipc/routes/cache.ts +96 -0
- package/src/ipc/routes/index.ts +21 -0
- package/src/ipc/routes/task-queue.ts +226 -0
- package/src/ipc/routes/task.ts +173 -0
- package/src/ipc/routes/ui-request.ts +50 -0
- package/src/ipc/routes/wake-conversation.ts +19 -0
- package/src/ipc/routes/watcher.ts +203 -0
- package/src/ipc/socket-path.ts +100 -0
- package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
- package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
- package/src/memory/__tests__/conversation-analyze-job.test.ts +233 -0
- package/src/memory/__tests__/conversation-group-migration.test.ts +99 -0
- package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
- package/src/memory/admin.ts +18 -0
- package/src/memory/app-store.ts +1 -1
- package/src/memory/attachments-store.ts +70 -0
- package/src/memory/auto-analysis-enqueue.ts +127 -0
- package/src/memory/auto-analysis-guard.ts +27 -0
- package/src/memory/cleanup-schedule-state.ts +37 -0
- package/src/memory/conversation-analyze-job.ts +74 -0
- package/src/memory/conversation-attention-store.ts +13 -6
- package/src/memory/conversation-crud.ts +199 -0
- package/src/memory/conversation-disk-view.ts +7 -0
- package/src/memory/conversation-group-migration.ts +65 -1
- package/src/memory/conversation-queries.ts +6 -5
- package/src/memory/conversation-title-service.ts +7 -4
- package/src/memory/db-init.ts +8 -0
- package/src/memory/db-maintenance.ts +108 -0
- package/src/memory/db.ts +1 -0
- package/src/memory/embedding-backend.ts +1 -1
- package/src/memory/graph/compaction.ts +299 -0
- package/src/memory/graph/consolidation.ts +4 -4
- package/src/memory/graph/conversation-graph-memory.ts +104 -29
- package/src/memory/graph/extraction.test.ts +295 -2
- package/src/memory/graph/extraction.ts +181 -51
- package/src/memory/graph/graph-search.test.ts +92 -0
- package/src/memory/graph/graph-search.ts +4 -1
- package/src/memory/graph/narrative.ts +2 -2
- package/src/memory/graph/pattern-scan.ts +2 -2
- package/src/memory/graph/retriever.test.ts +459 -0
- package/src/memory/graph/retriever.ts +257 -66
- package/src/memory/graph/scoring.test.ts +186 -0
- package/src/memory/graph/scoring.ts +31 -1
- package/src/memory/graph/store.ts +41 -0
- package/src/memory/graph/tool-handlers.ts +27 -0
- package/src/memory/graph/tools.ts +6 -1
- package/src/memory/group-crud.ts +6 -1
- package/src/memory/indexer.ts +95 -16
- package/src/memory/job-handlers/cleanup.ts +11 -8
- package/src/memory/job-handlers/conversation-starters.ts +39 -30
- package/src/memory/job-handlers/summarization.ts +2 -2
- package/src/memory/job-utils.ts +7 -1
- package/src/memory/jobs/embed-pkb-file.test.ts +168 -0
- package/src/memory/jobs/embed-pkb-file.ts +54 -0
- package/src/memory/jobs-store.ts +106 -5
- package/src/memory/jobs-worker.ts +26 -9
- package/src/memory/llm-usage-store.ts +92 -56
- package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +1 -1
- package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
- package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
- package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
- package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +82 -0
- package/src/memory/migrations/index.ts +7 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/pkb/pkb-index.test.ts +368 -0
- package/src/memory/pkb/pkb-index.ts +255 -0
- package/src/memory/pkb/pkb-reconcile.test.ts +251 -0
- package/src/memory/pkb/pkb-reconcile.ts +148 -0
- package/src/memory/pkb/pkb-search.test.ts +438 -0
- package/src/memory/pkb/pkb-search.ts +137 -0
- package/src/memory/pkb/types.ts +53 -0
- package/src/memory/qdrant-client.ts +122 -1
- package/src/memory/qdrant-manager.ts +43 -16
- package/src/memory/schema/conversations.ts +2 -0
- package/src/memory/schema/oauth.ts +3 -0
- package/src/memory/slack-thread-store.ts +37 -0
- package/src/memory/usage-buckets.ts +396 -0
- package/src/messaging/providers/gmail/adapter.ts +6 -16
- package/src/messaging/providers/gmail/client.ts +79 -6
- package/src/messaging/providers/gmail/types.ts +7 -0
- package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
- package/src/messaging/providers/slack/adapter.ts +155 -38
- package/src/messaging/providers/slack/backfill.test.ts +257 -0
- package/src/messaging/providers/slack/backfill.ts +101 -0
- package/src/messaging/providers/slack/client.ts +16 -0
- package/src/messaging/providers/slack/message-metadata.test.ts +316 -0
- package/src/messaging/providers/slack/message-metadata.ts +123 -0
- package/src/messaging/providers/slack/render-transcript.test.ts +1373 -0
- package/src/messaging/providers/slack/render-transcript.ts +443 -0
- package/src/messaging/providers/slack/types.ts +4 -0
- package/src/messaging/style-analyzer.ts +5 -2
- package/src/notifications/README.md +9 -5
- package/src/notifications/decision-engine.ts +6 -12
- package/src/notifications/preference-extractor.ts +2 -6
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
- package/src/oauth/byo-connection.test.ts +18 -1
- package/src/oauth/byo-connection.ts +3 -1
- package/src/oauth/connect-orchestrator.ts +2 -0
- package/src/oauth/connection-resolver.ts +6 -2
- package/src/oauth/connection.ts +2 -0
- package/src/oauth/oauth-store.ts +10 -0
- package/src/oauth/platform-connection.test.ts +145 -0
- package/src/oauth/platform-connection.ts +62 -31
- package/src/oauth/seed-providers.ts +10 -1
- package/src/permissions/approval-policy.test.ts +948 -0
- package/src/permissions/approval-policy.ts +257 -0
- package/src/permissions/bash-risk-classifier.test.ts +1208 -0
- package/src/permissions/bash-risk-classifier.ts +707 -0
- package/src/permissions/checker.ts +218 -699
- package/src/permissions/command-registry.test.ts +535 -0
- package/src/permissions/command-registry.ts +825 -0
- package/src/permissions/defaults.ts +71 -75
- package/src/permissions/file-risk-classifier.test.ts +535 -0
- package/src/permissions/file-risk-classifier.ts +274 -0
- package/src/permissions/risk-types.ts +205 -0
- package/src/permissions/secret-prompter.ts +53 -2
- package/src/permissions/skill-risk-classifier.test.ts +311 -0
- package/src/permissions/skill-risk-classifier.ts +214 -0
- package/src/permissions/trust-client.ts +52 -25
- package/src/permissions/trust-store-interface.ts +1 -6
- package/src/permissions/trust-store.ts +164 -65
- package/src/permissions/types.ts +23 -14
- package/src/permissions/web-risk-classifier.test.ts +170 -0
- package/src/permissions/web-risk-classifier.ts +89 -0
- package/src/permissions/workspace-policy.ts +1 -13
- package/src/platform/client.test.ts +10 -0
- package/src/platform/client.ts +19 -1
- package/src/platform/sync-identity.ts +129 -0
- package/src/prompts/persona-resolver.ts +127 -3
- package/src/prompts/system-prompt.ts +78 -38
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/SOUL.md +5 -3
- package/src/prompts/templates/channels/slack.md +20 -0
- package/src/prompts/update-bulletin-job.ts +190 -0
- package/src/prompts/user-reference.ts +20 -17
- package/src/providers/__tests__/context-overflow-error.test.ts +328 -0
- package/src/providers/__tests__/provider-env-vars.test.ts +102 -0
- package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
- package/src/providers/__tests__/retry-callsite.test.ts +424 -0
- package/src/providers/anthropic/client.ts +335 -70
- package/src/providers/call-site-routing.ts +71 -0
- package/src/providers/fireworks/client.ts +2 -2
- package/src/providers/gemini/client.ts +74 -3
- package/src/providers/managed-proxy/constants.ts +2 -1
- package/src/providers/model-catalog.ts +502 -28
- package/src/providers/model-intents.ts +8 -8
- package/src/providers/ollama/client.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +530 -0
- package/src/providers/openai/client.ts +25 -440
- package/src/providers/openai/responses-provider.ts +579 -0
- package/src/providers/openrouter/client.ts +168 -4
- package/src/providers/provider-env-vars.ts +56 -0
- package/src/providers/provider-secret-catalog.ts +139 -0
- package/src/providers/provider-send-message.ts +22 -5
- package/src/providers/ratelimit.ts +4 -0
- package/src/providers/registry.ts +21 -10
- package/src/providers/retry.ts +185 -39
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +883 -0
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
- package/src/providers/speech-to-text/deepgram.test.ts +332 -0
- package/src/providers/speech-to-text/deepgram.ts +115 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
- package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
- package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
- package/src/providers/speech-to-text/google-gemini.ts +101 -0
- package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
- package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
- package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
- package/src/providers/speech-to-text/openai-whisper.ts +63 -33
- package/src/providers/speech-to-text/provider-catalog.ts +323 -0
- package/src/providers/speech-to-text/resolve.ts +393 -6
- package/src/providers/speech-to-text/xai-realtime.test.ts +578 -0
- package/src/providers/speech-to-text/xai-realtime.ts +796 -0
- package/src/providers/speech-to-text/xai.test.ts +155 -0
- package/src/providers/speech-to-text/xai.ts +97 -0
- package/src/providers/types.ts +102 -3
- package/src/runtime/AGENTS.md +45 -3
- package/src/runtime/__tests__/agent-wake.test.ts +872 -0
- package/src/runtime/__tests__/interactive-ui.test.ts +673 -0
- package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
- package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
- package/src/runtime/agent-wake.ts +553 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
- package/src/runtime/auth/route-policy.ts +34 -5
- package/src/runtime/auth/token-service.ts +56 -1
- package/src/runtime/btw-sidechain.ts +15 -3
- package/src/runtime/capability-tokens.ts +10 -10
- package/src/runtime/channel-invite-transport.ts +1 -1
- package/src/runtime/channel-invite-transports/email.ts +14 -6
- package/src/runtime/channel-readiness-service.ts +12 -22
- package/src/runtime/channel-reply-delivery.ts +106 -2
- package/src/runtime/chrome-extension-registry.ts +38 -2
- package/src/runtime/decision-token.ts +116 -0
- package/src/runtime/gateway-client.ts +2 -2
- package/src/runtime/http-router.ts +32 -0
- package/src/runtime/http-server.ts +447 -11
- package/src/runtime/http-types.ts +29 -3
- package/src/runtime/interactive-ui.ts +362 -0
- package/src/runtime/invite-instruction-generator.ts +2 -2
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +176 -0
- package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +390 -0
- package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +221 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +1540 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +453 -0
- package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +222 -0
- package/src/runtime/migrations/gcs-signed-url.ts +162 -0
- package/src/runtime/migrations/migration-transport.ts +1 -0
- package/src/runtime/migrations/migration-wizard.ts +1 -0
- package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
- package/src/runtime/migrations/vbundle-importer.ts +187 -8
- package/src/runtime/migrations/vbundle-metadata-merge.ts +124 -0
- package/src/runtime/migrations/vbundle-streaming-importer.ts +2522 -0
- package/src/runtime/migrations/vbundle-streaming-validator.ts +244 -0
- package/src/runtime/migrations/vbundle-tar-stream.ts +217 -0
- package/src/runtime/migrations/vbundle-validator.ts +15 -6
- package/src/runtime/pending-interactions.ts +0 -11
- package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +618 -0
- package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +247 -0
- package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +246 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
- package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
- package/src/runtime/routes/app-management-routes.ts +12 -18
- package/src/runtime/routes/approval-prompt-ts-tracker.ts +58 -0
- package/src/runtime/routes/approval-routes.ts +12 -17
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +9 -0
- package/src/runtime/routes/attachment-routes.test.ts +9 -3
- package/src/runtime/routes/attachment-routes.ts +216 -17
- package/src/runtime/routes/avatar-routes.ts +20 -4
- package/src/runtime/routes/backup-routes.ts +519 -0
- package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
- package/src/runtime/routes/btw-routes.ts +9 -10
- package/src/runtime/routes/contact-routes.test.ts +298 -0
- package/src/runtime/routes/contact-routes.ts +132 -5
- package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
- package/src/runtime/routes/conversation-management-routes.ts +133 -0
- package/src/runtime/routes/conversation-routes.ts +487 -160
- package/src/runtime/routes/debug-routes.ts +1 -1
- package/src/runtime/routes/diagnostics-routes.ts +6 -4
- package/src/runtime/routes/events-routes.ts +16 -0
- package/src/runtime/routes/filing-routes.ts +93 -0
- package/src/runtime/routes/guardian-approval-interception.ts +33 -3
- package/src/runtime/routes/guardian-approval-prompt.ts +13 -3
- package/src/runtime/routes/home-feed-routes.ts +452 -0
- package/src/runtime/routes/home-state-routes.ts +138 -0
- package/src/runtime/routes/host-browser-routes.ts +3 -14
- package/src/runtime/routes/identity-intro-cache.ts +7 -3
- package/src/runtime/routes/identity-routes.ts +3 -17
- package/src/runtime/routes/inbound-message-handler.ts +912 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +113 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +129 -6
- package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
- package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
- package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
- package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
- package/src/runtime/routes/integrations/slack/channel.ts +36 -6
- package/src/runtime/routes/integrations/slack/share.ts +45 -7
- package/src/runtime/routes/llm-context-normalization.ts +325 -0
- package/src/runtime/routes/memory-item-routes.test.ts +3 -2
- package/src/runtime/routes/migration-routes.ts +722 -91
- package/src/runtime/routes/settings-routes.ts +26 -7
- package/src/runtime/routes/skills-routes.ts +76 -7
- package/src/runtime/routes/stt-routes.ts +233 -0
- package/src/runtime/routes/surface-action-routes.ts +41 -2
- package/src/runtime/routes/trust-rules-routes.ts +30 -14
- package/src/runtime/routes/tts-routes.ts +108 -24
- package/src/runtime/routes/usage-routes.ts +30 -2
- package/src/runtime/routes/user-route-dispatcher.ts +50 -5
- package/src/runtime/routes/user-routes.ts +13 -1
- package/src/runtime/routes/work-items-routes.test.ts +1 -1
- package/src/runtime/routes/work-items-routes.ts +11 -3
- package/src/runtime/runtime-mode.ts +33 -0
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +426 -0
- package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
- package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
- package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
- package/src/runtime/services/analyze-conversation.ts +340 -0
- package/src/runtime/services/analyze-deps-singleton.ts +32 -0
- package/src/runtime/services/auto-analysis-prompt.ts +55 -0
- package/src/runtime/skill-route-registry.ts +71 -0
- package/src/runtime/slack-block-formatting.ts +437 -10
- package/src/schedule/scheduler.ts +58 -0
- package/src/security/__tests__/provider-key-env-fallback.test.ts +119 -0
- package/src/security/__tests__/untrusted-content.test.ts +109 -0
- package/src/security/oauth2.ts +122 -37
- package/src/security/secure-keys.ts +32 -10
- package/src/security/token-manager.ts +35 -13
- package/src/security/untrusted-content.ts +102 -0
- package/src/sequence/engine.ts +23 -0
- package/src/sequence/types.ts +1 -1
- package/src/skills/catalog-cache.ts +26 -7
- package/src/skills/catalog-files.ts +64 -2
- package/src/skills/catalog-install.ts +31 -3
- package/src/skills/category-inference.ts +122 -0
- package/src/skills/clawhub-files.ts +213 -0
- package/src/skills/clawhub.ts +84 -23
- package/src/skills/skill-cache-store.ts +97 -0
- package/src/skills/skill-file-provider.ts +40 -0
- package/src/skills/skillssh-files.ts +395 -0
- package/src/skills/skillssh-registry.ts +4 -4
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +468 -0
- package/src/stt/__tests__/types.test.ts +89 -0
- package/src/stt/daemon-batch-transcriber.ts +228 -0
- package/src/stt/stt-stream-session.ts +506 -0
- package/src/stt/types.ts +334 -0
- package/src/stt/wav-encoder.test.ts +373 -0
- package/src/stt/wav-encoder.ts +175 -0
- package/src/subagent/manager.ts +79 -27
- package/src/tasks/ephemeral-permissions.ts +9 -4
- package/src/telemetry/usage-telemetry-reporter.ts +27 -5
- package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
- package/src/tools/browser/__tests__/browser-status.test.ts +166 -0
- package/src/tools/browser/browser-execution.ts +1208 -41
- package/src/tools/browser/browser-manager.ts +45 -0
- package/src/tools/browser/browser-mode-constants.ts +12 -0
- package/src/tools/browser/browser-mode.ts +92 -0
- package/src/tools/browser/browser-status-constants.ts +33 -0
- package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
- package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
- package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +205 -17
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
- package/src/tools/browser/cdp-client/errors.ts +15 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
- package/src/tools/browser/cdp-client/factory.ts +797 -87
- package/src/tools/browser/cdp-client/index.ts +16 -2
- package/src/tools/browser/cdp-client/types.ts +68 -0
- package/src/tools/credentials/tool-policy.ts +39 -5
- package/src/tools/credentials/vault.ts +41 -7
- package/src/tools/executor.ts +4 -0
- package/src/tools/filesystem/write.ts +52 -0
- package/src/tools/host-terminal/host-shell.ts +45 -5
- package/src/tools/memory/register.test.ts +185 -0
- package/src/tools/memory/register.ts +3 -1
- package/src/tools/network/web-fetch.ts +25 -12
- package/src/tools/network/web-search.ts +20 -2
- package/src/tools/permission-checker.ts +36 -15
- package/src/tools/policy-context.ts +25 -8
- package/src/tools/registry.ts +55 -3
- package/src/tools/shared/shell-output.ts +3 -1
- package/src/tools/side-effects.ts +0 -9
- package/src/tools/skills/execute.ts +2 -2
- package/src/tools/skills/sandbox-runner.ts +6 -2
- package/src/tools/terminal/backends/native.ts +51 -2
- package/src/tools/terminal/safe-env.ts +11 -2
- package/src/tools/terminal/shell.ts +16 -4
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +29 -3
- package/src/tools/ui-surface/definitions.ts +6 -1
- package/src/tools/verification-control-plane-policy.ts +1 -1
- package/src/tts/__tests__/provider-adapters.test.ts +1061 -0
- package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
- package/src/tts/__tests__/provider-catalog.test.ts +183 -0
- package/src/tts/__tests__/provider-registry.test.ts +90 -0
- package/src/tts/provider-catalog.ts +219 -0
- package/src/tts/provider-registry.ts +73 -0
- package/src/tts/providers/deepgram-provider.ts +219 -0
- package/src/tts/providers/elevenlabs-provider.ts +211 -0
- package/src/tts/providers/fish-audio-provider.ts +183 -0
- package/src/tts/providers/index.ts +44 -0
- package/src/tts/providers/register-builtins.ts +130 -0
- package/src/tts/providers/xai-provider.ts +224 -0
- package/src/tts/synthesize-text.ts +110 -0
- package/src/tts/tts-config-resolver.ts +78 -0
- package/src/tts/types.ts +199 -0
- package/src/types/onboarding-context.ts +7 -0
- package/src/types/tar-stream.d.ts +66 -0
- package/src/util/abort-reasons.ts +58 -0
- package/src/util/device-id.ts +32 -16
- package/src/util/errors.ts +9 -1
- package/src/util/json.ts +17 -0
- package/src/util/platform.ts +56 -12
- package/src/util/pricing.ts +78 -5
- package/src/util/spawn.ts +1 -1
- package/src/util/truncate.ts +4 -2
- package/src/util/unicode.ts +201 -0
- package/src/version.ts +19 -24
- package/src/watcher/engine.ts +24 -1
- package/src/watcher/providers/google-calendar.ts +134 -8
- package/src/watcher/providers/outlook-calendar.ts +42 -2
- package/src/watcher/watcher-store.ts +31 -0
- package/src/workspace/git-service.ts +23 -4
- package/src/workspace/migrations/003-seed-device-id.ts +9 -3
- package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
- package/src/workspace/migrations/029-seed-pkb.ts +1 -1
- package/src/workspace/migrations/031-drop-user-md.ts +317 -0
- package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
- package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
- package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
- package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
- package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
- package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
- package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
- package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +516 -0
- package/src/workspace/migrations/039-drop-legacy-llm-keys.ts +171 -0
- package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +154 -0
- package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +57 -0
- package/src/workspace/migrations/042-fix-backfill-google-gmail-settings-scope.ts +70 -0
- package/src/workspace/migrations/043-release-notes-latex-rendering.ts +75 -0
- package/src/workspace/migrations/044-bump-stale-provider-stream-timeout.ts +51 -0
- package/src/workspace/migrations/045-release-notes-meet-avatar.ts +130 -0
- package/src/workspace/migrations/AGENTS.md +1 -1
- package/src/workspace/migrations/registry.ts +32 -0
- package/src/workspace/provider-commit-message-generator.ts +19 -38
- package/src/workspace/top-level-renderer.ts +13 -1
- package/src/workspace/turn-commit.ts +31 -0
- package/src/__tests__/email-cli.test.ts +0 -297
- package/src/__tests__/email-service-config-fallback.test.ts +0 -102
- package/src/__tests__/outlook-attachments.test.ts +0 -301
- package/src/__tests__/outlook-automation-tools.test.ts +0 -425
- package/src/__tests__/outlook-categories.test.ts +0 -212
- package/src/__tests__/outlook-compose-tools.test.ts +0 -325
- package/src/__tests__/outlook-declutter-tools.test.ts +0 -585
- package/src/__tests__/outlook-follow-up.test.ts +0 -196
- package/src/__tests__/outlook-trash.test.ts +0 -77
- package/src/__tests__/outlook-unsubscribe.test.ts +0 -250
- package/src/__tests__/update-bulletin-format.test.ts +0 -122
- package/src/__tests__/update-bulletin-state.test.ts +0 -135
- package/src/__tests__/update-bulletin.test.ts +0 -277
- package/src/__tests__/update-template-contract.test.ts +0 -29
- package/src/cli/commands/browser-relay.ts +0 -466
- package/src/cli/commands/doctor.ts +0 -341
- package/src/config/bundled-skills/browser/SKILL.md +0 -63
- package/src/config/bundled-skills/browser/TOOLS.json +0 -393
- package/src/config/bundled-skills/browser/tools/browser-click.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-close.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-extract.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-hover.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-navigate.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-press-key.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-scroll.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-select-option.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-type.ts +0 -12
- package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +0 -32
- package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +0 -12
- package/src/config/bundled-skills/chatgpt-import/SKILL.md +0 -27
- package/src/config/bundled-skills/chatgpt-import/TOOLS.json +0 -27
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +0 -378
- package/src/config/bundled-skills/gmail/SKILL.md +0 -175
- package/src/config/bundled-skills/gmail/TOOLS.json +0 -558
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +0 -149
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +0 -112
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +0 -44
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +0 -81
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +0 -108
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +0 -146
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +0 -53
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +0 -220
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +0 -26
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +0 -251
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +0 -29
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +0 -122
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +0 -67
- package/src/config/bundled-skills/gmail/tools/scan-result-store.ts +0 -100
- package/src/config/bundled-skills/gmail/tools/shared.ts +0 -47
- package/src/config/bundled-skills/google-calendar/SKILL.md +0 -51
- package/src/config/bundled-skills/google-calendar/TOOLS.json +0 -226
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +0 -223
- package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +0 -27
- package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +0 -48
- package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +0 -19
- package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +0 -36
- package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +0 -58
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +0 -17
- package/src/config/bundled-skills/google-calendar/types.ts +0 -97
- package/src/config/bundled-skills/outlook/SKILL.md +0 -196
- package/src/config/bundled-skills/outlook/TOOLS.json +0 -530
- package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +0 -85
- package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +0 -77
- package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +0 -84
- package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +0 -94
- package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +0 -49
- package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +0 -237
- package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +0 -161
- package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +0 -32
- package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +0 -272
- package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +0 -29
- package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +0 -129
- package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +0 -87
- package/src/config/bundled-skills/outlook/tools/shared.ts +0 -20
- package/src/config/bundled-skills/outlook-calendar/SKILL.md +0 -51
- package/src/config/bundled-skills/outlook-calendar/TOOLS.json +0 -221
- package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +0 -252
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +0 -53
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +0 -74
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +0 -18
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +0 -46
- package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +0 -36
- package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +0 -17
- package/src/config/bundled-skills/outlook-calendar/types.ts +0 -120
- package/src/config/bundled-skills/slack/SKILL.md +0 -107
- package/src/config/bundled-skills/tasks/SKILL.md +0 -37
- package/src/config/bundled-skills/tasks/TOOLS.json +0 -353
- package/src/config/bundled-skills/tasks/icon.svg +0 -34
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-list.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-run.ts +0 -12
- package/src/config/bundled-skills/tasks/tools/task-save.ts +0 -12
- package/src/config/bundled-skills/watcher/SKILL.md +0 -31
- package/src/config/bundled-skills/watcher/TOOLS.json +0 -167
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +0 -12
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +0 -12
- package/src/email/guardrails.ts +0 -221
- package/src/email/provider.ts +0 -117
- package/src/email/providers/agentmail.ts +0 -361
- package/src/email/providers/index.ts +0 -65
- package/src/email/service.ts +0 -384
- package/src/email/types.ts +0 -126
- package/src/prompts/templates/UPDATES.md +0 -38
- package/src/prompts/templates/USER.md +0 -13
- package/src/prompts/update-bulletin-format.ts +0 -68
- package/src/prompts/update-bulletin-state.ts +0 -58
- package/src/prompts/update-bulletin-template-path.ts +0 -13
- package/src/prompts/update-bulletin.ts +0 -128
- package/src/providers/speech-to-text/types.ts +0 -17
- package/src/runtime/routes/browser-cdp-routes.ts +0 -229
- package/src/shared/provider-env-vars.ts +0 -19
- package/src/tools/watcher/create.ts +0 -86
- package/src/tools/watcher/delete.ts +0 -36
- package/src/tools/watcher/digest.ts +0 -54
- package/src/tools/watcher/list.ts +0 -83
- package/src/tools/watcher/update.ts +0 -71
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// PKB search is mocked so the reminder-hints tests can assert behavior
|
|
4
|
+
// without standing up Qdrant. The mock returns whatever is staged in
|
|
5
|
+
// `pkbSearchResults` / `pkbSearchThrows` for the enclosing test.
|
|
6
|
+
let pkbSearchResults: Array<{
|
|
7
|
+
path: string;
|
|
8
|
+
denseScore: number;
|
|
9
|
+
hybridScore?: number;
|
|
10
|
+
}> = [];
|
|
11
|
+
let pkbSearchThrows: Error | null = null;
|
|
12
|
+
mock.module("../memory/pkb/pkb-search.js", () => ({
|
|
13
|
+
searchPkbFiles: async () => {
|
|
14
|
+
if (pkbSearchThrows) throw pkbSearchThrows;
|
|
15
|
+
return pkbSearchResults;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
2
18
|
|
|
3
19
|
import { _setOverridesForTesting } from "../config/assistant-feature-flags.js";
|
|
4
20
|
import type {
|
|
5
21
|
ChannelCapabilities,
|
|
22
|
+
SlackTranscriptInputRow,
|
|
6
23
|
UnifiedTurnContextOptions,
|
|
7
24
|
} from "../daemon/conversation-runtime-assembly.js";
|
|
8
25
|
import {
|
|
9
26
|
applyRuntimeInjections,
|
|
27
|
+
assembleSlackActiveThreadFocusBlock,
|
|
28
|
+
assembleSlackChronologicalMessages,
|
|
10
29
|
buildSubagentStatusBlock,
|
|
11
30
|
buildUnifiedTurnContextBlock,
|
|
12
31
|
findLastInjectedNowContent,
|
|
@@ -15,11 +34,21 @@ import {
|
|
|
15
34
|
injectNowScratchpad,
|
|
16
35
|
injectSubagentStatus,
|
|
17
36
|
isGroupChatType,
|
|
37
|
+
isSlackChannelConversation,
|
|
38
|
+
loadSlackActiveThreadFocusBlock,
|
|
39
|
+
loadSlackChronologicalMessages,
|
|
18
40
|
resolveChannelCapabilities,
|
|
19
41
|
stripChannelCapabilityContext,
|
|
20
42
|
stripInjectionsForCompaction,
|
|
21
43
|
stripNowScratchpad,
|
|
22
44
|
} from "../daemon/conversation-runtime-assembly.js";
|
|
45
|
+
import { buildPkbReminder } from "../daemon/pkb-reminder-builder.js";
|
|
46
|
+
import type { MessageRow } from "../memory/conversation-crud.js";
|
|
47
|
+
import {
|
|
48
|
+
type SlackMessageMetadata,
|
|
49
|
+
writeSlackMetadata,
|
|
50
|
+
} from "../messaging/providers/slack/message-metadata.js";
|
|
51
|
+
import { parentAlias } from "../messaging/providers/slack/render-transcript.js";
|
|
23
52
|
import type { Message } from "../providers/types.js";
|
|
24
53
|
import type { SubagentState } from "../subagent/types.js";
|
|
25
54
|
|
|
@@ -345,6 +374,56 @@ describe("isGroupChatType", () => {
|
|
|
345
374
|
});
|
|
346
375
|
});
|
|
347
376
|
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// isSlackChannelConversation
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe("isSlackChannelConversation", () => {
|
|
382
|
+
const base = {
|
|
383
|
+
dashboardCapable: false,
|
|
384
|
+
supportsDynamicUi: false,
|
|
385
|
+
supportsVoiceInput: false,
|
|
386
|
+
} as const;
|
|
387
|
+
|
|
388
|
+
test("returns true for Slack channels (chatType === channel)", () => {
|
|
389
|
+
expect(
|
|
390
|
+
isSlackChannelConversation({
|
|
391
|
+
channel: "slack",
|
|
392
|
+
chatType: "channel",
|
|
393
|
+
...base,
|
|
394
|
+
}),
|
|
395
|
+
).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("returns false for Slack DMs regardless of chatType shape", () => {
|
|
399
|
+
// Gateway omits chatType entirely for DM message events, so
|
|
400
|
+
// `isSlackChannelConversation` must return false for both the
|
|
401
|
+
// `chatType === undefined` and `chatType === "im"` shapes.
|
|
402
|
+
expect(isSlackChannelConversation({ channel: "slack", ...base })).toBe(
|
|
403
|
+
false,
|
|
404
|
+
);
|
|
405
|
+
expect(
|
|
406
|
+
isSlackChannelConversation({
|
|
407
|
+
channel: "slack",
|
|
408
|
+
chatType: "im",
|
|
409
|
+
...base,
|
|
410
|
+
}),
|
|
411
|
+
).toBe(false);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("returns false for non-Slack channels", () => {
|
|
415
|
+
expect(
|
|
416
|
+
isSlackChannelConversation({
|
|
417
|
+
channel: "telegram",
|
|
418
|
+
chatType: "channel",
|
|
419
|
+
...base,
|
|
420
|
+
}),
|
|
421
|
+
).toBe(false);
|
|
422
|
+
expect(isSlackChannelConversation(null)).toBe(false);
|
|
423
|
+
expect(isSlackChannelConversation()).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
348
427
|
// ---------------------------------------------------------------------------
|
|
349
428
|
// stripChannelCapabilityContext
|
|
350
429
|
// ---------------------------------------------------------------------------
|
|
@@ -422,7 +501,7 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
|
|
|
422
501
|
},
|
|
423
502
|
];
|
|
424
503
|
|
|
425
|
-
test("injects channel capabilities when provided", () => {
|
|
504
|
+
test("injects channel capabilities when provided", async () => {
|
|
426
505
|
const caps: ChannelCapabilities = {
|
|
427
506
|
channel: "telegram",
|
|
428
507
|
dashboardCapable: false,
|
|
@@ -430,7 +509,7 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
|
|
|
430
509
|
supportsVoiceInput: false,
|
|
431
510
|
};
|
|
432
511
|
|
|
433
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
512
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
434
513
|
channelCapabilities: caps,
|
|
435
514
|
});
|
|
436
515
|
|
|
@@ -442,8 +521,8 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
|
|
|
442
521
|
);
|
|
443
522
|
});
|
|
444
523
|
|
|
445
|
-
test("does not inject when channelCapabilities is null", () => {
|
|
446
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
524
|
+
test("does not inject when channelCapabilities is null", async () => {
|
|
525
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
447
526
|
channelCapabilities: null,
|
|
448
527
|
});
|
|
449
528
|
|
|
@@ -451,14 +530,14 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
|
|
|
451
530
|
expect(result[0].content.length).toBe(1);
|
|
452
531
|
});
|
|
453
532
|
|
|
454
|
-
test("does not inject when channelCapabilities is omitted", () => {
|
|
455
|
-
const result = applyRuntimeInjections(baseMessages, {});
|
|
533
|
+
test("does not inject when channelCapabilities is omitted", async () => {
|
|
534
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {});
|
|
456
535
|
|
|
457
536
|
expect(result.length).toBe(1);
|
|
458
537
|
expect(result[0].content.length).toBe(1);
|
|
459
538
|
});
|
|
460
539
|
|
|
461
|
-
test("combines with other injections", () => {
|
|
540
|
+
test("combines with other injections", async () => {
|
|
462
541
|
const caps: ChannelCapabilities = {
|
|
463
542
|
channel: "telegram",
|
|
464
543
|
dashboardCapable: false,
|
|
@@ -466,7 +545,7 @@ describe("applyRuntimeInjections with channelCapabilities", () => {
|
|
|
466
545
|
supportsVoiceInput: false,
|
|
467
546
|
};
|
|
468
547
|
|
|
469
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
548
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
470
549
|
channelCapabilities: caps,
|
|
471
550
|
});
|
|
472
551
|
|
|
@@ -612,8 +691,11 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
612
691
|
isNonInteractive: true,
|
|
613
692
|
};
|
|
614
693
|
|
|
615
|
-
test("full mode (default) includes all injections", () => {
|
|
616
|
-
const result = applyRuntimeInjections(
|
|
694
|
+
test("full mode (default) includes all injections", async () => {
|
|
695
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
696
|
+
baseMessages,
|
|
697
|
+
fullOptions,
|
|
698
|
+
);
|
|
617
699
|
const allText = result[0].content
|
|
618
700
|
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
619
701
|
.map((b) => b.text)
|
|
@@ -627,11 +709,11 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
627
709
|
expect(allText).toContain("<non_interactive_context>");
|
|
628
710
|
expect(allText).toContain("<NOW.md");
|
|
629
711
|
expect(allText).toContain("<system_reminder>");
|
|
630
|
-
expect(allText).toContain("<
|
|
712
|
+
expect(allText).toContain("<knowledge_base>");
|
|
631
713
|
});
|
|
632
714
|
|
|
633
|
-
test("explicit mode: 'full' behaves the same as default", () => {
|
|
634
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
715
|
+
test("explicit mode: 'full' behaves the same as default", async () => {
|
|
716
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
635
717
|
...fullOptions,
|
|
636
718
|
mode: "full",
|
|
637
719
|
});
|
|
@@ -646,8 +728,8 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
646
728
|
expect(allText).toContain("<NOW.md");
|
|
647
729
|
});
|
|
648
730
|
|
|
649
|
-
test("minimal mode skips high-token optional blocks", () => {
|
|
650
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
731
|
+
test("minimal mode skips high-token optional blocks", async () => {
|
|
732
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
651
733
|
...fullOptions,
|
|
652
734
|
mode: "minimal",
|
|
653
735
|
});
|
|
@@ -662,11 +744,11 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
662
744
|
expect(allText).not.toContain("<active_workspace>");
|
|
663
745
|
expect(allText).not.toContain("<NOW.md");
|
|
664
746
|
expect(allText).not.toContain("<system_reminder>");
|
|
665
|
-
expect(allText).not.toContain("<
|
|
747
|
+
expect(allText).not.toContain("<knowledge_base>");
|
|
666
748
|
});
|
|
667
749
|
|
|
668
|
-
test("minimal mode preserves safety-critical blocks", () => {
|
|
669
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
750
|
+
test("minimal mode preserves safety-critical blocks", async () => {
|
|
751
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
670
752
|
...fullOptions,
|
|
671
753
|
mode: "minimal",
|
|
672
754
|
});
|
|
@@ -681,23 +763,29 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
681
763
|
expect(allText).toContain("<channel_capabilities>");
|
|
682
764
|
});
|
|
683
765
|
|
|
684
|
-
test("minimal mode produces strictly fewer content blocks than full mode", () => {
|
|
685
|
-
const fullResult = applyRuntimeInjections(
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
}
|
|
766
|
+
test("minimal mode produces strictly fewer content blocks than full mode", async () => {
|
|
767
|
+
const { messages: fullResult } = await applyRuntimeInjections(
|
|
768
|
+
baseMessages,
|
|
769
|
+
{
|
|
770
|
+
...fullOptions,
|
|
771
|
+
mode: "full",
|
|
772
|
+
},
|
|
773
|
+
);
|
|
774
|
+
const { messages: minimalResult } = await applyRuntimeInjections(
|
|
775
|
+
baseMessages,
|
|
776
|
+
{
|
|
777
|
+
...fullOptions,
|
|
778
|
+
mode: "minimal",
|
|
779
|
+
},
|
|
780
|
+
);
|
|
693
781
|
|
|
694
782
|
expect(minimalResult[0].content.length).toBeLessThan(
|
|
695
783
|
fullResult[0].content.length,
|
|
696
784
|
);
|
|
697
785
|
});
|
|
698
786
|
|
|
699
|
-
test("minimal mode still preserves the original user message text", () => {
|
|
700
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
787
|
+
test("minimal mode still preserves the original user message text", async () => {
|
|
788
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
701
789
|
...fullOptions,
|
|
702
790
|
mode: "minimal",
|
|
703
791
|
});
|
|
@@ -730,7 +818,7 @@ describe("injectNowScratchpad", () => {
|
|
|
730
818
|
expect(injected.type).toBe("text");
|
|
731
819
|
const text = (injected as { type: "text"; text: string }).text;
|
|
732
820
|
expect(text).toBe(
|
|
733
|
-
"<NOW.md Always keep this up to date>\nCurrent focus: shipping PR 3\n</NOW.md>",
|
|
821
|
+
"<NOW.md Always keep this up to date; keep under 10 lines>\nCurrent focus: shipping PR 3\n</NOW.md>",
|
|
734
822
|
);
|
|
735
823
|
// Original content comes last
|
|
736
824
|
expect((result.content[1] as { type: "text"; text: string }).text).toBe(
|
|
@@ -1015,8 +1103,8 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
|
|
|
1015
1103
|
},
|
|
1016
1104
|
];
|
|
1017
1105
|
|
|
1018
|
-
test("injects NOW.md block when provided", () => {
|
|
1019
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1106
|
+
test("injects NOW.md block when provided", async () => {
|
|
1107
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1020
1108
|
nowScratchpad: "Current focus: fix the bug",
|
|
1021
1109
|
});
|
|
1022
1110
|
|
|
@@ -1028,8 +1116,8 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
|
|
|
1028
1116
|
expect(text).toContain("Current focus: fix the bug");
|
|
1029
1117
|
});
|
|
1030
1118
|
|
|
1031
|
-
test("scratchpad appears before user's original text content", () => {
|
|
1032
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1119
|
+
test("scratchpad appears before user's original text content", async () => {
|
|
1120
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1033
1121
|
nowScratchpad: "scratchpad notes",
|
|
1034
1122
|
});
|
|
1035
1123
|
|
|
@@ -1043,8 +1131,8 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
|
|
|
1043
1131
|
);
|
|
1044
1132
|
});
|
|
1045
1133
|
|
|
1046
|
-
test("does not inject when nowScratchpad is null", () => {
|
|
1047
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1134
|
+
test("does not inject when nowScratchpad is null", async () => {
|
|
1135
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1048
1136
|
nowScratchpad: null,
|
|
1049
1137
|
});
|
|
1050
1138
|
|
|
@@ -1052,15 +1140,15 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
|
|
|
1052
1140
|
expect(result[0].content.length).toBe(1);
|
|
1053
1141
|
});
|
|
1054
1142
|
|
|
1055
|
-
test("does not inject when nowScratchpad is omitted", () => {
|
|
1056
|
-
const result = applyRuntimeInjections(baseMessages, {});
|
|
1143
|
+
test("does not inject when nowScratchpad is omitted", async () => {
|
|
1144
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {});
|
|
1057
1145
|
|
|
1058
1146
|
expect(result.length).toBe(1);
|
|
1059
1147
|
expect(result[0].content.length).toBe(1);
|
|
1060
1148
|
});
|
|
1061
1149
|
|
|
1062
|
-
test("skipped in minimal mode", () => {
|
|
1063
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1150
|
+
test("skipped in minimal mode", async () => {
|
|
1151
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1064
1152
|
nowScratchpad: "Current focus: fix the bug",
|
|
1065
1153
|
mode: "minimal",
|
|
1066
1154
|
});
|
|
@@ -1391,6 +1479,61 @@ describe("buildUnifiedTurnContextBlock", () => {
|
|
|
1391
1479
|
expect(text).toContain("contact_notes: Prefers short replies");
|
|
1392
1480
|
expect(text).toContain("contact_interaction_count: 42");
|
|
1393
1481
|
});
|
|
1482
|
+
|
|
1483
|
+
test("time_since_last_message: emitted right after current_time when provided", () => {
|
|
1484
|
+
const options: UnifiedTurnContextOptions = {
|
|
1485
|
+
timestamp: "2026-04-02T12:00:00Z",
|
|
1486
|
+
interfaceName: "macos",
|
|
1487
|
+
timeSinceLastMessage: "2d ago",
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
const text = buildUnifiedTurnContextBlock(options);
|
|
1491
|
+
const lines = text.split("\n");
|
|
1492
|
+
expect(lines[0]).toBe("<turn_context>");
|
|
1493
|
+
expect(lines[1]).toBe("current_time: 2026-04-02T12:00:00Z");
|
|
1494
|
+
expect(lines[2]).toBe("time_since_last_message: 2d ago");
|
|
1495
|
+
expect(lines[3]).toBe("interface: macos");
|
|
1496
|
+
expect(lines[4]).toBe("</turn_context>");
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
test("time_since_last_message: omitted when null", () => {
|
|
1500
|
+
const options: UnifiedTurnContextOptions = {
|
|
1501
|
+
timestamp: "2026-04-02T12:00:00Z",
|
|
1502
|
+
interfaceName: "macos",
|
|
1503
|
+
timeSinceLastMessage: null,
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const text = buildUnifiedTurnContextBlock(options);
|
|
1507
|
+
expect(text).not.toContain("time_since_last_message");
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
test("time_since_last_message: omitted when field absent (backward-compat)", () => {
|
|
1511
|
+
const options: UnifiedTurnContextOptions = {
|
|
1512
|
+
timestamp: "2026-04-02T12:00:00Z",
|
|
1513
|
+
interfaceName: "macos",
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
const text = buildUnifiedTurnContextBlock(options);
|
|
1517
|
+
expect(text).not.toContain("time_since_last_message");
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
test("time_since_last_message: works on non-guardian path", () => {
|
|
1521
|
+
const options: UnifiedTurnContextOptions = {
|
|
1522
|
+
timestamp: "2026-04-02T12:00:00Z",
|
|
1523
|
+
interfaceName: "telegram",
|
|
1524
|
+
channelName: "telegram",
|
|
1525
|
+
timeSinceLastMessage: "yesterday",
|
|
1526
|
+
actorContext: {
|
|
1527
|
+
sourceChannel: "telegram",
|
|
1528
|
+
canonicalActorIdentity: "user-1",
|
|
1529
|
+
trustClass: "trusted_contact",
|
|
1530
|
+
},
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
const text = buildUnifiedTurnContextBlock(options);
|
|
1534
|
+
expect(text).toContain("time_since_last_message: yesterday");
|
|
1535
|
+
expect(text).toContain("canonical_actor_identity: user-1");
|
|
1536
|
+
});
|
|
1394
1537
|
});
|
|
1395
1538
|
|
|
1396
1539
|
// ---------------------------------------------------------------------------
|
|
@@ -1408,8 +1551,8 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
|
|
|
1408
1551
|
const sampleBlock =
|
|
1409
1552
|
"<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
|
|
1410
1553
|
|
|
1411
|
-
test("injects unifiedTurnContext when provided", () => {
|
|
1412
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1554
|
+
test("injects unifiedTurnContext when provided", async () => {
|
|
1555
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1413
1556
|
unifiedTurnContext: sampleBlock,
|
|
1414
1557
|
});
|
|
1415
1558
|
|
|
@@ -1424,8 +1567,8 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
|
|
|
1424
1567
|
);
|
|
1425
1568
|
});
|
|
1426
1569
|
|
|
1427
|
-
test("does not inject when unifiedTurnContext is null", () => {
|
|
1428
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1570
|
+
test("does not inject when unifiedTurnContext is null", async () => {
|
|
1571
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1429
1572
|
unifiedTurnContext: null,
|
|
1430
1573
|
});
|
|
1431
1574
|
|
|
@@ -1433,15 +1576,15 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
|
|
|
1433
1576
|
expect(result[0].content).toHaveLength(1);
|
|
1434
1577
|
});
|
|
1435
1578
|
|
|
1436
|
-
test("does not inject when unifiedTurnContext is omitted", () => {
|
|
1437
|
-
const result = applyRuntimeInjections(baseMessages, {});
|
|
1579
|
+
test("does not inject when unifiedTurnContext is omitted", async () => {
|
|
1580
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {});
|
|
1438
1581
|
|
|
1439
1582
|
expect(result).toHaveLength(1);
|
|
1440
1583
|
expect(result[0].content).toHaveLength(1);
|
|
1441
1584
|
});
|
|
1442
1585
|
|
|
1443
|
-
test("injected in full mode", () => {
|
|
1444
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1586
|
+
test("injected in full mode", async () => {
|
|
1587
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1445
1588
|
unifiedTurnContext: sampleBlock,
|
|
1446
1589
|
mode: "full",
|
|
1447
1590
|
});
|
|
@@ -1454,8 +1597,8 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
|
|
|
1454
1597
|
expect(allText).toContain("<turn_context>");
|
|
1455
1598
|
});
|
|
1456
1599
|
|
|
1457
|
-
test("injected in minimal mode (no mode guard)", () => {
|
|
1458
|
-
const result = applyRuntimeInjections(baseMessages, {
|
|
1600
|
+
test("injected in minimal mode (no mode guard)", async () => {
|
|
1601
|
+
const { messages: result } = await applyRuntimeInjections(baseMessages, {
|
|
1459
1602
|
unifiedTurnContext: sampleBlock,
|
|
1460
1603
|
mode: "minimal",
|
|
1461
1604
|
});
|
|
@@ -1469,6 +1612,55 @@ describe("applyRuntimeInjections with unifiedTurnContext", () => {
|
|
|
1469
1612
|
});
|
|
1470
1613
|
});
|
|
1471
1614
|
|
|
1615
|
+
// ---------------------------------------------------------------------------
|
|
1616
|
+
// applyRuntimeInjections blocks.unifiedTurnContext
|
|
1617
|
+
// ---------------------------------------------------------------------------
|
|
1618
|
+
|
|
1619
|
+
describe("applyRuntimeInjections blocks.unifiedTurnContext", () => {
|
|
1620
|
+
const userTailMessages: Message[] = [
|
|
1621
|
+
{
|
|
1622
|
+
role: "user",
|
|
1623
|
+
content: [{ type: "text", text: "Hello there" }],
|
|
1624
|
+
},
|
|
1625
|
+
];
|
|
1626
|
+
|
|
1627
|
+
const sampleBlock =
|
|
1628
|
+
"<turn_context>\ncurrent_time: 2026-04-02T12:00:00Z\ninterface: macos\n</turn_context>";
|
|
1629
|
+
|
|
1630
|
+
test("captures unifiedTurnContext when tail is a user message", async () => {
|
|
1631
|
+
const result = await applyRuntimeInjections(userTailMessages, {
|
|
1632
|
+
unifiedTurnContext: sampleBlock,
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
expect(result.blocks.unifiedTurnContext).toBe(sampleBlock);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
test("does not capture when tail is not a user message", async () => {
|
|
1639
|
+
const assistantTailMessages: Message[] = [
|
|
1640
|
+
{
|
|
1641
|
+
role: "user",
|
|
1642
|
+
content: [{ type: "text", text: "Hello" }],
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
role: "assistant",
|
|
1646
|
+
content: [{ type: "text", text: "Hi back" }],
|
|
1647
|
+
},
|
|
1648
|
+
];
|
|
1649
|
+
|
|
1650
|
+
const result = await applyRuntimeInjections(assistantTailMessages, {
|
|
1651
|
+
unifiedTurnContext: sampleBlock,
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
expect(result.blocks.unifiedTurnContext).toBeUndefined();
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
test("does not capture when unifiedTurnContext option is absent", async () => {
|
|
1658
|
+
const result = await applyRuntimeInjections(userTailMessages, {});
|
|
1659
|
+
|
|
1660
|
+
expect(result.blocks.unifiedTurnContext).toBeUndefined();
|
|
1661
|
+
});
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1472
1664
|
// ---------------------------------------------------------------------------
|
|
1473
1665
|
// findLastInjectedNowContent
|
|
1474
1666
|
// ---------------------------------------------------------------------------
|
|
@@ -1668,8 +1860,8 @@ describe("applyRuntimeInjections — subagent status", () => {
|
|
|
1668
1860
|
content: [{ type: "text", text: "user message" }],
|
|
1669
1861
|
};
|
|
1670
1862
|
|
|
1671
|
-
test("includes subagent status in full mode", () => {
|
|
1672
|
-
const result = applyRuntimeInjections([userMsg], {
|
|
1863
|
+
test("includes subagent status in full mode", async () => {
|
|
1864
|
+
const { messages: result } = await applyRuntimeInjections([userMsg], {
|
|
1673
1865
|
subagentStatusBlock:
|
|
1674
1866
|
"<active_subagents>\n- [running] test\n</active_subagents>",
|
|
1675
1867
|
mode: "full",
|
|
@@ -1681,8 +1873,8 @@ describe("applyRuntimeInjections — subagent status", () => {
|
|
|
1681
1873
|
expect(texts.some((t) => t.includes("<active_subagents>"))).toBe(true);
|
|
1682
1874
|
});
|
|
1683
1875
|
|
|
1684
|
-
test("skips subagent status in minimal mode", () => {
|
|
1685
|
-
const result = applyRuntimeInjections([userMsg], {
|
|
1876
|
+
test("skips subagent status in minimal mode", async () => {
|
|
1877
|
+
const { messages: result } = await applyRuntimeInjections([userMsg], {
|
|
1686
1878
|
subagentStatusBlock:
|
|
1687
1879
|
"<active_subagents>\n- [running] test\n</active_subagents>",
|
|
1688
1880
|
mode: "minimal",
|
|
@@ -1717,3 +1909,2546 @@ describe("stripInjectionsForCompaction — subagent status", () => {
|
|
|
1717
1909
|
expect(texts).toContain("hello");
|
|
1718
1910
|
});
|
|
1719
1911
|
});
|
|
1912
|
+
|
|
1913
|
+
// ---------------------------------------------------------------------------
|
|
1914
|
+
// applyRuntimeInjections — PKB relevance hints
|
|
1915
|
+
// ---------------------------------------------------------------------------
|
|
1916
|
+
|
|
1917
|
+
describe("applyRuntimeInjections — PKB relevance hints", () => {
|
|
1918
|
+
const baseMessages: Message[] = [
|
|
1919
|
+
{
|
|
1920
|
+
role: "user",
|
|
1921
|
+
content: [{ type: "text", text: "Tell me about project foo" }],
|
|
1922
|
+
},
|
|
1923
|
+
];
|
|
1924
|
+
|
|
1925
|
+
const FLAT_REMINDER = buildPkbReminder([]);
|
|
1926
|
+
|
|
1927
|
+
// Use a platform-agnostic absolute workspace root so the tests work on
|
|
1928
|
+
// macOS and Linux runners alike. `pkbRoot` sits under `pkbWorkingDir` to
|
|
1929
|
+
// mirror production, where `pkbRoot = join(workingDir, "pkb")`.
|
|
1930
|
+
const pkbWorkingDir = "/tmp/fake-workspace";
|
|
1931
|
+
const pkbRoot = `${pkbWorkingDir}/pkb`;
|
|
1932
|
+
|
|
1933
|
+
function makePkbOptions(overrides: Record<string, unknown> = {}) {
|
|
1934
|
+
return {
|
|
1935
|
+
pkbActive: true,
|
|
1936
|
+
pkbQueryVector: [0.1, 0.2, 0.3],
|
|
1937
|
+
pkbScopeId: "scope-1",
|
|
1938
|
+
pkbConversation: { messages: baseMessages },
|
|
1939
|
+
pkbRoot,
|
|
1940
|
+
pkbWorkingDir,
|
|
1941
|
+
pkbAutoInjectList: [],
|
|
1942
|
+
...overrides,
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function extractTexts(result: Message[]): string[] {
|
|
1947
|
+
const tail = result[result.length - 1];
|
|
1948
|
+
return tail.content
|
|
1949
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
1950
|
+
.map((b) => b.text);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
test("three uninvolved hits → reminder contains all three bullets", async () => {
|
|
1954
|
+
pkbSearchResults = [
|
|
1955
|
+
{ path: "topics/alpha.md", denseScore: 0.9 },
|
|
1956
|
+
{ path: "topics/beta.md", denseScore: 0.8 },
|
|
1957
|
+
{ path: "topics/gamma.md", denseScore: 0.7 },
|
|
1958
|
+
];
|
|
1959
|
+
pkbSearchThrows = null;
|
|
1960
|
+
|
|
1961
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
1962
|
+
baseMessages,
|
|
1963
|
+
makePkbOptions(),
|
|
1964
|
+
);
|
|
1965
|
+
const texts = extractTexts(result);
|
|
1966
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
1967
|
+
expect(reminder).toBeDefined();
|
|
1968
|
+
expect(reminder).toContain("- topics/alpha.md");
|
|
1969
|
+
expect(reminder).toContain("- topics/beta.md");
|
|
1970
|
+
expect(reminder).toContain("- topics/gamma.md");
|
|
1971
|
+
expect(reminder).toContain("these files look especially relevant");
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
test("default auto-injected files (from PKB_DEFAULT_FILES) are filtered out of hints", async () => {
|
|
1975
|
+
// Regression test: when `_autoinject.md` is missing, `readPkbContext`
|
|
1976
|
+
// falls back to PKB_DEFAULT_FILES — so those files ARE in the prompt.
|
|
1977
|
+
// The tracker must know about them too, otherwise the reminder would
|
|
1978
|
+
// redundantly recommend e.g. `essentials.md` even though its contents
|
|
1979
|
+
// are already injected. The agent-loop passes the effective auto-inject
|
|
1980
|
+
// list (via `getPkbAutoInjectList`) to `applyRuntimeInjections`.
|
|
1981
|
+
pkbSearchResults = [
|
|
1982
|
+
{ path: "essentials.md", denseScore: 0.95 },
|
|
1983
|
+
{ path: "topics/alpha.md", denseScore: 0.9 },
|
|
1984
|
+
];
|
|
1985
|
+
pkbSearchThrows = null;
|
|
1986
|
+
|
|
1987
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
1988
|
+
baseMessages,
|
|
1989
|
+
makePkbOptions({
|
|
1990
|
+
// Simulate the fallback the agent-loop now threads through:
|
|
1991
|
+
// `_autoinject.md` is missing, so defaults are injected.
|
|
1992
|
+
pkbAutoInjectList: [
|
|
1993
|
+
"INDEX.md",
|
|
1994
|
+
"essentials.md",
|
|
1995
|
+
"threads.md",
|
|
1996
|
+
"buffer.md",
|
|
1997
|
+
],
|
|
1998
|
+
}),
|
|
1999
|
+
);
|
|
2000
|
+
const texts = extractTexts(result);
|
|
2001
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2002
|
+
expect(reminder).toBeDefined();
|
|
2003
|
+
// essentials.md is a default auto-inject file, so it's already in the
|
|
2004
|
+
// prompt — the reminder must not recommend it again.
|
|
2005
|
+
expect(reminder).not.toContain("- essentials.md");
|
|
2006
|
+
// The other hit, which is not auto-injected, still appears.
|
|
2007
|
+
expect(reminder).toContain("- topics/alpha.md");
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
test("<system_reminder> is injected immediately before the user's typed text (above, not below)", async () => {
|
|
2011
|
+
pkbSearchResults = [];
|
|
2012
|
+
pkbSearchThrows = null;
|
|
2013
|
+
|
|
2014
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2015
|
+
baseMessages,
|
|
2016
|
+
makePkbOptions(),
|
|
2017
|
+
);
|
|
2018
|
+
const texts = extractTexts(result);
|
|
2019
|
+
const reminderIdx = texts.findIndex((t) =>
|
|
2020
|
+
t.startsWith("<system_reminder>"),
|
|
2021
|
+
);
|
|
2022
|
+
const userTextIdx = texts.findIndex(
|
|
2023
|
+
(t) => t === "Tell me about project foo",
|
|
2024
|
+
);
|
|
2025
|
+
expect(reminderIdx).toBeGreaterThanOrEqual(0);
|
|
2026
|
+
expect(userTextIdx).toBeGreaterThanOrEqual(0);
|
|
2027
|
+
expect(reminderIdx).toBeLessThan(userTextIdx);
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
test("in-context paths are filtered out of hints", async () => {
|
|
2031
|
+
pkbSearchResults = [
|
|
2032
|
+
{ path: "topics/alpha.md", denseScore: 0.9 },
|
|
2033
|
+
{ path: "topics/beta.md", denseScore: 0.8 },
|
|
2034
|
+
{ path: "topics/gamma.md", denseScore: 0.7 },
|
|
2035
|
+
];
|
|
2036
|
+
pkbSearchThrows = null;
|
|
2037
|
+
|
|
2038
|
+
// Build a conversation that has already read topics/beta.md via file_read.
|
|
2039
|
+
const conversationWithRead: { messages: Message[] } = {
|
|
2040
|
+
messages: [
|
|
2041
|
+
...baseMessages,
|
|
2042
|
+
{
|
|
2043
|
+
role: "assistant",
|
|
2044
|
+
content: [
|
|
2045
|
+
{
|
|
2046
|
+
type: "tool_use",
|
|
2047
|
+
id: "tu_1",
|
|
2048
|
+
name: "file_read",
|
|
2049
|
+
input: { path: `${pkbRoot}/topics/beta.md` },
|
|
2050
|
+
},
|
|
2051
|
+
],
|
|
2052
|
+
},
|
|
2053
|
+
],
|
|
2054
|
+
};
|
|
2055
|
+
|
|
2056
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2057
|
+
baseMessages,
|
|
2058
|
+
makePkbOptions({ pkbConversation: conversationWithRead }),
|
|
2059
|
+
);
|
|
2060
|
+
const texts = extractTexts(result);
|
|
2061
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2062
|
+
expect(reminder).toBeDefined();
|
|
2063
|
+
expect(reminder).toContain("- topics/alpha.md");
|
|
2064
|
+
expect(reminder).not.toContain("- topics/beta.md");
|
|
2065
|
+
expect(reminder).toContain("- topics/gamma.md");
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
test("empty search → reminder equals flat fallback text byte-for-byte", async () => {
|
|
2069
|
+
pkbSearchResults = [];
|
|
2070
|
+
pkbSearchThrows = null;
|
|
2071
|
+
|
|
2072
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2073
|
+
baseMessages,
|
|
2074
|
+
makePkbOptions(),
|
|
2075
|
+
);
|
|
2076
|
+
const texts = extractTexts(result);
|
|
2077
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2078
|
+
expect(reminder).toBe(FLAT_REMINDER);
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
test("search throws → reminder equals flat fallback text byte-for-byte", async () => {
|
|
2082
|
+
pkbSearchResults = [];
|
|
2083
|
+
pkbSearchThrows = new Error("qdrant exploded");
|
|
2084
|
+
|
|
2085
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2086
|
+
baseMessages,
|
|
2087
|
+
makePkbOptions(),
|
|
2088
|
+
);
|
|
2089
|
+
const texts = extractTexts(result);
|
|
2090
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2091
|
+
expect(reminder).toBe(FLAT_REMINDER);
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
test("missing query vector → flat fallback, search is not attempted", async () => {
|
|
2095
|
+
pkbSearchThrows = new Error("should not be called");
|
|
2096
|
+
|
|
2097
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2098
|
+
baseMessages,
|
|
2099
|
+
makePkbOptions({ pkbQueryVector: undefined }),
|
|
2100
|
+
);
|
|
2101
|
+
const texts = extractTexts(result);
|
|
2102
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2103
|
+
expect(reminder).toBe(FLAT_REMINDER);
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
test("gate uses denseScore — hybridScore alone cannot pass the threshold", async () => {
|
|
2107
|
+
// Simulates the situation where sparse-only matches (which surface via
|
|
2108
|
+
// hybrid's prefetch beyond the dense prefetch limit) pick up RRF hits
|
|
2109
|
+
// but fail the absolute cosine quality bar.
|
|
2110
|
+
pkbSearchResults = [
|
|
2111
|
+
{ path: "topics/alpha.md", denseScore: 0.9, hybridScore: 0.02 },
|
|
2112
|
+
{ path: "topics/noise.md", denseScore: 0.3, hybridScore: 0.03 },
|
|
2113
|
+
];
|
|
2114
|
+
pkbSearchThrows = null;
|
|
2115
|
+
|
|
2116
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2117
|
+
baseMessages,
|
|
2118
|
+
makePkbOptions(),
|
|
2119
|
+
);
|
|
2120
|
+
const texts = extractTexts(result);
|
|
2121
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2122
|
+
expect(reminder).toBeDefined();
|
|
2123
|
+
expect(reminder).toContain("- topics/alpha.md");
|
|
2124
|
+
// Below-threshold dense score is filtered even though its hybrid score
|
|
2125
|
+
// is higher than alpha's.
|
|
2126
|
+
expect(reminder).not.toContain("- topics/noise.md");
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test("ranking follows hybridScore when present — lexical winner surfaces first", async () => {
|
|
2130
|
+
// Sparse re-ranks alpha ahead of beta even though beta's dense cosine is
|
|
2131
|
+
// higher. Both pass the dense threshold, so both survive filtering; the
|
|
2132
|
+
// hybrid score drives ordering among survivors.
|
|
2133
|
+
pkbSearchResults = [
|
|
2134
|
+
{ path: "topics/beta.md", denseScore: 0.9, hybridScore: 0.02 },
|
|
2135
|
+
{ path: "topics/alpha.md", denseScore: 0.75, hybridScore: 0.04 },
|
|
2136
|
+
];
|
|
2137
|
+
pkbSearchThrows = null;
|
|
2138
|
+
|
|
2139
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2140
|
+
baseMessages,
|
|
2141
|
+
makePkbOptions(),
|
|
2142
|
+
);
|
|
2143
|
+
const texts = extractTexts(result);
|
|
2144
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2145
|
+
expect(reminder).toBeDefined();
|
|
2146
|
+
const alphaIdx = reminder!.indexOf("- topics/alpha.md");
|
|
2147
|
+
const betaIdx = reminder!.indexOf("- topics/beta.md");
|
|
2148
|
+
expect(alphaIdx).toBeGreaterThanOrEqual(0);
|
|
2149
|
+
expect(betaIdx).toBeGreaterThanOrEqual(0);
|
|
2150
|
+
expect(alphaIdx).toBeLessThan(betaIdx);
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test("archive/ threshold is stricter (0.7) and applies to denseScore", async () => {
|
|
2154
|
+
pkbSearchResults = [
|
|
2155
|
+
{ path: "topics/alpha.md", denseScore: 0.55 }, // passes 0.5
|
|
2156
|
+
{ path: "archive/old.md", denseScore: 0.55 }, // fails 0.7
|
|
2157
|
+
{ path: "archive/solid.md", denseScore: 0.75 }, // passes 0.7
|
|
2158
|
+
];
|
|
2159
|
+
pkbSearchThrows = null;
|
|
2160
|
+
|
|
2161
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2162
|
+
baseMessages,
|
|
2163
|
+
makePkbOptions(),
|
|
2164
|
+
);
|
|
2165
|
+
const texts = extractTexts(result);
|
|
2166
|
+
const reminder = texts.find((t) => t.startsWith("<system_reminder>"));
|
|
2167
|
+
expect(reminder).toBeDefined();
|
|
2168
|
+
expect(reminder).toContain("- topics/alpha.md");
|
|
2169
|
+
expect(reminder).not.toContain("- archive/old.md");
|
|
2170
|
+
expect(reminder).toContain("- archive/solid.md");
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
test("stripInjectionsForCompaction removes the PKB reminder (flat and hinted)", () => {
|
|
2174
|
+
// Verifies the existing strip pipeline still catches the new reminder
|
|
2175
|
+
// text — it still opens with `<system_reminder>`, which is already in
|
|
2176
|
+
// RUNTIME_INJECTION_PREFIXES.
|
|
2177
|
+
const flatMessage: Message = {
|
|
2178
|
+
role: "user",
|
|
2179
|
+
content: [
|
|
2180
|
+
{ type: "text", text: "hello" },
|
|
2181
|
+
{ type: "text", text: buildPkbReminder([]) },
|
|
2182
|
+
],
|
|
2183
|
+
};
|
|
2184
|
+
const hintedMessage: Message = {
|
|
2185
|
+
role: "user",
|
|
2186
|
+
content: [
|
|
2187
|
+
{ type: "text", text: "hello" },
|
|
2188
|
+
{
|
|
2189
|
+
type: "text",
|
|
2190
|
+
text: buildPkbReminder(["topics/alpha.md", "topics/beta.md"]),
|
|
2191
|
+
},
|
|
2192
|
+
],
|
|
2193
|
+
};
|
|
2194
|
+
|
|
2195
|
+
for (const msg of [flatMessage, hintedMessage]) {
|
|
2196
|
+
const stripped = stripInjectionsForCompaction([msg]);
|
|
2197
|
+
const texts = stripped[0].content
|
|
2198
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2199
|
+
.map((b) => b.text);
|
|
2200
|
+
expect(texts.some((t) => t.startsWith("<system_reminder>"))).toBe(false);
|
|
2201
|
+
expect(texts).toContain("hello");
|
|
2202
|
+
}
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
test("after simulated compaction (strip + rebuild), fresh hints are emitted from post-compaction tool_use blocks", async () => {
|
|
2206
|
+
pkbSearchResults = [
|
|
2207
|
+
{ path: "topics/alpha.md", denseScore: 0.9 },
|
|
2208
|
+
{ path: "topics/beta.md", denseScore: 0.8 },
|
|
2209
|
+
{ path: "topics/gamma.md", denseScore: 0.7 },
|
|
2210
|
+
];
|
|
2211
|
+
pkbSearchThrows = null;
|
|
2212
|
+
|
|
2213
|
+
// Pre-compaction conversation: beta was already read.
|
|
2214
|
+
const preCompactionConversation: { messages: Message[] } = {
|
|
2215
|
+
messages: [
|
|
2216
|
+
...baseMessages,
|
|
2217
|
+
{
|
|
2218
|
+
role: "assistant",
|
|
2219
|
+
content: [
|
|
2220
|
+
{
|
|
2221
|
+
type: "tool_use",
|
|
2222
|
+
id: "tu_pre",
|
|
2223
|
+
name: "file_read",
|
|
2224
|
+
input: { path: `${pkbRoot}/topics/beta.md` },
|
|
2225
|
+
},
|
|
2226
|
+
],
|
|
2227
|
+
},
|
|
2228
|
+
],
|
|
2229
|
+
};
|
|
2230
|
+
|
|
2231
|
+
// 1. Initial injection sees the pre-compaction state — beta should be
|
|
2232
|
+
// filtered out.
|
|
2233
|
+
const { messages: initialResult } = await applyRuntimeInjections(
|
|
2234
|
+
baseMessages,
|
|
2235
|
+
{
|
|
2236
|
+
pkbActive: true,
|
|
2237
|
+
pkbQueryVector: [0.1, 0.2],
|
|
2238
|
+
pkbScopeId: "scope-1",
|
|
2239
|
+
pkbConversation: preCompactionConversation,
|
|
2240
|
+
pkbRoot,
|
|
2241
|
+
pkbWorkingDir,
|
|
2242
|
+
pkbAutoInjectList: [],
|
|
2243
|
+
},
|
|
2244
|
+
);
|
|
2245
|
+
// Unwrap the injected reminder from the last user message.
|
|
2246
|
+
const initialTexts = extractTexts(initialResult);
|
|
2247
|
+
const initialReminder = initialTexts.find(
|
|
2248
|
+
(t) =>
|
|
2249
|
+
t.startsWith("<system_reminder>") &&
|
|
2250
|
+
t.includes("these files look especially relevant"),
|
|
2251
|
+
);
|
|
2252
|
+
expect(initialReminder).toBeDefined();
|
|
2253
|
+
expect(initialReminder).not.toContain("- topics/beta.md");
|
|
2254
|
+
|
|
2255
|
+
// 2. Simulate compaction: strip all runtime injections, rebuild
|
|
2256
|
+
// conversation to reflect the post-compaction state (tool_use blocks
|
|
2257
|
+
// are serialized into summary text, so the only live file_read is the
|
|
2258
|
+
// newly-read gamma).
|
|
2259
|
+
const postCompactionConversation: { messages: Message[] } = {
|
|
2260
|
+
messages: [
|
|
2261
|
+
...baseMessages,
|
|
2262
|
+
{
|
|
2263
|
+
role: "assistant",
|
|
2264
|
+
content: [
|
|
2265
|
+
{
|
|
2266
|
+
type: "tool_use",
|
|
2267
|
+
id: "tu_post",
|
|
2268
|
+
name: "file_read",
|
|
2269
|
+
input: { path: `${pkbRoot}/topics/gamma.md` },
|
|
2270
|
+
},
|
|
2271
|
+
],
|
|
2272
|
+
},
|
|
2273
|
+
],
|
|
2274
|
+
};
|
|
2275
|
+
const postCompactionMessages = stripInjectionsForCompaction(initialResult);
|
|
2276
|
+
|
|
2277
|
+
// 3. Re-inject with the new conversation — gamma (now in context)
|
|
2278
|
+
// should be filtered, and beta (no longer "in context") should appear.
|
|
2279
|
+
const { messages: rebuiltResult } = await applyRuntimeInjections(
|
|
2280
|
+
postCompactionMessages,
|
|
2281
|
+
{
|
|
2282
|
+
pkbActive: true,
|
|
2283
|
+
pkbQueryVector: [0.1, 0.2],
|
|
2284
|
+
pkbScopeId: "scope-1",
|
|
2285
|
+
pkbConversation: postCompactionConversation,
|
|
2286
|
+
pkbRoot,
|
|
2287
|
+
pkbWorkingDir,
|
|
2288
|
+
pkbAutoInjectList: [],
|
|
2289
|
+
},
|
|
2290
|
+
);
|
|
2291
|
+
const rebuiltTexts = extractTexts(rebuiltResult);
|
|
2292
|
+
const rebuiltReminder = rebuiltTexts.find(
|
|
2293
|
+
(t) =>
|
|
2294
|
+
t.startsWith("<system_reminder>") &&
|
|
2295
|
+
t.includes("these files look especially relevant"),
|
|
2296
|
+
);
|
|
2297
|
+
expect(rebuiltReminder).toBeDefined();
|
|
2298
|
+
expect(rebuiltReminder).toContain("- topics/alpha.md");
|
|
2299
|
+
expect(rebuiltReminder).toContain("- topics/beta.md");
|
|
2300
|
+
expect(rebuiltReminder).not.toContain("- topics/gamma.md");
|
|
2301
|
+
});
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
// ---------------------------------------------------------------------------
|
|
2305
|
+
// Slack channel chronological rendering (multi-thread)
|
|
2306
|
+
// ---------------------------------------------------------------------------
|
|
2307
|
+
|
|
2308
|
+
describe("Slack channel chronological rendering — multi-thread", () => {
|
|
2309
|
+
// Slack ts values are seconds-since-epoch with microsecond precision.
|
|
2310
|
+
// Pick a few stable anchors so thread aliases (sha-derived) stay
|
|
2311
|
+
// predictable across the scenarios.
|
|
2312
|
+
const T0 = "1700000000.000001"; // 2023-11-14 22:13:20 UTC — top-level message in thread A
|
|
2313
|
+
const T0_REPLY1 = "1700000005.000001"; // reply in thread A
|
|
2314
|
+
const T0_REPLY2 = "1700000020.000001"; // later reply in thread A
|
|
2315
|
+
const T1 = "1700000010.000002"; // top-level message starting thread B
|
|
2316
|
+
const T2 = "1700000030.000003"; // newer top-level message
|
|
2317
|
+
const ALIAS_T0 = parentAlias(T0);
|
|
2318
|
+
const ALIAS_T1 = parentAlias(T1);
|
|
2319
|
+
const ALIAS_T2 = parentAlias(T2);
|
|
2320
|
+
|
|
2321
|
+
const SLACK_CHANNEL_ID = "C0123CHANNEL";
|
|
2322
|
+
|
|
2323
|
+
function buildSlackMeta(
|
|
2324
|
+
overrides: Partial<SlackMessageMetadata>,
|
|
2325
|
+
): SlackMessageMetadata {
|
|
2326
|
+
return {
|
|
2327
|
+
source: "slack",
|
|
2328
|
+
channelId: SLACK_CHANNEL_ID,
|
|
2329
|
+
channelTs: overrides.channelTs ?? T0,
|
|
2330
|
+
eventKind: "message",
|
|
2331
|
+
...overrides,
|
|
2332
|
+
} as SlackMessageMetadata;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function userRow(opts: {
|
|
2336
|
+
id: string;
|
|
2337
|
+
createdAt: number;
|
|
2338
|
+
text: string;
|
|
2339
|
+
slackMeta?: SlackMessageMetadata;
|
|
2340
|
+
extraOuterMetadata?: Record<string, unknown>;
|
|
2341
|
+
}): MessageRow {
|
|
2342
|
+
const outer: Record<string, unknown> = {
|
|
2343
|
+
...(opts.extraOuterMetadata ?? {}),
|
|
2344
|
+
};
|
|
2345
|
+
if (opts.slackMeta) outer.slackMeta = writeSlackMetadata(opts.slackMeta);
|
|
2346
|
+
return {
|
|
2347
|
+
id: opts.id,
|
|
2348
|
+
conversationId: "conv-1",
|
|
2349
|
+
role: "user",
|
|
2350
|
+
content: JSON.stringify([{ type: "text", text: opts.text }]),
|
|
2351
|
+
createdAt: opts.createdAt,
|
|
2352
|
+
metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function assistantRow(opts: {
|
|
2357
|
+
id: string;
|
|
2358
|
+
createdAt: number;
|
|
2359
|
+
text: string;
|
|
2360
|
+
slackMeta?: SlackMessageMetadata;
|
|
2361
|
+
}): MessageRow {
|
|
2362
|
+
const outer: Record<string, unknown> = {};
|
|
2363
|
+
if (opts.slackMeta) outer.slackMeta = writeSlackMetadata(opts.slackMeta);
|
|
2364
|
+
return {
|
|
2365
|
+
id: opts.id,
|
|
2366
|
+
conversationId: "conv-1",
|
|
2367
|
+
role: "assistant",
|
|
2368
|
+
content: JSON.stringify([{ type: "text", text: opts.text }]),
|
|
2369
|
+
createdAt: opts.createdAt,
|
|
2370
|
+
metadata: Object.keys(outer).length > 0 ? JSON.stringify(outer) : null,
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Helper: assemble a Slack-channel turn through the public assembly path
|
|
2375
|
+
// so the tests exercise the same code the daemon uses.
|
|
2376
|
+
async function runSlackChannelAssembly(
|
|
2377
|
+
rows: MessageRow[],
|
|
2378
|
+
): Promise<Message[]> {
|
|
2379
|
+
const slackChannelCaps: ChannelCapabilities = {
|
|
2380
|
+
channel: "slack",
|
|
2381
|
+
dashboardCapable: false,
|
|
2382
|
+
supportsDynamicUi: false,
|
|
2383
|
+
supportsVoiceInput: false,
|
|
2384
|
+
chatType: "channel",
|
|
2385
|
+
};
|
|
2386
|
+
const slackChronologicalMessages = loadSlackChronologicalMessages(
|
|
2387
|
+
"conv-1",
|
|
2388
|
+
slackChannelCaps,
|
|
2389
|
+
{ loader: () => rows, trustClass: "guardian" },
|
|
2390
|
+
);
|
|
2391
|
+
const lastUserMessage: Message = {
|
|
2392
|
+
role: "user",
|
|
2393
|
+
content: [{ type: "text", text: "current turn" }],
|
|
2394
|
+
};
|
|
2395
|
+
const { messages } = await applyRuntimeInjections([lastUserMessage], {
|
|
2396
|
+
channelCapabilities: slackChannelCaps,
|
|
2397
|
+
slackChronologicalMessages,
|
|
2398
|
+
});
|
|
2399
|
+
return messages;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// Extract the rendered text content from a chronological transcript
|
|
2403
|
+
// result. Each Message produced by the slack-channel render carries
|
|
2404
|
+
// exactly one rendered text block, but the FINAL message also receives
|
|
2405
|
+
// injection blocks (e.g. <channel_capabilities>) prepended by the rest
|
|
2406
|
+
// of `applyRuntimeInjections`. The rendered transcript line is always
|
|
2407
|
+
// the LAST text block of each Message.
|
|
2408
|
+
function texts(messages: Message[]): string[] {
|
|
2409
|
+
return messages.map((m) => {
|
|
2410
|
+
for (let i = m.content.length - 1; i >= 0; i--) {
|
|
2411
|
+
const block = m.content[i];
|
|
2412
|
+
if (block.type === "text") return block.text;
|
|
2413
|
+
}
|
|
2414
|
+
return "";
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// ── Scenario 1: reply in mid-thread ──────────────────────────────────
|
|
2419
|
+
// Alice posts to thread A, Bob replies in thread B (cross-thread). Then
|
|
2420
|
+
// Alice posts a follow-up reply in thread A. Cross-thread visibility:
|
|
2421
|
+
// Bob's mid-thread reply must remain visible alongside thread A.
|
|
2422
|
+
test("scenario 1 — mid-thread reply preserves cross-thread visibility", async () => {
|
|
2423
|
+
const rows: MessageRow[] = [
|
|
2424
|
+
userRow({
|
|
2425
|
+
id: "m1",
|
|
2426
|
+
createdAt: 1700000000_000,
|
|
2427
|
+
text: "Top-level in thread A",
|
|
2428
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2429
|
+
}),
|
|
2430
|
+
userRow({
|
|
2431
|
+
id: "m2",
|
|
2432
|
+
createdAt: 1700000010_000,
|
|
2433
|
+
text: "Top-level starting thread B",
|
|
2434
|
+
slackMeta: buildSlackMeta({ channelTs: T1, displayName: "bob" }),
|
|
2435
|
+
}),
|
|
2436
|
+
userRow({
|
|
2437
|
+
id: "m3",
|
|
2438
|
+
createdAt: 1700000015_000,
|
|
2439
|
+
text: "Reply in thread B (cross-thread relative to A)",
|
|
2440
|
+
slackMeta: buildSlackMeta({
|
|
2441
|
+
channelTs: "1700000015.000001",
|
|
2442
|
+
threadTs: T1,
|
|
2443
|
+
displayName: "bob",
|
|
2444
|
+
}),
|
|
2445
|
+
}),
|
|
2446
|
+
userRow({
|
|
2447
|
+
id: "m4",
|
|
2448
|
+
createdAt: 1700000020_000,
|
|
2449
|
+
text: "Reply in thread A from alice",
|
|
2450
|
+
slackMeta: buildSlackMeta({
|
|
2451
|
+
channelTs: T0_REPLY2,
|
|
2452
|
+
threadTs: T0,
|
|
2453
|
+
displayName: "alice",
|
|
2454
|
+
}),
|
|
2455
|
+
}),
|
|
2456
|
+
];
|
|
2457
|
+
|
|
2458
|
+
const result = await runSlackChannelAssembly(rows);
|
|
2459
|
+
const lines = texts(result);
|
|
2460
|
+
|
|
2461
|
+
expect(lines.length).toBe(4);
|
|
2462
|
+
// Chronological order is preserved.
|
|
2463
|
+
expect(lines[0]).toContain("Top-level in thread A");
|
|
2464
|
+
expect(lines[1]).toContain("Top-level starting thread B");
|
|
2465
|
+
expect(lines[2]).toContain("Reply in thread B");
|
|
2466
|
+
expect(lines[3]).toContain("Reply in thread A");
|
|
2467
|
+
// Cross-thread visibility: thread B's reply is in the rendered output
|
|
2468
|
+
// alongside thread A's reply.
|
|
2469
|
+
expect(lines[2]).toContain(`→ ${ALIAS_T1}`);
|
|
2470
|
+
expect(lines[3]).toContain(`→ ${ALIAS_T0}`);
|
|
2471
|
+
// Sender labels appear.
|
|
2472
|
+
expect(lines[0]).toContain("alice");
|
|
2473
|
+
expect(lines[1]).toContain("bob");
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
// ── Scenario 2: reply to a top-level (starts new thread) ─────────────
|
|
2477
|
+
test("scenario 2 — reply to top-level renders thread tag pointing at parent", async () => {
|
|
2478
|
+
const rows: MessageRow[] = [
|
|
2479
|
+
userRow({
|
|
2480
|
+
id: "m1",
|
|
2481
|
+
createdAt: 1700000000_000,
|
|
2482
|
+
text: "Top-level message",
|
|
2483
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2484
|
+
}),
|
|
2485
|
+
userRow({
|
|
2486
|
+
id: "m2",
|
|
2487
|
+
createdAt: 1700000005_000,
|
|
2488
|
+
text: "Reply that starts a new thread",
|
|
2489
|
+
slackMeta: buildSlackMeta({
|
|
2490
|
+
channelTs: T0_REPLY1,
|
|
2491
|
+
threadTs: T0,
|
|
2492
|
+
displayName: "bob",
|
|
2493
|
+
}),
|
|
2494
|
+
}),
|
|
2495
|
+
];
|
|
2496
|
+
|
|
2497
|
+
const result = await runSlackChannelAssembly(rows);
|
|
2498
|
+
const lines = texts(result);
|
|
2499
|
+
|
|
2500
|
+
expect(lines.length).toBe(2);
|
|
2501
|
+
// Top-level has no thread tag.
|
|
2502
|
+
expect(lines[0]).not.toContain("→ M");
|
|
2503
|
+
// Reply points at the parent's deterministic alias.
|
|
2504
|
+
expect(lines[1]).toContain(`→ ${ALIAS_T0}`);
|
|
2505
|
+
expect(lines[1]).toContain("Reply that starts a new thread");
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
// ── Scenario 3: reply to the most-recent top-level message ───────────
|
|
2509
|
+
test("scenario 3 — reply to last top-level still renders thread tag", async () => {
|
|
2510
|
+
const rows: MessageRow[] = [
|
|
2511
|
+
userRow({
|
|
2512
|
+
id: "m1",
|
|
2513
|
+
createdAt: 1700000000_000,
|
|
2514
|
+
text: "Older top-level",
|
|
2515
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2516
|
+
}),
|
|
2517
|
+
userRow({
|
|
2518
|
+
id: "m2",
|
|
2519
|
+
createdAt: 1700000010_000,
|
|
2520
|
+
text: "Newer top-level",
|
|
2521
|
+
slackMeta: buildSlackMeta({ channelTs: T1, displayName: "alice" }),
|
|
2522
|
+
}),
|
|
2523
|
+
userRow({
|
|
2524
|
+
id: "m3",
|
|
2525
|
+
createdAt: 1700000020_000,
|
|
2526
|
+
text: "Reply to the newer top-level",
|
|
2527
|
+
slackMeta: buildSlackMeta({
|
|
2528
|
+
channelTs: "1700000020.000099",
|
|
2529
|
+
threadTs: T1,
|
|
2530
|
+
displayName: "bob",
|
|
2531
|
+
}),
|
|
2532
|
+
}),
|
|
2533
|
+
];
|
|
2534
|
+
|
|
2535
|
+
const result = await runSlackChannelAssembly(rows);
|
|
2536
|
+
const lines = texts(result);
|
|
2537
|
+
|
|
2538
|
+
expect(lines.length).toBe(3);
|
|
2539
|
+
// The reply targets the newer top-level alias, not the older one.
|
|
2540
|
+
expect(lines[2]).toContain(`→ ${ALIAS_T1}`);
|
|
2541
|
+
expect(lines[2]).not.toContain(`→ ${ALIAS_T0}`);
|
|
2542
|
+
});
|
|
2543
|
+
|
|
2544
|
+
// ── Scenario 4: brand-new top-level message ──────────────────────────
|
|
2545
|
+
test("scenario 4 — new top-level message has no thread tag", async () => {
|
|
2546
|
+
const rows: MessageRow[] = [
|
|
2547
|
+
userRow({
|
|
2548
|
+
id: "m1",
|
|
2549
|
+
createdAt: 1700000000_000,
|
|
2550
|
+
text: "Existing top-level",
|
|
2551
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2552
|
+
}),
|
|
2553
|
+
userRow({
|
|
2554
|
+
id: "m2",
|
|
2555
|
+
createdAt: 1700000030_000,
|
|
2556
|
+
text: "Brand-new top-level message",
|
|
2557
|
+
slackMeta: buildSlackMeta({ channelTs: T2, displayName: "carol" }),
|
|
2558
|
+
}),
|
|
2559
|
+
];
|
|
2560
|
+
|
|
2561
|
+
const result = await runSlackChannelAssembly(rows);
|
|
2562
|
+
const lines = texts(result);
|
|
2563
|
+
|
|
2564
|
+
expect(lines.length).toBe(2);
|
|
2565
|
+
// Both lines render without a thread tag — they are siblings, not
|
|
2566
|
+
// members of the same thread.
|
|
2567
|
+
expect(lines[0]).not.toContain("→ M");
|
|
2568
|
+
expect(lines[1]).not.toContain("→ M");
|
|
2569
|
+
expect(lines[1]).toContain("Brand-new top-level message");
|
|
2570
|
+
// Sanity: each top-level message has a deterministic alias even if
|
|
2571
|
+
// the rendered output doesn't surface it on a top-level line. This
|
|
2572
|
+
// confirms the alias function is reachable for downstream consumers
|
|
2573
|
+
// (focus block in PR 24).
|
|
2574
|
+
expect(ALIAS_T2.length).toBe(7);
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
// ── Scenario 5: legacy mixed with post-upgrade rows ──────────────────
|
|
2578
|
+
// Pre-upgrade rows have no `slackMeta` sub-key. Post-upgrade rows have
|
|
2579
|
+
// it. Both kinds must appear in the rendered transcript with legacy
|
|
2580
|
+
// rows rendered flat (no thread tag) and post-upgrade rows carrying
|
|
2581
|
+
// their thread tags. The renderer's chronological sort must intermix
|
|
2582
|
+
// them on the appropriate timeline.
|
|
2583
|
+
test("scenario 5 — legacy rows mixed with post-upgrade rows render chronologically", async () => {
|
|
2584
|
+
const rows: MessageRow[] = [
|
|
2585
|
+
// Legacy user row with a displayName hint only — no slackMeta.
|
|
2586
|
+
userRow({
|
|
2587
|
+
id: "m1",
|
|
2588
|
+
createdAt: 1699999000_000,
|
|
2589
|
+
text: "Legacy user message",
|
|
2590
|
+
extraOuterMetadata: { displayName: "legacy_alice" },
|
|
2591
|
+
}),
|
|
2592
|
+
// Legacy assistant row.
|
|
2593
|
+
assistantRow({
|
|
2594
|
+
id: "m2",
|
|
2595
|
+
createdAt: 1699999500_000,
|
|
2596
|
+
text: "Legacy assistant reply",
|
|
2597
|
+
}),
|
|
2598
|
+
// Post-upgrade row anchored to a thread parent that has no record
|
|
2599
|
+
// in storage (legacy parent) — the renderer still emits the alias
|
|
2600
|
+
// because the metadata is intact.
|
|
2601
|
+
userRow({
|
|
2602
|
+
id: "m3",
|
|
2603
|
+
createdAt: 1700000000_000,
|
|
2604
|
+
text: "Post-upgrade thread reply",
|
|
2605
|
+
slackMeta: buildSlackMeta({
|
|
2606
|
+
channelTs: T0_REPLY1,
|
|
2607
|
+
threadTs: T0,
|
|
2608
|
+
displayName: "alice",
|
|
2609
|
+
}),
|
|
2610
|
+
}),
|
|
2611
|
+
];
|
|
2612
|
+
|
|
2613
|
+
const result = await runSlackChannelAssembly(rows);
|
|
2614
|
+
const lines = texts(result);
|
|
2615
|
+
|
|
2616
|
+
// All three rows survive the rendering pipeline. Legacy rows are NOT
|
|
2617
|
+
// dropped from context.
|
|
2618
|
+
expect(lines.length).toBe(3);
|
|
2619
|
+
// Chronological order preserved across legacy/post-upgrade rows.
|
|
2620
|
+
expect(lines[0]).toContain("Legacy user message");
|
|
2621
|
+
expect(lines[1]).toContain("Legacy assistant reply");
|
|
2622
|
+
expect(lines[2]).toContain("Post-upgrade thread reply");
|
|
2623
|
+
// Legacy rows render flat — no thread tag arrow.
|
|
2624
|
+
expect(lines[0]).not.toContain("→ M");
|
|
2625
|
+
expect(lines[1]).not.toContain("→ M");
|
|
2626
|
+
// Post-upgrade row carries its thread tag.
|
|
2627
|
+
expect(lines[2]).toContain(`→ ${ALIAS_T0}`);
|
|
2628
|
+
// Sender labels: legacy rows carry no structured displayName, and the
|
|
2629
|
+
// role slot already conveys user-vs-assistant identity, so the row
|
|
2630
|
+
// mapper emits `null` senderLabel and the renderer omits the label
|
|
2631
|
+
// entirely. Real Slack usernames are only rendered for post-upgrade
|
|
2632
|
+
// user rows where `slackMeta.displayName` is populated.
|
|
2633
|
+
expect(lines[0]).not.toContain("@user");
|
|
2634
|
+
expect(lines[0]).not.toContain("@assistant");
|
|
2635
|
+
expect(lines[1]).not.toContain("@assistant");
|
|
2636
|
+
expect(lines[1]).not.toContain("@user");
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
// ── Branch isolation: non-Slack channels untouched ───────────────────
|
|
2640
|
+
test("non-slack conversations bypass chronological rendering", async () => {
|
|
2641
|
+
const lastUserMessage: Message = {
|
|
2642
|
+
role: "user",
|
|
2643
|
+
content: [{ type: "text", text: "vellum question" }],
|
|
2644
|
+
};
|
|
2645
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2646
|
+
[lastUserMessage],
|
|
2647
|
+
{
|
|
2648
|
+
channelCapabilities: {
|
|
2649
|
+
channel: "vellum",
|
|
2650
|
+
dashboardCapable: true,
|
|
2651
|
+
supportsDynamicUi: true,
|
|
2652
|
+
supportsVoiceInput: true,
|
|
2653
|
+
},
|
|
2654
|
+
// Even if we accidentally pass a chronological transcript, the
|
|
2655
|
+
// branch must be a no-op for non-slack channels.
|
|
2656
|
+
slackChronologicalMessages: [
|
|
2657
|
+
{
|
|
2658
|
+
role: "user",
|
|
2659
|
+
content: [{ type: "text", text: "should not appear" }],
|
|
2660
|
+
},
|
|
2661
|
+
],
|
|
2662
|
+
},
|
|
2663
|
+
);
|
|
2664
|
+
expect(result.length).toBe(1);
|
|
2665
|
+
const allText = result[0].content
|
|
2666
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2667
|
+
.map((b) => b.text)
|
|
2668
|
+
.join("\n");
|
|
2669
|
+
expect(allText).toContain("vellum question");
|
|
2670
|
+
expect(allText).not.toContain("should not appear");
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
// ── DMs (chatType === "im") use chronological rendering ────────────────
|
|
2674
|
+
// The runtime-assembly hook overrides `runMessages` for any Slack
|
|
2675
|
+
// conversation (channels and DMs alike). DMs render flat (no thread
|
|
2676
|
+
// tags), but they DO swap in the pre-assembled chronological transcript
|
|
2677
|
+
// so the model sees one consistent persisted view.
|
|
2678
|
+
test("slack DMs (chatType im) use chronological rendering", async () => {
|
|
2679
|
+
const lastUserMessage: Message = {
|
|
2680
|
+
role: "user",
|
|
2681
|
+
content: [{ type: "text", text: "DM question" }],
|
|
2682
|
+
};
|
|
2683
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2684
|
+
[lastUserMessage],
|
|
2685
|
+
{
|
|
2686
|
+
channelCapabilities: {
|
|
2687
|
+
channel: "slack",
|
|
2688
|
+
dashboardCapable: false,
|
|
2689
|
+
supportsDynamicUi: false,
|
|
2690
|
+
supportsVoiceInput: false,
|
|
2691
|
+
chatType: "im",
|
|
2692
|
+
},
|
|
2693
|
+
slackChronologicalMessages: [
|
|
2694
|
+
{
|
|
2695
|
+
role: "user",
|
|
2696
|
+
content: [
|
|
2697
|
+
{
|
|
2698
|
+
type: "text",
|
|
2699
|
+
text: "[11/14/23 14:25 @alice]: earlier DM line",
|
|
2700
|
+
},
|
|
2701
|
+
],
|
|
2702
|
+
},
|
|
2703
|
+
{
|
|
2704
|
+
role: "assistant",
|
|
2705
|
+
content: [{ type: "text", text: "[11/14/23 14:26]: prior reply" }],
|
|
2706
|
+
},
|
|
2707
|
+
],
|
|
2708
|
+
},
|
|
2709
|
+
);
|
|
2710
|
+
// The chronological transcript REPLACES the default runMessages, so
|
|
2711
|
+
// the inbound `DM question` text does not appear — only the rendered
|
|
2712
|
+
// transcript lines do (plus any non-Slack injections).
|
|
2713
|
+
const allText = result
|
|
2714
|
+
.flatMap((m) => m.content)
|
|
2715
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2716
|
+
.map((b) => b.text)
|
|
2717
|
+
.join("\n");
|
|
2718
|
+
expect(allText).toContain("earlier DM line");
|
|
2719
|
+
expect(allText).toContain("prior reply");
|
|
2720
|
+
expect(allText).not.toContain("DM question");
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
// ── Memory-injection carry-through on slack replacement ──────────────
|
|
2724
|
+
// `graphMemory.prepareMemory` prepends `<memory __injected>` (and
|
|
2725
|
+
// optional memory-image groups) to the last user message BEFORE the
|
|
2726
|
+
// runtime assembly runs. When the Slack branch replaces `runMessages`
|
|
2727
|
+
// with the chronological transcript, the prepended blocks must be
|
|
2728
|
+
// carried onto the new tail so the model still sees recalled memory.
|
|
2729
|
+
// The final order inside the tail user message is:
|
|
2730
|
+
// channel_capabilities → [carried memory blocks] → slack transcript tail.
|
|
2731
|
+
test("slack replacement preserves prepended memory block", async () => {
|
|
2732
|
+
const slackCaps: ChannelCapabilities = {
|
|
2733
|
+
channel: "slack",
|
|
2734
|
+
dashboardCapable: false,
|
|
2735
|
+
supportsDynamicUi: false,
|
|
2736
|
+
supportsVoiceInput: false,
|
|
2737
|
+
chatType: "im",
|
|
2738
|
+
};
|
|
2739
|
+
const runMessagesWithMemory: Message[] = [
|
|
2740
|
+
{
|
|
2741
|
+
role: "user",
|
|
2742
|
+
content: [
|
|
2743
|
+
{
|
|
2744
|
+
type: "text",
|
|
2745
|
+
text: "<memory __injected>\nrecalled fact about the user\n</memory>",
|
|
2746
|
+
},
|
|
2747
|
+
{ type: "text", text: "hello there" },
|
|
2748
|
+
],
|
|
2749
|
+
},
|
|
2750
|
+
];
|
|
2751
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2752
|
+
runMessagesWithMemory,
|
|
2753
|
+
{
|
|
2754
|
+
channelCapabilities: slackCaps,
|
|
2755
|
+
slackChronologicalMessages: [
|
|
2756
|
+
{
|
|
2757
|
+
role: "user",
|
|
2758
|
+
content: [{ type: "text", text: "[19:55 alice]: hello there" }],
|
|
2759
|
+
},
|
|
2760
|
+
],
|
|
2761
|
+
},
|
|
2762
|
+
);
|
|
2763
|
+
const tail = result[result.length - 1];
|
|
2764
|
+
expect(tail.role).toBe("user");
|
|
2765
|
+
const allText = tail.content
|
|
2766
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2767
|
+
.map((b) => b.text)
|
|
2768
|
+
.join("\n");
|
|
2769
|
+
expect(allText).toContain("<memory __injected>");
|
|
2770
|
+
expect(allText).toContain("recalled fact about the user");
|
|
2771
|
+
expect(allText).toContain("[19:55 alice]: hello there");
|
|
2772
|
+
// Memory block must appear before the Slack transcript tail so the
|
|
2773
|
+
// model sees recalled context ahead of the conversation view.
|
|
2774
|
+
const memoryIdx = allText.indexOf("<memory __injected>");
|
|
2775
|
+
const transcriptIdx = allText.indexOf("[19:55 alice]: hello there");
|
|
2776
|
+
expect(memoryIdx).toBeLessThan(transcriptIdx);
|
|
2777
|
+
// The pre-replacement "hello there" text from the original runMessages
|
|
2778
|
+
// must NOT leak through — only the Slack-rendered line appears.
|
|
2779
|
+
expect(allText.match(/hello there/g)?.length).toBe(1);
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
test("slack replacement preserves memory-image groups + text block", async () => {
|
|
2783
|
+
const slackCaps: ChannelCapabilities = {
|
|
2784
|
+
channel: "slack",
|
|
2785
|
+
dashboardCapable: false,
|
|
2786
|
+
supportsDynamicUi: false,
|
|
2787
|
+
supportsVoiceInput: false,
|
|
2788
|
+
chatType: "im",
|
|
2789
|
+
};
|
|
2790
|
+
const runMessagesWithMemory: Message[] = [
|
|
2791
|
+
{
|
|
2792
|
+
role: "user",
|
|
2793
|
+
content: [
|
|
2794
|
+
{
|
|
2795
|
+
type: "text",
|
|
2796
|
+
text: "<memory_image __injected>\nimage description",
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
type: "image",
|
|
2800
|
+
source: {
|
|
2801
|
+
type: "base64",
|
|
2802
|
+
media_type: "image/png",
|
|
2803
|
+
data: "AAAA",
|
|
2804
|
+
},
|
|
2805
|
+
},
|
|
2806
|
+
{ type: "text", text: "</memory_image>" },
|
|
2807
|
+
{
|
|
2808
|
+
type: "text",
|
|
2809
|
+
text: "<memory __injected>\nrecalled text\n</memory>",
|
|
2810
|
+
},
|
|
2811
|
+
{ type: "text", text: "original turn text" },
|
|
2812
|
+
],
|
|
2813
|
+
},
|
|
2814
|
+
];
|
|
2815
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2816
|
+
runMessagesWithMemory,
|
|
2817
|
+
{
|
|
2818
|
+
channelCapabilities: slackCaps,
|
|
2819
|
+
slackChronologicalMessages: [
|
|
2820
|
+
{
|
|
2821
|
+
role: "user",
|
|
2822
|
+
content: [{ type: "text", text: "[19:55 alice]: transcript line" }],
|
|
2823
|
+
},
|
|
2824
|
+
],
|
|
2825
|
+
},
|
|
2826
|
+
);
|
|
2827
|
+
const tail = result[result.length - 1];
|
|
2828
|
+
expect(tail.role).toBe("user");
|
|
2829
|
+
// The memory-image block is carried through as an `image` content
|
|
2830
|
+
// block; the transcript-only replacement would have none.
|
|
2831
|
+
const imageBlocks = tail.content.filter((b) => b.type === "image");
|
|
2832
|
+
expect(imageBlocks.length).toBe(1);
|
|
2833
|
+
const allText = tail.content
|
|
2834
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2835
|
+
.map((b) => b.text)
|
|
2836
|
+
.join("\n");
|
|
2837
|
+
expect(allText).toContain("<memory_image __injected>");
|
|
2838
|
+
expect(allText).toContain("</memory_image>");
|
|
2839
|
+
expect(allText).toContain("<memory __injected>");
|
|
2840
|
+
expect(allText).toContain("[19:55 alice]: transcript line");
|
|
2841
|
+
// The original turn text (before the Slack replacement) must NOT
|
|
2842
|
+
// leak through — only the memory prefix + transcript tail are kept.
|
|
2843
|
+
expect(allText).not.toContain("original turn text");
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
test("slack replacement is a no-op when the tail has no memory prefix", async () => {
|
|
2847
|
+
const slackCaps: ChannelCapabilities = {
|
|
2848
|
+
channel: "slack",
|
|
2849
|
+
dashboardCapable: false,
|
|
2850
|
+
supportsDynamicUi: false,
|
|
2851
|
+
supportsVoiceInput: false,
|
|
2852
|
+
chatType: "im",
|
|
2853
|
+
};
|
|
2854
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2855
|
+
[{ role: "user", content: [{ type: "text", text: "inbound" }] }],
|
|
2856
|
+
{
|
|
2857
|
+
channelCapabilities: slackCaps,
|
|
2858
|
+
slackChronologicalMessages: [
|
|
2859
|
+
{
|
|
2860
|
+
role: "user",
|
|
2861
|
+
content: [{ type: "text", text: "[19:55 alice]: only transcript" }],
|
|
2862
|
+
},
|
|
2863
|
+
],
|
|
2864
|
+
},
|
|
2865
|
+
);
|
|
2866
|
+
const tail = result[result.length - 1];
|
|
2867
|
+
const allText = tail.content
|
|
2868
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2869
|
+
.map((b) => b.text)
|
|
2870
|
+
.join("\n");
|
|
2871
|
+
expect(allText).not.toContain("<memory __injected>");
|
|
2872
|
+
expect(allText).toContain("[19:55 alice]: only transcript");
|
|
2873
|
+
});
|
|
2874
|
+
|
|
2875
|
+
// ── transport_hints suppression for slack channels ────────────────────
|
|
2876
|
+
test("slack channel conversations skip <transport_hints> injection", async () => {
|
|
2877
|
+
const slackChannelCaps: ChannelCapabilities = {
|
|
2878
|
+
channel: "slack",
|
|
2879
|
+
dashboardCapable: false,
|
|
2880
|
+
supportsDynamicUi: false,
|
|
2881
|
+
supportsVoiceInput: false,
|
|
2882
|
+
chatType: "channel",
|
|
2883
|
+
};
|
|
2884
|
+
const rows: MessageRow[] = [
|
|
2885
|
+
userRow({
|
|
2886
|
+
id: "m1",
|
|
2887
|
+
createdAt: 1700000000_000,
|
|
2888
|
+
text: "Original message",
|
|
2889
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2890
|
+
}),
|
|
2891
|
+
];
|
|
2892
|
+
const slackChronologicalMessages = loadSlackChronologicalMessages(
|
|
2893
|
+
"conv-1",
|
|
2894
|
+
slackChannelCaps,
|
|
2895
|
+
{ loader: () => rows, trustClass: "guardian" },
|
|
2896
|
+
);
|
|
2897
|
+
|
|
2898
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2899
|
+
[{ role: "user", content: [{ type: "text", text: "current turn" }] }],
|
|
2900
|
+
{
|
|
2901
|
+
channelCapabilities: slackChannelCaps,
|
|
2902
|
+
slackChronologicalMessages,
|
|
2903
|
+
transportHints: ["thread context: ..."],
|
|
2904
|
+
},
|
|
2905
|
+
);
|
|
2906
|
+
|
|
2907
|
+
const allText = result
|
|
2908
|
+
.flatMap((m) => m.content)
|
|
2909
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2910
|
+
.map((b) => b.text)
|
|
2911
|
+
.join("\n");
|
|
2912
|
+
expect(allText).not.toContain("<transport_hints>");
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
// ── transport_hints suppression for slack DMs ─────────────────────────
|
|
2916
|
+
// Slack DMs assemble context from persisted message rows; defensively
|
|
2917
|
+
// suppress transport hints on the daemon side too so any stale hint
|
|
2918
|
+
// cannot leak into the LLM input.
|
|
2919
|
+
test("slack DM conversations skip <transport_hints> injection", async () => {
|
|
2920
|
+
const slackDmCaps: ChannelCapabilities = {
|
|
2921
|
+
channel: "slack",
|
|
2922
|
+
dashboardCapable: false,
|
|
2923
|
+
supportsDynamicUi: false,
|
|
2924
|
+
supportsVoiceInput: false,
|
|
2925
|
+
chatType: "im",
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2929
|
+
[{ role: "user", content: [{ type: "text", text: "hi DM" }] }],
|
|
2930
|
+
{
|
|
2931
|
+
channelCapabilities: slackDmCaps,
|
|
2932
|
+
transportHints: ["dm context: ..."],
|
|
2933
|
+
},
|
|
2934
|
+
);
|
|
2935
|
+
|
|
2936
|
+
const allText = result
|
|
2937
|
+
.flatMap((m) => m.content)
|
|
2938
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2939
|
+
.map((b) => b.text)
|
|
2940
|
+
.join("\n");
|
|
2941
|
+
expect(allText).not.toContain("<transport_hints>");
|
|
2942
|
+
expect(allText).not.toContain("dm context");
|
|
2943
|
+
});
|
|
2944
|
+
|
|
2945
|
+
// ── transport_hints kept for non-slack channels ───────────────────────
|
|
2946
|
+
test("non-slack conversations still receive <transport_hints>", async () => {
|
|
2947
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
2948
|
+
[{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
2949
|
+
{
|
|
2950
|
+
channelCapabilities: {
|
|
2951
|
+
channel: "telegram",
|
|
2952
|
+
dashboardCapable: false,
|
|
2953
|
+
supportsDynamicUi: false,
|
|
2954
|
+
supportsVoiceInput: false,
|
|
2955
|
+
chatType: "private",
|
|
2956
|
+
},
|
|
2957
|
+
transportHints: ["please answer concisely"],
|
|
2958
|
+
},
|
|
2959
|
+
);
|
|
2960
|
+
const allText = result
|
|
2961
|
+
.flatMap((m) => m.content)
|
|
2962
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
2963
|
+
.map((b) => b.text)
|
|
2964
|
+
.join("\n");
|
|
2965
|
+
expect(allText).toContain("<transport_hints>");
|
|
2966
|
+
expect(allText).toContain("please answer concisely");
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
// ── trust-filter regression for loadSlackChronologicalMessages ───────
|
|
2970
|
+
// For untrusted actors, guardian-scoped rows must be excluded
|
|
2971
|
+
// from the chronological transcript the same way `loadFromDb` filters
|
|
2972
|
+
// them out of the default history.
|
|
2973
|
+
test("loadSlackChronologicalMessages filters guardian-scoped rows for untrusted actors", () => {
|
|
2974
|
+
const caps: ChannelCapabilities = {
|
|
2975
|
+
channel: "slack",
|
|
2976
|
+
dashboardCapable: false,
|
|
2977
|
+
supportsDynamicUi: false,
|
|
2978
|
+
supportsVoiceInput: false,
|
|
2979
|
+
chatType: "channel",
|
|
2980
|
+
};
|
|
2981
|
+
// Row 1 has no provenance → guardian-scoped (filtered out).
|
|
2982
|
+
// Row 2 has provenance.trustClass === "trusted_contact" (kept).
|
|
2983
|
+
const rows: MessageRow[] = [
|
|
2984
|
+
userRow({
|
|
2985
|
+
id: "m1",
|
|
2986
|
+
createdAt: 1700000000_000,
|
|
2987
|
+
text: "guardian-only context",
|
|
2988
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
2989
|
+
}),
|
|
2990
|
+
userRow({
|
|
2991
|
+
id: "m2",
|
|
2992
|
+
createdAt: 1700000010_000,
|
|
2993
|
+
text: "from untrusted actor",
|
|
2994
|
+
slackMeta: buildSlackMeta({ channelTs: T1, displayName: "bob" }),
|
|
2995
|
+
extraOuterMetadata: {
|
|
2996
|
+
provenanceTrustClass: "trusted_contact",
|
|
2997
|
+
},
|
|
2998
|
+
}),
|
|
2999
|
+
];
|
|
3000
|
+
const result = loadSlackChronologicalMessages("conv-1", caps, {
|
|
3001
|
+
loader: () => rows,
|
|
3002
|
+
trustClass: "trusted_contact",
|
|
3003
|
+
});
|
|
3004
|
+
expect(result).not.toBeNull();
|
|
3005
|
+
const allText = result!
|
|
3006
|
+
.flatMap((m) => m.content)
|
|
3007
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3008
|
+
.map((b) => b.text)
|
|
3009
|
+
.join("\n");
|
|
3010
|
+
expect(allText).not.toContain("guardian-only context");
|
|
3011
|
+
expect(allText).toContain("from untrusted actor");
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
// ── loadSlackChronologicalMessages returns null for non-slack channels ─
|
|
3015
|
+
test("loadSlackChronologicalMessages returns null for non-slack channels", () => {
|
|
3016
|
+
const result = loadSlackChronologicalMessages(
|
|
3017
|
+
"conv-1",
|
|
3018
|
+
{
|
|
3019
|
+
channel: "telegram",
|
|
3020
|
+
dashboardCapable: false,
|
|
3021
|
+
supportsDynamicUi: false,
|
|
3022
|
+
supportsVoiceInput: false,
|
|
3023
|
+
chatType: "private",
|
|
3024
|
+
},
|
|
3025
|
+
{ loader: () => [] },
|
|
3026
|
+
);
|
|
3027
|
+
expect(result).toBeNull();
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
3031
|
+
// Active-thread focus block (PR 24)
|
|
3032
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
3033
|
+
//
|
|
3034
|
+
// The focus block is appended (tail) to the FINAL user turn ONLY when
|
|
3035
|
+
// the inbound message lives inside a Slack thread. It surfaces parent +
|
|
3036
|
+
// replies (and reactions targeting them) so the model can orient even
|
|
3037
|
+
// when the channel-wide chronological transcript is long and
|
|
3038
|
+
// interleaved. The block is non-persisted: replays / re-injections strip
|
|
3039
|
+
// any prior `<active_thread>` blocks via `RUNTIME_INJECTION_PREFIXES`.
|
|
3040
|
+
|
|
3041
|
+
// Re-run a Slack-channel turn through the public assembly path with the
|
|
3042
|
+
// active-thread focus block plumbed in (mirrors production wiring in
|
|
3043
|
+
// conversation-agent-loop.ts).
|
|
3044
|
+
async function runSlackChannelAssemblyWithFocus(rows: MessageRow[]): Promise<{
|
|
3045
|
+
messages: Message[];
|
|
3046
|
+
focusBlock: string | null;
|
|
3047
|
+
}> {
|
|
3048
|
+
const slackChannelCaps: ChannelCapabilities = {
|
|
3049
|
+
channel: "slack",
|
|
3050
|
+
dashboardCapable: false,
|
|
3051
|
+
supportsDynamicUi: false,
|
|
3052
|
+
supportsVoiceInput: false,
|
|
3053
|
+
chatType: "channel",
|
|
3054
|
+
};
|
|
3055
|
+
const slackChronologicalMessages = loadSlackChronologicalMessages(
|
|
3056
|
+
"conv-1",
|
|
3057
|
+
slackChannelCaps,
|
|
3058
|
+
{ loader: () => rows, trustClass: "guardian" },
|
|
3059
|
+
);
|
|
3060
|
+
const focusBlock = loadSlackActiveThreadFocusBlock(
|
|
3061
|
+
"conv-1",
|
|
3062
|
+
slackChannelCaps,
|
|
3063
|
+
{ loader: () => rows, trustClass: "guardian" },
|
|
3064
|
+
);
|
|
3065
|
+
const lastUserMessage: Message = {
|
|
3066
|
+
role: "user",
|
|
3067
|
+
content: [{ type: "text", text: "current turn" }],
|
|
3068
|
+
};
|
|
3069
|
+
const { messages } = await applyRuntimeInjections([lastUserMessage], {
|
|
3070
|
+
channelCapabilities: slackChannelCaps,
|
|
3071
|
+
slackChronologicalMessages,
|
|
3072
|
+
slackActiveThreadFocusBlock: focusBlock,
|
|
3073
|
+
});
|
|
3074
|
+
return { messages, focusBlock };
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
test("appends <active_thread> focus block when inbound is a thread reply", async () => {
|
|
3078
|
+
// Channel transcript with two interleaved threads. The latest user row
|
|
3079
|
+
// is a reply in thread A — the focus block must list thread A's parent
|
|
3080
|
+
// and replies, including the new reply, but exclude thread B entirely.
|
|
3081
|
+
const rows: MessageRow[] = [
|
|
3082
|
+
userRow({
|
|
3083
|
+
id: "m1",
|
|
3084
|
+
createdAt: 1700000000_000,
|
|
3085
|
+
text: "Top-level in thread A",
|
|
3086
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
3087
|
+
}),
|
|
3088
|
+
userRow({
|
|
3089
|
+
id: "m2",
|
|
3090
|
+
createdAt: 1700000010_000,
|
|
3091
|
+
text: "Top-level in thread B",
|
|
3092
|
+
slackMeta: buildSlackMeta({ channelTs: T1, displayName: "bob" }),
|
|
3093
|
+
}),
|
|
3094
|
+
userRow({
|
|
3095
|
+
id: "m3",
|
|
3096
|
+
createdAt: 1700000015_000,
|
|
3097
|
+
text: "Cross-thread reply in B",
|
|
3098
|
+
slackMeta: buildSlackMeta({
|
|
3099
|
+
channelTs: "1700000015.000001",
|
|
3100
|
+
threadTs: T1,
|
|
3101
|
+
displayName: "bob",
|
|
3102
|
+
}),
|
|
3103
|
+
}),
|
|
3104
|
+
// Inbound (latest user row): reply in thread A.
|
|
3105
|
+
userRow({
|
|
3106
|
+
id: "m4",
|
|
3107
|
+
createdAt: 1700000020_000,
|
|
3108
|
+
text: "New reply in thread A",
|
|
3109
|
+
slackMeta: buildSlackMeta({
|
|
3110
|
+
channelTs: T0_REPLY2,
|
|
3111
|
+
threadTs: T0,
|
|
3112
|
+
displayName: "alice",
|
|
3113
|
+
}),
|
|
3114
|
+
}),
|
|
3115
|
+
];
|
|
3116
|
+
|
|
3117
|
+
const { messages, focusBlock } =
|
|
3118
|
+
await runSlackChannelAssemblyWithFocus(rows);
|
|
3119
|
+
|
|
3120
|
+
// Block was built and is non-empty.
|
|
3121
|
+
expect(focusBlock).not.toBeNull();
|
|
3122
|
+
expect(focusBlock!).toContain("<active_thread>");
|
|
3123
|
+
expect(focusBlock!).toContain("</active_thread>");
|
|
3124
|
+
// Parent (T0) is included, both by content and via the parent alias.
|
|
3125
|
+
expect(focusBlock!).toContain("Top-level in thread A");
|
|
3126
|
+
// The new reply is included.
|
|
3127
|
+
expect(focusBlock!).toContain("New reply in thread A");
|
|
3128
|
+
expect(focusBlock!).toContain(`→ ${ALIAS_T0}`);
|
|
3129
|
+
// Thread B's content is NOT in the focus block.
|
|
3130
|
+
expect(focusBlock!).not.toContain("Top-level in thread B");
|
|
3131
|
+
expect(focusBlock!).not.toContain("Cross-thread reply in B");
|
|
3132
|
+
expect(focusBlock!).not.toContain(`→ ${ALIAS_T1}`);
|
|
3133
|
+
|
|
3134
|
+
// The focus block is appended to the FINAL user message as a tail
|
|
3135
|
+
// text block — not to any earlier message.
|
|
3136
|
+
const lastMsg = messages[messages.length - 1];
|
|
3137
|
+
expect(lastMsg.role).toBe("user");
|
|
3138
|
+
const lastTexts = lastMsg.content
|
|
3139
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3140
|
+
.map((b) => b.text);
|
|
3141
|
+
expect(lastTexts.some((t) => t.startsWith("<active_thread>"))).toBe(true);
|
|
3142
|
+
|
|
3143
|
+
// Earlier rendered messages do NOT carry the focus block.
|
|
3144
|
+
for (let i = 0; i < messages.length - 1; i++) {
|
|
3145
|
+
const earlierTexts = messages[i].content
|
|
3146
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3147
|
+
.map((b) => b.text);
|
|
3148
|
+
for (const t of earlierTexts) {
|
|
3149
|
+
expect(t).not.toContain("<active_thread>");
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
});
|
|
3153
|
+
|
|
3154
|
+
test("includes reactions on thread messages in the focus block", async () => {
|
|
3155
|
+
// Thread A has a parent + reply; reactions hang off both. The focus
|
|
3156
|
+
// block must list the reactions (rendered by `renderSlackTranscript`'s
|
|
3157
|
+
// existing reaction-line format) so the model sees the engagement.
|
|
3158
|
+
const rows: MessageRow[] = [
|
|
3159
|
+
userRow({
|
|
3160
|
+
id: "m1",
|
|
3161
|
+
createdAt: 1700000000_000,
|
|
3162
|
+
text: "Thread A parent",
|
|
3163
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
3164
|
+
}),
|
|
3165
|
+
// Reaction on the parent.
|
|
3166
|
+
userRow({
|
|
3167
|
+
id: "m2",
|
|
3168
|
+
createdAt: 1700000003_000,
|
|
3169
|
+
text: "[reaction]",
|
|
3170
|
+
slackMeta: buildSlackMeta({
|
|
3171
|
+
channelTs: "1700000003.111111",
|
|
3172
|
+
// Reactions live on the channel timeline, not inside a
|
|
3173
|
+
// particular thread; targetChannelTs is the load-bearing field.
|
|
3174
|
+
eventKind: "reaction",
|
|
3175
|
+
displayName: "carol",
|
|
3176
|
+
reaction: {
|
|
3177
|
+
emoji: "thumbsup",
|
|
3178
|
+
targetChannelTs: T0,
|
|
3179
|
+
op: "added",
|
|
3180
|
+
},
|
|
3181
|
+
}),
|
|
3182
|
+
}),
|
|
3183
|
+
// Reply in thread A (this is the inbound — most recent user row).
|
|
3184
|
+
userRow({
|
|
3185
|
+
id: "m3",
|
|
3186
|
+
createdAt: 1700000010_000,
|
|
3187
|
+
text: "Thread A reply",
|
|
3188
|
+
slackMeta: buildSlackMeta({
|
|
3189
|
+
channelTs: T0_REPLY1,
|
|
3190
|
+
threadTs: T0,
|
|
3191
|
+
displayName: "bob",
|
|
3192
|
+
}),
|
|
3193
|
+
}),
|
|
3194
|
+
// Reaction on the reply (added AFTER the reply, before the assembly).
|
|
3195
|
+
userRow({
|
|
3196
|
+
id: "m4",
|
|
3197
|
+
createdAt: 1700000012_000,
|
|
3198
|
+
text: "[reaction]",
|
|
3199
|
+
slackMeta: buildSlackMeta({
|
|
3200
|
+
channelTs: "1700000012.222222",
|
|
3201
|
+
eventKind: "reaction",
|
|
3202
|
+
displayName: "dave",
|
|
3203
|
+
reaction: {
|
|
3204
|
+
emoji: "eyes",
|
|
3205
|
+
targetChannelTs: T0_REPLY1,
|
|
3206
|
+
op: "added",
|
|
3207
|
+
},
|
|
3208
|
+
}),
|
|
3209
|
+
}),
|
|
3210
|
+
// The actual inbound user row that triggers the focus — a fresh
|
|
3211
|
+
// reply in the same thread (so detectActiveThreadTs picks T0).
|
|
3212
|
+
userRow({
|
|
3213
|
+
id: "m5",
|
|
3214
|
+
createdAt: 1700000020_000,
|
|
3215
|
+
text: "Another reply in thread A",
|
|
3216
|
+
slackMeta: buildSlackMeta({
|
|
3217
|
+
channelTs: T0_REPLY2,
|
|
3218
|
+
threadTs: T0,
|
|
3219
|
+
displayName: "alice",
|
|
3220
|
+
}),
|
|
3221
|
+
}),
|
|
3222
|
+
];
|
|
3223
|
+
|
|
3224
|
+
const { focusBlock } = await runSlackChannelAssemblyWithFocus(rows);
|
|
3225
|
+
expect(focusBlock).not.toBeNull();
|
|
3226
|
+
// Both reactions surface in the block (parent + reply targets).
|
|
3227
|
+
expect(focusBlock!).toContain("reacted");
|
|
3228
|
+
expect(focusBlock!).toContain("thumbsup");
|
|
3229
|
+
expect(focusBlock!).toContain("eyes");
|
|
3230
|
+
// Reactions reference the parent alias for visual grounding.
|
|
3231
|
+
expect(focusBlock!).toContain(ALIAS_T0);
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
test("no focus block when inbound is a top-level message", async () => {
|
|
3235
|
+
// Latest user row is top-level (no threadTs) — focus block must be
|
|
3236
|
+
// null and applyRuntimeInjections must NOT append `<active_thread>`.
|
|
3237
|
+
const rows: MessageRow[] = [
|
|
3238
|
+
userRow({
|
|
3239
|
+
id: "m1",
|
|
3240
|
+
createdAt: 1700000000_000,
|
|
3241
|
+
text: "Earlier top-level",
|
|
3242
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
3243
|
+
}),
|
|
3244
|
+
userRow({
|
|
3245
|
+
id: "m2",
|
|
3246
|
+
createdAt: 1700000030_000,
|
|
3247
|
+
text: "Brand-new top-level (the inbound)",
|
|
3248
|
+
slackMeta: buildSlackMeta({ channelTs: T2, displayName: "carol" }),
|
|
3249
|
+
}),
|
|
3250
|
+
];
|
|
3251
|
+
|
|
3252
|
+
const { messages, focusBlock } =
|
|
3253
|
+
await runSlackChannelAssemblyWithFocus(rows);
|
|
3254
|
+
expect(focusBlock).toBeNull();
|
|
3255
|
+
const allText = messages
|
|
3256
|
+
.flatMap((m) => m.content)
|
|
3257
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3258
|
+
.map((b) => b.text)
|
|
3259
|
+
.join("\n");
|
|
3260
|
+
expect(allText).not.toContain("<active_thread>");
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
test("focus blocks are stripped from prior turns on rebuild (no accumulation)", async () => {
|
|
3264
|
+
// Simulate a multi-turn exchange: turn 1 yields a user message that
|
|
3265
|
+
// already carries an `<active_thread>` block (because the previous
|
|
3266
|
+
// turn's assembly appended it). The compaction-stripping pipeline must
|
|
3267
|
+
// remove the focus block so it does not persist into the next turn's
|
|
3268
|
+
// history.
|
|
3269
|
+
const userMessageWithStaleFocus: Message = {
|
|
3270
|
+
role: "user",
|
|
3271
|
+
content: [
|
|
3272
|
+
{ type: "text", text: "actual user content from prior turn" },
|
|
3273
|
+
{
|
|
3274
|
+
type: "text",
|
|
3275
|
+
text: "<active_thread>\n[11/14/23 14:25 @alice]: old focus\n</active_thread>",
|
|
3276
|
+
},
|
|
3277
|
+
],
|
|
3278
|
+
};
|
|
3279
|
+
const stripped = stripInjectionsForCompaction([userMessageWithStaleFocus]);
|
|
3280
|
+
expect(stripped.length).toBe(1);
|
|
3281
|
+
const remainingTexts = stripped[0].content
|
|
3282
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3283
|
+
.map((b) => b.text);
|
|
3284
|
+
expect(remainingTexts).toContain("actual user content from prior turn");
|
|
3285
|
+
for (const t of remainingTexts) {
|
|
3286
|
+
expect(t).not.toContain("<active_thread>");
|
|
3287
|
+
}
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
test("focus block is dropped when injection is replayed (rebuilds re-derive it)", async () => {
|
|
3291
|
+
// Defensive: the `<active_thread>` block is a per-turn injection. When
|
|
3292
|
+
// overflow recovery / compaction re-runs `applyRuntimeInjections` on
|
|
3293
|
+
// already-injected messages, prior `<active_thread>` blocks must be
|
|
3294
|
+
// stripped so the rebuild's freshly-derived block is the only one
|
|
3295
|
+
// present. We simulate by building a Slack channel turn, then
|
|
3296
|
+
// running the strip pipeline + applying injections again with a
|
|
3297
|
+
// different focus block to confirm no duplication occurs.
|
|
3298
|
+
const rows: MessageRow[] = [
|
|
3299
|
+
userRow({
|
|
3300
|
+
id: "m1",
|
|
3301
|
+
createdAt: 1700000000_000,
|
|
3302
|
+
text: "Thread A parent",
|
|
3303
|
+
slackMeta: buildSlackMeta({ channelTs: T0, displayName: "alice" }),
|
|
3304
|
+
}),
|
|
3305
|
+
userRow({
|
|
3306
|
+
id: "m2",
|
|
3307
|
+
createdAt: 1700000020_000,
|
|
3308
|
+
text: "Reply in thread A",
|
|
3309
|
+
slackMeta: buildSlackMeta({
|
|
3310
|
+
channelTs: T0_REPLY2,
|
|
3311
|
+
threadTs: T0,
|
|
3312
|
+
displayName: "alice",
|
|
3313
|
+
}),
|
|
3314
|
+
}),
|
|
3315
|
+
];
|
|
3316
|
+
|
|
3317
|
+
const { messages: firstPassMessages } =
|
|
3318
|
+
await runSlackChannelAssemblyWithFocus(rows);
|
|
3319
|
+
|
|
3320
|
+
// Strip injected blocks (this is what the overflow / compaction path
|
|
3321
|
+
// does between rebuilds).
|
|
3322
|
+
const stripped = stripInjectionsForCompaction(firstPassMessages);
|
|
3323
|
+
const strippedTexts = stripped
|
|
3324
|
+
.flatMap((m) => m.content)
|
|
3325
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3326
|
+
.map((b) => b.text)
|
|
3327
|
+
.join("\n");
|
|
3328
|
+
expect(strippedTexts).not.toContain("<active_thread>");
|
|
3329
|
+
|
|
3330
|
+
// Re-run injection with a fresh focus block — only ONE
|
|
3331
|
+
// `<active_thread>` block must end up in the result.
|
|
3332
|
+
const slackChannelCaps: ChannelCapabilities = {
|
|
3333
|
+
channel: "slack",
|
|
3334
|
+
dashboardCapable: false,
|
|
3335
|
+
supportsDynamicUi: false,
|
|
3336
|
+
supportsVoiceInput: false,
|
|
3337
|
+
chatType: "channel",
|
|
3338
|
+
};
|
|
3339
|
+
const newFocus = "<active_thread>\nnewly built\n</active_thread>";
|
|
3340
|
+
const { messages: reInjected } = await applyRuntimeInjections(stripped, {
|
|
3341
|
+
channelCapabilities: slackChannelCaps,
|
|
3342
|
+
slackActiveThreadFocusBlock: newFocus,
|
|
3343
|
+
});
|
|
3344
|
+
const reInjectedTexts = reInjected
|
|
3345
|
+
.flatMap((m) => m.content)
|
|
3346
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3347
|
+
.map((b) => b.text);
|
|
3348
|
+
const blockCount = reInjectedTexts.filter((t) =>
|
|
3349
|
+
t.startsWith("<active_thread>"),
|
|
3350
|
+
).length;
|
|
3351
|
+
expect(blockCount).toBe(1);
|
|
3352
|
+
expect(
|
|
3353
|
+
reInjectedTexts.find((t) => t.startsWith("<active_thread>")),
|
|
3354
|
+
).toContain("newly built");
|
|
3355
|
+
});
|
|
3356
|
+
|
|
3357
|
+
test("non-slack conversations ignore slackActiveThreadFocusBlock", async () => {
|
|
3358
|
+
// Defensive: the focus injection is gated on `slackChannel` (i.e.
|
|
3359
|
+
// `isSlackChannelConversation`). Even if a caller mistakenly forwards
|
|
3360
|
+
// a focus block on a non-Slack channel, it must NOT be appended.
|
|
3361
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
3362
|
+
[{ role: "user", content: [{ type: "text", text: "vellum question" }] }],
|
|
3363
|
+
{
|
|
3364
|
+
channelCapabilities: {
|
|
3365
|
+
channel: "vellum",
|
|
3366
|
+
dashboardCapable: true,
|
|
3367
|
+
supportsDynamicUi: true,
|
|
3368
|
+
supportsVoiceInput: true,
|
|
3369
|
+
},
|
|
3370
|
+
slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
|
|
3371
|
+
},
|
|
3372
|
+
);
|
|
3373
|
+
const allText = result
|
|
3374
|
+
.flatMap((m) => m.content)
|
|
3375
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3376
|
+
.map((b) => b.text)
|
|
3377
|
+
.join("\n");
|
|
3378
|
+
expect(allText).not.toContain("<active_thread>");
|
|
3379
|
+
expect(allText).toContain("vellum question");
|
|
3380
|
+
});
|
|
3381
|
+
|
|
3382
|
+
test("slack DMs ignore slackActiveThreadFocusBlock", async () => {
|
|
3383
|
+
// Same as above but for Slack DMs (chatType === "im"). The focus
|
|
3384
|
+
// injection is keyed on `isSlackChannelConversation` which excludes
|
|
3385
|
+
// DMs, so the block must not appear.
|
|
3386
|
+
const { messages: result } = await applyRuntimeInjections(
|
|
3387
|
+
[{ role: "user", content: [{ type: "text", text: "DM question" }] }],
|
|
3388
|
+
{
|
|
3389
|
+
channelCapabilities: {
|
|
3390
|
+
channel: "slack",
|
|
3391
|
+
dashboardCapable: false,
|
|
3392
|
+
supportsDynamicUi: false,
|
|
3393
|
+
supportsVoiceInput: false,
|
|
3394
|
+
chatType: "im",
|
|
3395
|
+
},
|
|
3396
|
+
slackActiveThreadFocusBlock: "<active_thread>\nbogus\n</active_thread>",
|
|
3397
|
+
},
|
|
3398
|
+
);
|
|
3399
|
+
const allText = result
|
|
3400
|
+
.flatMap((m) => m.content)
|
|
3401
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
3402
|
+
.map((b) => b.text)
|
|
3403
|
+
.join("\n");
|
|
3404
|
+
expect(allText).not.toContain("<active_thread>");
|
|
3405
|
+
expect(allText).toContain("DM question");
|
|
3406
|
+
});
|
|
3407
|
+
|
|
3408
|
+
test("loadSlackActiveThreadFocusBlock returns null for non-slack channels", () => {
|
|
3409
|
+
const result = loadSlackActiveThreadFocusBlock(
|
|
3410
|
+
"conv-1",
|
|
3411
|
+
{
|
|
3412
|
+
channel: "telegram",
|
|
3413
|
+
dashboardCapable: false,
|
|
3414
|
+
supportsDynamicUi: false,
|
|
3415
|
+
supportsVoiceInput: false,
|
|
3416
|
+
chatType: "private",
|
|
3417
|
+
},
|
|
3418
|
+
{ loader: () => [] },
|
|
3419
|
+
);
|
|
3420
|
+
expect(result).toBeNull();
|
|
3421
|
+
});
|
|
3422
|
+
|
|
3423
|
+
test("loadSlackActiveThreadFocusBlock returns null for Slack DMs (no threads)", () => {
|
|
3424
|
+
// DMs do not have threads, so the focus block is always a no-op.
|
|
3425
|
+
// The loader short-circuits before invoking the row loader so the
|
|
3426
|
+
// DB read is skipped entirely. Covers both the gateway-omitted
|
|
3427
|
+
// `chatType === undefined` case and the explicit `chatType === "im"`
|
|
3428
|
+
// shape some fixtures still emit.
|
|
3429
|
+
let loaderCalls = 0;
|
|
3430
|
+
const dmCapsWithImType: ChannelCapabilities = {
|
|
3431
|
+
channel: "slack",
|
|
3432
|
+
dashboardCapable: false,
|
|
3433
|
+
supportsDynamicUi: false,
|
|
3434
|
+
supportsVoiceInput: false,
|
|
3435
|
+
chatType: "im",
|
|
3436
|
+
};
|
|
3437
|
+
expect(
|
|
3438
|
+
loadSlackActiveThreadFocusBlock("conv-1", dmCapsWithImType, {
|
|
3439
|
+
loader: () => {
|
|
3440
|
+
loaderCalls += 1;
|
|
3441
|
+
return [];
|
|
3442
|
+
},
|
|
3443
|
+
}),
|
|
3444
|
+
).toBeNull();
|
|
3445
|
+
const dmCapsNoChatType: ChannelCapabilities = {
|
|
3446
|
+
channel: "slack",
|
|
3447
|
+
dashboardCapable: false,
|
|
3448
|
+
supportsDynamicUi: false,
|
|
3449
|
+
supportsVoiceInput: false,
|
|
3450
|
+
};
|
|
3451
|
+
expect(
|
|
3452
|
+
loadSlackActiveThreadFocusBlock("conv-1", dmCapsNoChatType, {
|
|
3453
|
+
loader: () => {
|
|
3454
|
+
loaderCalls += 1;
|
|
3455
|
+
return [];
|
|
3456
|
+
},
|
|
3457
|
+
}),
|
|
3458
|
+
).toBeNull();
|
|
3459
|
+
expect(loaderCalls).toBe(0);
|
|
3460
|
+
});
|
|
3461
|
+
});
|
|
3462
|
+
|
|
3463
|
+
// ---------------------------------------------------------------------------
|
|
3464
|
+
// assembleSlackActiveThreadFocusBlock — pure assembly entrypoint
|
|
3465
|
+
// ---------------------------------------------------------------------------
|
|
3466
|
+
|
|
3467
|
+
describe("assembleSlackActiveThreadFocusBlock", () => {
|
|
3468
|
+
const SLACK_CHANNEL_ID = "C0FOCUS";
|
|
3469
|
+
const PARENT_TS = "1700000000.000001";
|
|
3470
|
+
const REPLY_TS = "1700000010.000002";
|
|
3471
|
+
|
|
3472
|
+
const SLACK_CAPS: ChannelCapabilities = {
|
|
3473
|
+
channel: "slack",
|
|
3474
|
+
dashboardCapable: false,
|
|
3475
|
+
supportsDynamicUi: false,
|
|
3476
|
+
supportsVoiceInput: false,
|
|
3477
|
+
chatType: "channel",
|
|
3478
|
+
};
|
|
3479
|
+
|
|
3480
|
+
function buildMeta(
|
|
3481
|
+
overrides: Partial<SlackMessageMetadata>,
|
|
3482
|
+
): SlackMessageMetadata {
|
|
3483
|
+
return {
|
|
3484
|
+
source: "slack",
|
|
3485
|
+
channelId: SLACK_CHANNEL_ID,
|
|
3486
|
+
channelTs: overrides.channelTs ?? PARENT_TS,
|
|
3487
|
+
eventKind: "message",
|
|
3488
|
+
...overrides,
|
|
3489
|
+
} as SlackMessageMetadata;
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
function envelope(meta: SlackMessageMetadata | null): string {
|
|
3493
|
+
const outer: Record<string, unknown> = {};
|
|
3494
|
+
if (meta) outer.slackMeta = writeSlackMetadata(meta);
|
|
3495
|
+
return JSON.stringify(outer);
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
function buildRow(
|
|
3499
|
+
role: "user" | "assistant",
|
|
3500
|
+
text: string,
|
|
3501
|
+
createdAt: number,
|
|
3502
|
+
meta: SlackMessageMetadata | null,
|
|
3503
|
+
): SlackTranscriptInputRow {
|
|
3504
|
+
return {
|
|
3505
|
+
role,
|
|
3506
|
+
content: JSON.stringify([{ type: "text", text }]),
|
|
3507
|
+
createdAt,
|
|
3508
|
+
metadata: meta ? envelope(meta) : null,
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
test("returns null when channel is not Slack", () => {
|
|
3513
|
+
const result = assembleSlackActiveThreadFocusBlock([], {
|
|
3514
|
+
channel: "telegram",
|
|
3515
|
+
dashboardCapable: false,
|
|
3516
|
+
supportsDynamicUi: false,
|
|
3517
|
+
supportsVoiceInput: false,
|
|
3518
|
+
chatType: "private",
|
|
3519
|
+
});
|
|
3520
|
+
expect(result).toBeNull();
|
|
3521
|
+
});
|
|
3522
|
+
|
|
3523
|
+
test("returns null for Slack DMs (chatType im) regardless of rows", () => {
|
|
3524
|
+
// DMs do not have threads. Even if a caller mistakenly passes thread-
|
|
3525
|
+
// looking metadata, the assembler short-circuits before scanning rows.
|
|
3526
|
+
const dmCaps: ChannelCapabilities = { ...SLACK_CAPS, chatType: "im" };
|
|
3527
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3528
|
+
buildRow(
|
|
3529
|
+
"user",
|
|
3530
|
+
"thread-shaped row in a DM",
|
|
3531
|
+
1_000,
|
|
3532
|
+
buildMeta({
|
|
3533
|
+
channelTs: REPLY_TS,
|
|
3534
|
+
threadTs: PARENT_TS,
|
|
3535
|
+
displayName: "@alice",
|
|
3536
|
+
}),
|
|
3537
|
+
),
|
|
3538
|
+
];
|
|
3539
|
+
expect(assembleSlackActiveThreadFocusBlock(rows, dmCaps)).toBeNull();
|
|
3540
|
+
});
|
|
3541
|
+
|
|
3542
|
+
test("returns null when no rows have slackMeta", () => {
|
|
3543
|
+
const result = assembleSlackActiveThreadFocusBlock(
|
|
3544
|
+
[buildRow("user", "legacy", 1_000, null)],
|
|
3545
|
+
SLACK_CAPS,
|
|
3546
|
+
);
|
|
3547
|
+
expect(result).toBeNull();
|
|
3548
|
+
});
|
|
3549
|
+
|
|
3550
|
+
test("returns null when latest user row is top-level (no threadTs)", () => {
|
|
3551
|
+
// Active thread detection scans newest-to-oldest user rows and stops
|
|
3552
|
+
// at the first one with slackMeta — if it's top-level, no focus
|
|
3553
|
+
// block is built.
|
|
3554
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3555
|
+
buildRow(
|
|
3556
|
+
"user",
|
|
3557
|
+
"older thread reply",
|
|
3558
|
+
1_000,
|
|
3559
|
+
buildMeta({
|
|
3560
|
+
channelTs: REPLY_TS,
|
|
3561
|
+
threadTs: PARENT_TS,
|
|
3562
|
+
displayName: "@alice",
|
|
3563
|
+
}),
|
|
3564
|
+
),
|
|
3565
|
+
buildRow(
|
|
3566
|
+
"user",
|
|
3567
|
+
"fresh top-level",
|
|
3568
|
+
2_000,
|
|
3569
|
+
buildMeta({ channelTs: "1700000099.000001", displayName: "@bob" }),
|
|
3570
|
+
),
|
|
3571
|
+
];
|
|
3572
|
+
const result = assembleSlackActiveThreadFocusBlock(rows, SLACK_CAPS);
|
|
3573
|
+
expect(result).toBeNull();
|
|
3574
|
+
});
|
|
3575
|
+
|
|
3576
|
+
test("collects parent + replies + reactions on the active thread", () => {
|
|
3577
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3578
|
+
// Parent of the active thread.
|
|
3579
|
+
buildRow(
|
|
3580
|
+
"user",
|
|
3581
|
+
"Parent",
|
|
3582
|
+
1_000,
|
|
3583
|
+
buildMeta({ channelTs: PARENT_TS, displayName: "@alice" }),
|
|
3584
|
+
),
|
|
3585
|
+
// Top-level message in a SIBLING thread (must NOT appear in the block).
|
|
3586
|
+
buildRow(
|
|
3587
|
+
"user",
|
|
3588
|
+
"Sibling top-level",
|
|
3589
|
+
1_500,
|
|
3590
|
+
buildMeta({
|
|
3591
|
+
channelTs: "1700000005.999999",
|
|
3592
|
+
displayName: "@bob",
|
|
3593
|
+
}),
|
|
3594
|
+
),
|
|
3595
|
+
// Reaction on parent (must appear).
|
|
3596
|
+
buildRow(
|
|
3597
|
+
"user",
|
|
3598
|
+
"[reaction]",
|
|
3599
|
+
1_800,
|
|
3600
|
+
buildMeta({
|
|
3601
|
+
channelTs: "1700000008.111111",
|
|
3602
|
+
eventKind: "reaction",
|
|
3603
|
+
displayName: "@carol",
|
|
3604
|
+
reaction: {
|
|
3605
|
+
emoji: "tada",
|
|
3606
|
+
targetChannelTs: PARENT_TS,
|
|
3607
|
+
op: "added",
|
|
3608
|
+
},
|
|
3609
|
+
}),
|
|
3610
|
+
),
|
|
3611
|
+
// Inbound: reply in active thread (latest user row).
|
|
3612
|
+
buildRow(
|
|
3613
|
+
"user",
|
|
3614
|
+
"Reply",
|
|
3615
|
+
2_000,
|
|
3616
|
+
buildMeta({
|
|
3617
|
+
channelTs: REPLY_TS,
|
|
3618
|
+
threadTs: PARENT_TS,
|
|
3619
|
+
displayName: "@alice",
|
|
3620
|
+
}),
|
|
3621
|
+
),
|
|
3622
|
+
];
|
|
3623
|
+
const result = assembleSlackActiveThreadFocusBlock(rows, SLACK_CAPS);
|
|
3624
|
+
expect(result).not.toBeNull();
|
|
3625
|
+
expect(result!).toContain("<active_thread>");
|
|
3626
|
+
expect(result!).toContain("</active_thread>");
|
|
3627
|
+
expect(result!).toContain("Parent");
|
|
3628
|
+
expect(result!).toContain("Reply");
|
|
3629
|
+
expect(result!).toContain("tada");
|
|
3630
|
+
// Sibling content is NOT pulled in.
|
|
3631
|
+
expect(result!).not.toContain("Sibling top-level");
|
|
3632
|
+
});
|
|
3633
|
+
|
|
3634
|
+
test("preserves speaker attribution when flattening to plain text", () => {
|
|
3635
|
+
// The `<active_thread>` block is rendered as newline-joined plain text,
|
|
3636
|
+
// discarding `Message.role`. Assistant rows and unnamed user rows must
|
|
3637
|
+
// therefore carry an explicit `@assistant` / `@user` label so the model
|
|
3638
|
+
// can still tell turns apart inside the flattened block.
|
|
3639
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3640
|
+
buildRow(
|
|
3641
|
+
"user",
|
|
3642
|
+
"Parent from alice",
|
|
3643
|
+
1_000,
|
|
3644
|
+
buildMeta({ channelTs: PARENT_TS, displayName: "@alice" }),
|
|
3645
|
+
),
|
|
3646
|
+
buildRow(
|
|
3647
|
+
"assistant",
|
|
3648
|
+
"Assistant reply",
|
|
3649
|
+
2_000,
|
|
3650
|
+
buildMeta({
|
|
3651
|
+
channelTs: "1700000005.000001",
|
|
3652
|
+
threadTs: PARENT_TS,
|
|
3653
|
+
}),
|
|
3654
|
+
),
|
|
3655
|
+
buildRow(
|
|
3656
|
+
"user",
|
|
3657
|
+
"Unnamed follow-up",
|
|
3658
|
+
3_000,
|
|
3659
|
+
buildMeta({ channelTs: REPLY_TS, threadTs: PARENT_TS }),
|
|
3660
|
+
),
|
|
3661
|
+
];
|
|
3662
|
+
const result = assembleSlackActiveThreadFocusBlock(rows, SLACK_CAPS);
|
|
3663
|
+
expect(result).not.toBeNull();
|
|
3664
|
+
expect(result!).toContain("@alice");
|
|
3665
|
+
expect(result!).toContain("@assistant");
|
|
3666
|
+
expect(result!).toContain("@user");
|
|
3667
|
+
});
|
|
3668
|
+
|
|
3669
|
+
test("emits a block even when the parent has not been backfilled yet", () => {
|
|
3670
|
+
// The inbound reply detects an `activeThreadTs` from its own
|
|
3671
|
+
// `threadTs`, but the parent (`channelTs === activeThreadTs`) has not
|
|
3672
|
+
// landed in storage yet (backfill pending). The block must still emit
|
|
3673
|
+
// — the reply itself is a member (its own threadTs matches) so the
|
|
3674
|
+
// renderer has at least one line to write.
|
|
3675
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3676
|
+
buildRow(
|
|
3677
|
+
"user",
|
|
3678
|
+
"Lone reply",
|
|
3679
|
+
1_000,
|
|
3680
|
+
buildMeta({
|
|
3681
|
+
channelTs: REPLY_TS,
|
|
3682
|
+
threadTs: PARENT_TS,
|
|
3683
|
+
displayName: "@alice",
|
|
3684
|
+
}),
|
|
3685
|
+
),
|
|
3686
|
+
];
|
|
3687
|
+
const result = assembleSlackActiveThreadFocusBlock(rows, SLACK_CAPS);
|
|
3688
|
+
expect(result).not.toBeNull();
|
|
3689
|
+
expect(result!).toContain("Lone reply");
|
|
3690
|
+
expect(result!).toContain("<active_thread>");
|
|
3691
|
+
});
|
|
3692
|
+
});
|
|
3693
|
+
|
|
3694
|
+
// ---------------------------------------------------------------------------
|
|
3695
|
+
// assembleSlackChronologicalMessages — DM chronological rendering
|
|
3696
|
+
// ---------------------------------------------------------------------------
|
|
3697
|
+
|
|
3698
|
+
describe("assembleSlackChronologicalMessages", () => {
|
|
3699
|
+
// Anchor times mirror the renderer's HH:MM (UTC) output.
|
|
3700
|
+
// 14:25:00 UTC on 2023-11-14 = epoch second 1699971900.
|
|
3701
|
+
const TS_14_25 = "1699971900.000100"; // 14:25 UTC
|
|
3702
|
+
const TS_14_28 = "1699972080.000300"; // 14:28 UTC
|
|
3703
|
+
const MS_14_25 = 1699971900_000;
|
|
3704
|
+
const MS_14_26 = 1699971960_000;
|
|
3705
|
+
const MS_14_28 = 1699972080_000;
|
|
3706
|
+
const MS_14_30 = 1699972200_000;
|
|
3707
|
+
|
|
3708
|
+
const DM_CHANNEL_ID = "D0DM0001";
|
|
3709
|
+
const DM_CAPS: ChannelCapabilities = {
|
|
3710
|
+
channel: "slack",
|
|
3711
|
+
dashboardCapable: false,
|
|
3712
|
+
supportsDynamicUi: false,
|
|
3713
|
+
supportsVoiceInput: false,
|
|
3714
|
+
chatType: "im",
|
|
3715
|
+
};
|
|
3716
|
+
|
|
3717
|
+
/**
|
|
3718
|
+
* Build the persisted-row metadata JSON envelope. `slackMeta` is stored as
|
|
3719
|
+
* a JSON string sub-key inside the outer metadata object, mirroring the
|
|
3720
|
+
* production write path in `conversation-messaging.ts`.
|
|
3721
|
+
*/
|
|
3722
|
+
function metadataEnvelope(slackMeta: SlackMessageMetadata | null): string {
|
|
3723
|
+
const envelope: Record<string, unknown> = {
|
|
3724
|
+
userMessageChannel: "slack",
|
|
3725
|
+
assistantMessageChannel: "slack",
|
|
3726
|
+
};
|
|
3727
|
+
if (slackMeta) {
|
|
3728
|
+
envelope.slackMeta = writeSlackMetadata(slackMeta);
|
|
3729
|
+
}
|
|
3730
|
+
return JSON.stringify(envelope);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
/** Build a row that mirrors how `addMessage` persists user/assistant content. */
|
|
3734
|
+
function row(
|
|
3735
|
+
role: "user" | "assistant",
|
|
3736
|
+
text: string,
|
|
3737
|
+
createdAt: number,
|
|
3738
|
+
metadata: string | null,
|
|
3739
|
+
): SlackTranscriptInputRow {
|
|
3740
|
+
return {
|
|
3741
|
+
role,
|
|
3742
|
+
content: JSON.stringify([{ type: "text", text }]),
|
|
3743
|
+
createdAt,
|
|
3744
|
+
metadata,
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
test("returns null when channel is not Slack", () => {
|
|
3749
|
+
const caps: ChannelCapabilities = {
|
|
3750
|
+
channel: "telegram",
|
|
3751
|
+
dashboardCapable: false,
|
|
3752
|
+
supportsDynamicUi: false,
|
|
3753
|
+
supportsVoiceInput: false,
|
|
3754
|
+
chatType: "private",
|
|
3755
|
+
};
|
|
3756
|
+
const result = assembleSlackChronologicalMessages([], caps);
|
|
3757
|
+
expect(result).toBeNull();
|
|
3758
|
+
});
|
|
3759
|
+
|
|
3760
|
+
test("renders for Slack channels (chatType !== 'im')", () => {
|
|
3761
|
+
// The channel branch and the DM branch share this assembler.
|
|
3762
|
+
// `applyRuntimeInjections` swaps in the chronological transcript for
|
|
3763
|
+
// any Slack conversation (channels and DMs alike); the assembler
|
|
3764
|
+
// itself returns rendered messages for any Slack channel.
|
|
3765
|
+
const channelCaps: ChannelCapabilities = {
|
|
3766
|
+
...DM_CAPS,
|
|
3767
|
+
chatType: "channel",
|
|
3768
|
+
};
|
|
3769
|
+
const result = assembleSlackChronologicalMessages([], channelCaps);
|
|
3770
|
+
expect(result).toEqual([]);
|
|
3771
|
+
});
|
|
3772
|
+
|
|
3773
|
+
test("renders when chatType is missing entirely", () => {
|
|
3774
|
+
// The assembler treats a missing chatType as a non-DM Slack channel
|
|
3775
|
+
// (it does not infer DM from absence). Callers that need to
|
|
3776
|
+
// distinguish DMs from channels (e.g. to skip thread-only injections)
|
|
3777
|
+
// can still gate via `isSlackChannelConversation`.
|
|
3778
|
+
const looseCaps: ChannelCapabilities = {
|
|
3779
|
+
channel: "slack",
|
|
3780
|
+
dashboardCapable: false,
|
|
3781
|
+
supportsDynamicUi: false,
|
|
3782
|
+
supportsVoiceInput: false,
|
|
3783
|
+
};
|
|
3784
|
+
const result = assembleSlackChronologicalMessages([], looseCaps);
|
|
3785
|
+
expect(result).toEqual([]);
|
|
3786
|
+
});
|
|
3787
|
+
|
|
3788
|
+
test("DM-only fixture: pure chronological render with no thread tags", () => {
|
|
3789
|
+
// Two-turn DM: user → assistant → user. All rows carry slackMeta but
|
|
3790
|
+
// none have threadTs (DMs never have threadTs). Output must be a flat
|
|
3791
|
+
// chronological transcript with no `→ Mxxxxxx` parent-alias arrows.
|
|
3792
|
+
const userMeta1: SlackMessageMetadata = {
|
|
3793
|
+
source: "slack",
|
|
3794
|
+
channelId: DM_CHANNEL_ID,
|
|
3795
|
+
channelTs: TS_14_25,
|
|
3796
|
+
eventKind: "message",
|
|
3797
|
+
displayName: "@alice",
|
|
3798
|
+
};
|
|
3799
|
+
const userMeta2: SlackMessageMetadata = {
|
|
3800
|
+
source: "slack",
|
|
3801
|
+
channelId: DM_CHANNEL_ID,
|
|
3802
|
+
channelTs: TS_14_28,
|
|
3803
|
+
eventKind: "message",
|
|
3804
|
+
displayName: "@alice",
|
|
3805
|
+
};
|
|
3806
|
+
// Outbound assistant rows in DMs may go through the legacy fallback
|
|
3807
|
+
// when no slackMeta envelope is present at all (e.g. a row written
|
|
3808
|
+
// before the post-send reconciliation lands, or pre-upgrade history).
|
|
3809
|
+
// This fixture pins down the legacy interleave behaviour and matches
|
|
3810
|
+
// how `assembleSlackChronologicalMessages` falls back to chronological
|
|
3811
|
+
// ordering by createdAt for null-slackMeta rows.
|
|
3812
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3813
|
+
row("user", "hi assistant", MS_14_25, metadataEnvelope(userMeta1)),
|
|
3814
|
+
row("assistant", "hi back!", MS_14_26, metadataEnvelope(null)),
|
|
3815
|
+
row("user", "another one", MS_14_28, metadataEnvelope(userMeta2)),
|
|
3816
|
+
];
|
|
3817
|
+
|
|
3818
|
+
const result = assembleSlackChronologicalMessages(rows, DM_CAPS);
|
|
3819
|
+
expect(result).not.toBeNull();
|
|
3820
|
+
expect(result).toEqual([
|
|
3821
|
+
{
|
|
3822
|
+
role: "user",
|
|
3823
|
+
content: [
|
|
3824
|
+
{ type: "text", text: "[11/14/23 14:25 @alice]: hi assistant" },
|
|
3825
|
+
],
|
|
3826
|
+
},
|
|
3827
|
+
{
|
|
3828
|
+
role: "assistant",
|
|
3829
|
+
content: [{ type: "text", text: "[11/14/23 14:26]: hi back!" }],
|
|
3830
|
+
},
|
|
3831
|
+
{
|
|
3832
|
+
role: "user",
|
|
3833
|
+
content: [
|
|
3834
|
+
{ type: "text", text: "[11/14/23 14:28 @alice]: another one" },
|
|
3835
|
+
],
|
|
3836
|
+
},
|
|
3837
|
+
]);
|
|
3838
|
+
// Sanity: no thread-tag arrow ever appears in DM output.
|
|
3839
|
+
for (const msg of result!) {
|
|
3840
|
+
const text = (msg.content[0] as { type: "text"; text: string }).text;
|
|
3841
|
+
expect(text).not.toMatch(/→ M[0-9a-f]{6}/);
|
|
3842
|
+
}
|
|
3843
|
+
});
|
|
3844
|
+
|
|
3845
|
+
test("legacy-DM fixture: pre-upgrade rows (no slackMeta) interleave with post-upgrade rows", () => {
|
|
3846
|
+
// Mix:
|
|
3847
|
+
// - Two pre-upgrade rows (created before PR 16 wired slackMeta into
|
|
3848
|
+
// DM persistence). Their metadata column has no slackMeta sub-key —
|
|
3849
|
+
// the renderer's flat fallback orders them by createdAt.
|
|
3850
|
+
// - One post-upgrade user row with slackMeta.
|
|
3851
|
+
// - One assistant row that lacks slackMeta entirely (no metadata
|
|
3852
|
+
// column at all — also goes through the legacy fallback).
|
|
3853
|
+
//
|
|
3854
|
+
// All four rows must appear in the output, sorted chronologically.
|
|
3855
|
+
const postUpgradeUserMeta: SlackMessageMetadata = {
|
|
3856
|
+
source: "slack",
|
|
3857
|
+
channelId: DM_CHANNEL_ID,
|
|
3858
|
+
channelTs: TS_14_28,
|
|
3859
|
+
eventKind: "message",
|
|
3860
|
+
displayName: "@alice",
|
|
3861
|
+
};
|
|
3862
|
+
|
|
3863
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3864
|
+
// Pre-upgrade user row from before slackMeta was persisted on DMs.
|
|
3865
|
+
row("user", "old hi", MS_14_25, metadataEnvelope(null)),
|
|
3866
|
+
// Pre-upgrade assistant row.
|
|
3867
|
+
row("assistant", "old reply", MS_14_26, metadataEnvelope(null)),
|
|
3868
|
+
// Post-upgrade user row with slackMeta.
|
|
3869
|
+
row("user", "fresh hi", MS_14_28, metadataEnvelope(postUpgradeUserMeta)),
|
|
3870
|
+
// Assistant row with no metadata column at all (defensive: null
|
|
3871
|
+
// metadata must still survive the assembly path).
|
|
3872
|
+
row("assistant", "fresh reply", MS_14_30, null),
|
|
3873
|
+
];
|
|
3874
|
+
|
|
3875
|
+
const result = assembleSlackChronologicalMessages(rows, DM_CAPS);
|
|
3876
|
+
expect(result).not.toBeNull();
|
|
3877
|
+
expect(result!.map((m) => (m.content[0] as { text: string }).text)).toEqual(
|
|
3878
|
+
[
|
|
3879
|
+
"[11/14/23 14:25]: old hi",
|
|
3880
|
+
"[11/14/23 14:26]: old reply",
|
|
3881
|
+
"[11/14/23 14:28 @alice]: fresh hi",
|
|
3882
|
+
"[11/14/23 14:30]: fresh reply",
|
|
3883
|
+
],
|
|
3884
|
+
);
|
|
3885
|
+
expect(result!.map((m) => m.role)).toEqual([
|
|
3886
|
+
"user",
|
|
3887
|
+
"assistant",
|
|
3888
|
+
"user",
|
|
3889
|
+
"assistant",
|
|
3890
|
+
]);
|
|
3891
|
+
});
|
|
3892
|
+
|
|
3893
|
+
test("malformed slackMeta sub-key falls back to legacy flat render", () => {
|
|
3894
|
+
// Defensive: if the slackMeta sub-key is present but isn't a valid
|
|
3895
|
+
// serialized SlackMessageMetadata, the row is treated as legacy rather
|
|
3896
|
+
// than dropped from context.
|
|
3897
|
+
const badEnvelope = JSON.stringify({
|
|
3898
|
+
userMessageChannel: "slack",
|
|
3899
|
+
slackMeta: "not valid json {{{",
|
|
3900
|
+
});
|
|
3901
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3902
|
+
row("user", "hello", MS_14_25, badEnvelope),
|
|
3903
|
+
];
|
|
3904
|
+
|
|
3905
|
+
const result = assembleSlackChronologicalMessages(rows, DM_CAPS);
|
|
3906
|
+
expect(result).toEqual([
|
|
3907
|
+
{
|
|
3908
|
+
role: "user",
|
|
3909
|
+
content: [{ type: "text", text: "[11/14/23 14:25]: hello" }],
|
|
3910
|
+
},
|
|
3911
|
+
]);
|
|
3912
|
+
});
|
|
3913
|
+
|
|
3914
|
+
test("empty rows yields an empty array (Slack DM with no history)", () => {
|
|
3915
|
+
const result = assembleSlackChronologicalMessages([], DM_CAPS);
|
|
3916
|
+
expect(result).toEqual([]);
|
|
3917
|
+
});
|
|
3918
|
+
|
|
3919
|
+
test("attachment-only user rows emit a placeholder tag line so sender/timestamp attribution is preserved", () => {
|
|
3920
|
+
// Before the placeholder, a row whose content is only an image or file
|
|
3921
|
+
// would render without any tag line at all — the model would see the
|
|
3922
|
+
// attachment block but lose all sender/timestamp attribution. Emit a
|
|
3923
|
+
// synthetic tag line with an `[image]` / `[file]` placeholder so the
|
|
3924
|
+
// attribution survives while the image/file block itself is still
|
|
3925
|
+
// preserved alongside it.
|
|
3926
|
+
const userMeta1: SlackMessageMetadata = {
|
|
3927
|
+
source: "slack",
|
|
3928
|
+
channelId: DM_CHANNEL_ID,
|
|
3929
|
+
channelTs: TS_14_25,
|
|
3930
|
+
eventKind: "message",
|
|
3931
|
+
displayName: "@alice",
|
|
3932
|
+
};
|
|
3933
|
+
const userMeta2: SlackMessageMetadata = {
|
|
3934
|
+
source: "slack",
|
|
3935
|
+
channelId: DM_CHANNEL_ID,
|
|
3936
|
+
channelTs: TS_14_28,
|
|
3937
|
+
eventKind: "message",
|
|
3938
|
+
displayName: "@alice",
|
|
3939
|
+
};
|
|
3940
|
+
const imageOnlyContent = JSON.stringify([
|
|
3941
|
+
{
|
|
3942
|
+
type: "image",
|
|
3943
|
+
source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" },
|
|
3944
|
+
},
|
|
3945
|
+
]);
|
|
3946
|
+
const mixedImageAndFileContent = JSON.stringify([
|
|
3947
|
+
{
|
|
3948
|
+
type: "image",
|
|
3949
|
+
source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" },
|
|
3950
|
+
},
|
|
3951
|
+
{ type: "file", source: { type: "file_id", file_id: "file_1" } },
|
|
3952
|
+
]);
|
|
3953
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
3954
|
+
{
|
|
3955
|
+
role: "user",
|
|
3956
|
+
content: imageOnlyContent,
|
|
3957
|
+
createdAt: MS_14_25,
|
|
3958
|
+
metadata: metadataEnvelope(userMeta1),
|
|
3959
|
+
},
|
|
3960
|
+
{
|
|
3961
|
+
role: "user",
|
|
3962
|
+
content: mixedImageAndFileContent,
|
|
3963
|
+
createdAt: MS_14_28,
|
|
3964
|
+
metadata: metadataEnvelope(userMeta2),
|
|
3965
|
+
},
|
|
3966
|
+
];
|
|
3967
|
+
const result = assembleSlackChronologicalMessages(rows, DM_CAPS);
|
|
3968
|
+
expect(result).not.toBeNull();
|
|
3969
|
+
expect(result!.length).toBe(2);
|
|
3970
|
+
const firstTag = (result![0]!.content[0] as { type: "text"; text: string })
|
|
3971
|
+
.text;
|
|
3972
|
+
const secondTag = (result![1]!.content[0] as { type: "text"; text: string })
|
|
3973
|
+
.text;
|
|
3974
|
+
expect(firstTag).toBe("[11/14/23 14:25 @alice]: [image]");
|
|
3975
|
+
expect(secondTag).toBe("[11/14/23 14:28 @alice]: [image] [file]");
|
|
3976
|
+
// The attachment blocks themselves must still be preserved alongside.
|
|
3977
|
+
expect(result![0]!.content.some((b) => b.type === "image")).toBe(true);
|
|
3978
|
+
expect(
|
|
3979
|
+
result![1]!.content.some((b) => b.type === "image") &&
|
|
3980
|
+
result![1]!.content.some((b) => b.type === "file"),
|
|
3981
|
+
).toBe(true);
|
|
3982
|
+
// No empty-body render like `[... @alice]: ` should ever appear.
|
|
3983
|
+
for (const msg of result!) {
|
|
3984
|
+
const head = (msg.content[0] as { type: "text"; text: string }).text;
|
|
3985
|
+
expect(head).not.toMatch(/]:\s*$/);
|
|
3986
|
+
}
|
|
3987
|
+
});
|
|
3988
|
+
|
|
3989
|
+
test("row content with interleaved text + tool_use preserves tool_use alongside tag line", () => {
|
|
3990
|
+
// Replayable content blocks (tool_use, tool_result, thinking, etc.) are
|
|
3991
|
+
// preserved alongside the tag line. A row persisted with
|
|
3992
|
+
// `[text, tool_use]` renders as `[{type:text, tag-line}, {type:tool_use}]`.
|
|
3993
|
+
//
|
|
3994
|
+
// The assistant tool_use is paired with a follow-up user tool_result so
|
|
3995
|
+
// the orphan-pair filter leaves both blocks intact.
|
|
3996
|
+
const userMeta: SlackMessageMetadata = {
|
|
3997
|
+
source: "slack",
|
|
3998
|
+
channelId: DM_CHANNEL_ID,
|
|
3999
|
+
channelTs: TS_14_25,
|
|
4000
|
+
eventKind: "message",
|
|
4001
|
+
displayName: "@alice",
|
|
4002
|
+
};
|
|
4003
|
+
const assistantRowContent = JSON.stringify([
|
|
4004
|
+
{ type: "text", text: "looking it up" },
|
|
4005
|
+
{
|
|
4006
|
+
type: "tool_use",
|
|
4007
|
+
id: "tu_1",
|
|
4008
|
+
name: "search",
|
|
4009
|
+
input: { q: "weather" },
|
|
4010
|
+
},
|
|
4011
|
+
]);
|
|
4012
|
+
const toolResultRowContent = JSON.stringify([
|
|
4013
|
+
{ type: "tool_result", tool_use_id: "tu_1", content: "72F sunny" },
|
|
4014
|
+
]);
|
|
4015
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
4016
|
+
row("user", "what's the weather?", MS_14_25, metadataEnvelope(userMeta)),
|
|
4017
|
+
{
|
|
4018
|
+
role: "assistant",
|
|
4019
|
+
content: assistantRowContent,
|
|
4020
|
+
createdAt: MS_14_26,
|
|
4021
|
+
metadata: metadataEnvelope(null),
|
|
4022
|
+
},
|
|
4023
|
+
{
|
|
4024
|
+
role: "user",
|
|
4025
|
+
content: toolResultRowContent,
|
|
4026
|
+
createdAt: MS_14_28,
|
|
4027
|
+
metadata: metadataEnvelope(null),
|
|
4028
|
+
},
|
|
4029
|
+
];
|
|
4030
|
+
const result = assembleSlackChronologicalMessages(rows, DM_CAPS);
|
|
4031
|
+
expect(result).not.toBeNull();
|
|
4032
|
+
const rendered = result!;
|
|
4033
|
+
// Pin the assistant row shape — that is what this test is about.
|
|
4034
|
+
expect(rendered.length).toBeGreaterThanOrEqual(2);
|
|
4035
|
+
expect(rendered[1]!).toEqual({
|
|
4036
|
+
role: "assistant",
|
|
4037
|
+
content: [
|
|
4038
|
+
{ type: "text", text: "[11/14/23 14:26]: looking it up" },
|
|
4039
|
+
{
|
|
4040
|
+
type: "tool_use",
|
|
4041
|
+
id: "tu_1",
|
|
4042
|
+
name: "search",
|
|
4043
|
+
input: { q: "weather" },
|
|
4044
|
+
},
|
|
4045
|
+
],
|
|
4046
|
+
});
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
test("post-reconciliation: assistant rows with channelTs participate in thread tagging", () => {
|
|
4050
|
+
// Once `deliverReplyViaCallback` reconciles `channelTs` from the
|
|
4051
|
+
// gateway's response, assistant rows carry a fully-formed slackMeta
|
|
4052
|
+
// envelope. They must then render through the Slack chronological
|
|
4053
|
+
// path (not the legacy fallback) so reply rows pointing at the
|
|
4054
|
+
// assistant's prior message get a `→ Mxxxxxx` parent-alias arrow.
|
|
4055
|
+
//
|
|
4056
|
+
// This is the cross-thread visibility that the slack-thread-aware-
|
|
4057
|
+
// context plan promises: a follow-up user reply to the assistant's
|
|
4058
|
+
// earlier post should render with a parent-alias arrow that the model
|
|
4059
|
+
// can use to reason about which prior assistant message it threads off.
|
|
4060
|
+
const SLACK_CHANNEL_ID_2 = "C0THREAD";
|
|
4061
|
+
const ASSISTANT_TS = "1700001000.000111";
|
|
4062
|
+
const REPLY_TS = "1700001020.000222";
|
|
4063
|
+
const SLACK_CAPS_CHANNEL: ChannelCapabilities = {
|
|
4064
|
+
channel: "slack",
|
|
4065
|
+
dashboardCapable: false,
|
|
4066
|
+
supportsDynamicUi: false,
|
|
4067
|
+
supportsVoiceInput: false,
|
|
4068
|
+
chatType: "channel",
|
|
4069
|
+
};
|
|
4070
|
+
|
|
4071
|
+
const assistantMeta: SlackMessageMetadata = {
|
|
4072
|
+
source: "slack",
|
|
4073
|
+
channelId: SLACK_CHANNEL_ID_2,
|
|
4074
|
+
channelTs: ASSISTANT_TS,
|
|
4075
|
+
eventKind: "message",
|
|
4076
|
+
};
|
|
4077
|
+
const userReplyMeta: SlackMessageMetadata = {
|
|
4078
|
+
source: "slack",
|
|
4079
|
+
channelId: SLACK_CHANNEL_ID_2,
|
|
4080
|
+
channelTs: REPLY_TS,
|
|
4081
|
+
threadTs: ASSISTANT_TS, // Reply to the assistant's earlier message.
|
|
4082
|
+
displayName: "@alice",
|
|
4083
|
+
eventKind: "message",
|
|
4084
|
+
};
|
|
4085
|
+
|
|
4086
|
+
// 1700001000 UTC = 2023-11-14 22:30:00 UTC
|
|
4087
|
+
const MS_ASSISTANT = 1700001000_000;
|
|
4088
|
+
const MS_REPLY = 1700001020_000;
|
|
4089
|
+
|
|
4090
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
4091
|
+
row(
|
|
4092
|
+
"assistant",
|
|
4093
|
+
"Earlier reply",
|
|
4094
|
+
MS_ASSISTANT,
|
|
4095
|
+
metadataEnvelope(assistantMeta),
|
|
4096
|
+
),
|
|
4097
|
+
row("user", "Following up", MS_REPLY, metadataEnvelope(userReplyMeta)),
|
|
4098
|
+
];
|
|
4099
|
+
|
|
4100
|
+
const result = assembleSlackChronologicalMessages(rows, SLACK_CAPS_CHANNEL);
|
|
4101
|
+
expect(result).not.toBeNull();
|
|
4102
|
+
expect(result!.length).toBe(2);
|
|
4103
|
+
|
|
4104
|
+
// The user follow-up MUST carry a `→ Mxxxxxx` parent-alias arrow that
|
|
4105
|
+
// points at the assistant's prior message. Before reconciliation, the
|
|
4106
|
+
// assistant row was treated as legacy/null-metadata and excluded from
|
|
4107
|
+
// alias issuance — the user reply rendered without the arrow.
|
|
4108
|
+
const replyText = (result![1].content[0] as { text: string }).text;
|
|
4109
|
+
expect(replyText).toMatch(/→ M[0-9a-f]{6}/);
|
|
4110
|
+
expect(replyText).toContain(parentAlias(ASSISTANT_TS));
|
|
4111
|
+
});
|
|
4112
|
+
|
|
4113
|
+
test("post-reconciliation: assistant row appears in active-thread focus block", () => {
|
|
4114
|
+
// The active-thread focus block at
|
|
4115
|
+
// `conversation-runtime-assembly.ts:1387` filters out rows with null
|
|
4116
|
+
// metadata. Before reconciliation, outbound assistant rows were null-
|
|
4117
|
+
// metadata at the renderable layer and silently dropped from the focus
|
|
4118
|
+
// block — even when they were part of the active thread the user just
|
|
4119
|
+
// replied to. Once channelTs is filled in, the assistant row's
|
|
4120
|
+
// `threadTs` matches the active thread and the row is included.
|
|
4121
|
+
const SLACK_CHANNEL_ID_3 = "C0FOCUS2";
|
|
4122
|
+
const PARENT_TS = "1700002000.000001";
|
|
4123
|
+
const ASSISTANT_REPLY_TS = "1700002005.000111";
|
|
4124
|
+
const USER_REPLY_TS = "1700002010.000222";
|
|
4125
|
+
const SLACK_CAPS_CHANNEL: ChannelCapabilities = {
|
|
4126
|
+
channel: "slack",
|
|
4127
|
+
dashboardCapable: false,
|
|
4128
|
+
supportsDynamicUi: false,
|
|
4129
|
+
supportsVoiceInput: false,
|
|
4130
|
+
chatType: "channel",
|
|
4131
|
+
};
|
|
4132
|
+
|
|
4133
|
+
const parentMeta: SlackMessageMetadata = {
|
|
4134
|
+
source: "slack",
|
|
4135
|
+
channelId: SLACK_CHANNEL_ID_3,
|
|
4136
|
+
channelTs: PARENT_TS,
|
|
4137
|
+
eventKind: "message",
|
|
4138
|
+
displayName: "@alice",
|
|
4139
|
+
};
|
|
4140
|
+
const assistantInThreadMeta: SlackMessageMetadata = {
|
|
4141
|
+
source: "slack",
|
|
4142
|
+
channelId: SLACK_CHANNEL_ID_3,
|
|
4143
|
+
channelTs: ASSISTANT_REPLY_TS,
|
|
4144
|
+
threadTs: PARENT_TS, // Assistant's reply lives inside the active thread.
|
|
4145
|
+
eventKind: "message",
|
|
4146
|
+
};
|
|
4147
|
+
const userInThreadMeta: SlackMessageMetadata = {
|
|
4148
|
+
source: "slack",
|
|
4149
|
+
channelId: SLACK_CHANNEL_ID_3,
|
|
4150
|
+
channelTs: USER_REPLY_TS,
|
|
4151
|
+
threadTs: PARENT_TS, // Latest user row — drives active-thread detection.
|
|
4152
|
+
displayName: "@alice",
|
|
4153
|
+
eventKind: "message",
|
|
4154
|
+
};
|
|
4155
|
+
|
|
4156
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
4157
|
+
{
|
|
4158
|
+
role: "user",
|
|
4159
|
+
content: JSON.stringify([{ type: "text", text: "Parent message" }]),
|
|
4160
|
+
createdAt: 1700002000_000,
|
|
4161
|
+
metadata: metadataEnvelope(parentMeta),
|
|
4162
|
+
},
|
|
4163
|
+
{
|
|
4164
|
+
role: "assistant",
|
|
4165
|
+
content: JSON.stringify([
|
|
4166
|
+
{ type: "text", text: "Assistant earlier reply" },
|
|
4167
|
+
]),
|
|
4168
|
+
createdAt: 1700002005_000,
|
|
4169
|
+
metadata: metadataEnvelope(assistantInThreadMeta),
|
|
4170
|
+
},
|
|
4171
|
+
{
|
|
4172
|
+
role: "user",
|
|
4173
|
+
content: JSON.stringify([{ type: "text", text: "Follow-up" }]),
|
|
4174
|
+
createdAt: 1700002010_000,
|
|
4175
|
+
metadata: metadataEnvelope(userInThreadMeta),
|
|
4176
|
+
},
|
|
4177
|
+
];
|
|
4178
|
+
|
|
4179
|
+
const focusBlock = assembleSlackActiveThreadFocusBlock(
|
|
4180
|
+
rows,
|
|
4181
|
+
SLACK_CAPS_CHANNEL,
|
|
4182
|
+
);
|
|
4183
|
+
expect(focusBlock).not.toBeNull();
|
|
4184
|
+
expect(focusBlock!).toContain("<active_thread>");
|
|
4185
|
+
expect(focusBlock!).toContain("Parent message");
|
|
4186
|
+
// The assistant's earlier reply must appear in the focus block now —
|
|
4187
|
+
// before reconciliation it was excluded because its slackMeta failed
|
|
4188
|
+
// `readSlackMetadata` validation (no channelTs).
|
|
4189
|
+
expect(focusBlock!).toContain("Assistant earlier reply");
|
|
4190
|
+
expect(focusBlock!).toContain("Follow-up");
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
test("multi-step tool turn: preserves tool_use/tool_result pairs across assembled transcript", () => {
|
|
4194
|
+
// Simulates seven rows of a realistic multi-step tool-using turn:
|
|
4195
|
+
// user("hi")
|
|
4196
|
+
// assistant([text, tool_use(abc)])
|
|
4197
|
+
// user([tool_result(abc)])
|
|
4198
|
+
// assistant([text, tool_use(def)])
|
|
4199
|
+
// user([tool_result(def)])
|
|
4200
|
+
// assistant([text])
|
|
4201
|
+
// user("follow-up")
|
|
4202
|
+
//
|
|
4203
|
+
// Rows 3 and 5 are synthetic "tool-turn" rows generated by the agent
|
|
4204
|
+
// loop and are NOT sent to Slack (no slackMeta.channelTs). They still
|
|
4205
|
+
// persist structurally because Anthropic requires tool_use/tool_result
|
|
4206
|
+
// pairing in message history. The chronological renderer must:
|
|
4207
|
+
// - preserve all four tool blocks in order
|
|
4208
|
+
// - emit pure-tool-only messages (no tag line) for the synthetic rows
|
|
4209
|
+
// - keep the Slack-visible rows' tag lines intact
|
|
4210
|
+
const CHANNEL = "C0ROUNDTRIP";
|
|
4211
|
+
const TS_TOP_USER = "1700003000.000100"; // 23:03:20 UTC
|
|
4212
|
+
const TS_ASSIST_1 = "1700003005.000200"; // 23:03:25 UTC
|
|
4213
|
+
const TS_ASSIST_2 = "1700003015.000300"; // 23:03:35 UTC
|
|
4214
|
+
const TS_ASSIST_3 = "1700003025.000400"; // 23:03:45 UTC
|
|
4215
|
+
const TS_FOLLOWUP = "1700003030.000500"; // 23:03:50 UTC
|
|
4216
|
+
|
|
4217
|
+
const userMeta = (ts: string): SlackMessageMetadata => ({
|
|
4218
|
+
source: "slack",
|
|
4219
|
+
channelId: CHANNEL,
|
|
4220
|
+
channelTs: ts,
|
|
4221
|
+
eventKind: "message",
|
|
4222
|
+
displayName: "@alice",
|
|
4223
|
+
});
|
|
4224
|
+
const assistMeta = (ts: string): SlackMessageMetadata => ({
|
|
4225
|
+
source: "slack",
|
|
4226
|
+
channelId: CHANNEL,
|
|
4227
|
+
channelTs: ts,
|
|
4228
|
+
eventKind: "message",
|
|
4229
|
+
});
|
|
4230
|
+
|
|
4231
|
+
const rows: SlackTranscriptInputRow[] = [
|
|
4232
|
+
// 1. User "hi" — Slack-visible, carries channelTs.
|
|
4233
|
+
{
|
|
4234
|
+
role: "user",
|
|
4235
|
+
content: JSON.stringify([{ type: "text", text: "hi" }]),
|
|
4236
|
+
createdAt: 1700003000_000,
|
|
4237
|
+
metadata: metadataEnvelope(userMeta(TS_TOP_USER)),
|
|
4238
|
+
},
|
|
4239
|
+
// 2. Assistant: text + tool_use(abc) — Slack-visible.
|
|
4240
|
+
{
|
|
4241
|
+
role: "assistant",
|
|
4242
|
+
content: JSON.stringify([
|
|
4243
|
+
{ type: "text", text: "checking..." },
|
|
4244
|
+
{
|
|
4245
|
+
type: "tool_use",
|
|
4246
|
+
id: "tu_abc",
|
|
4247
|
+
name: "search",
|
|
4248
|
+
input: { q: "first" },
|
|
4249
|
+
},
|
|
4250
|
+
]),
|
|
4251
|
+
createdAt: 1700003005_000,
|
|
4252
|
+
metadata: metadataEnvelope(assistMeta(TS_ASSIST_1)),
|
|
4253
|
+
},
|
|
4254
|
+
// 3. User: tool_result(abc) — synthetic, no slackMeta envelope.
|
|
4255
|
+
{
|
|
4256
|
+
role: "user",
|
|
4257
|
+
content: JSON.stringify([
|
|
4258
|
+
{ type: "tool_result", tool_use_id: "tu_abc", content: "result 1" },
|
|
4259
|
+
]),
|
|
4260
|
+
createdAt: 1700003006_000,
|
|
4261
|
+
metadata: metadataEnvelope(null),
|
|
4262
|
+
},
|
|
4263
|
+
// 4. Assistant: text + tool_use(def) — Slack-visible.
|
|
4264
|
+
{
|
|
4265
|
+
role: "assistant",
|
|
4266
|
+
content: JSON.stringify([
|
|
4267
|
+
{ type: "text", text: "one more lookup..." },
|
|
4268
|
+
{
|
|
4269
|
+
type: "tool_use",
|
|
4270
|
+
id: "tu_def",
|
|
4271
|
+
name: "search",
|
|
4272
|
+
input: { q: "second" },
|
|
4273
|
+
},
|
|
4274
|
+
]),
|
|
4275
|
+
createdAt: 1700003015_000,
|
|
4276
|
+
metadata: metadataEnvelope(assistMeta(TS_ASSIST_2)),
|
|
4277
|
+
},
|
|
4278
|
+
// 5. User: tool_result(def) — synthetic, no slackMeta envelope.
|
|
4279
|
+
{
|
|
4280
|
+
role: "user",
|
|
4281
|
+
content: JSON.stringify([
|
|
4282
|
+
{ type: "tool_result", tool_use_id: "tu_def", content: "result 2" },
|
|
4283
|
+
]),
|
|
4284
|
+
createdAt: 1700003016_000,
|
|
4285
|
+
metadata: metadataEnvelope(null),
|
|
4286
|
+
},
|
|
4287
|
+
// 6. Assistant: text-only final answer — Slack-visible.
|
|
4288
|
+
{
|
|
4289
|
+
role: "assistant",
|
|
4290
|
+
content: JSON.stringify([{ type: "text", text: "all done" }]),
|
|
4291
|
+
createdAt: 1700003025_000,
|
|
4292
|
+
metadata: metadataEnvelope(assistMeta(TS_ASSIST_3)),
|
|
4293
|
+
},
|
|
4294
|
+
// 7. User: follow-up text — Slack-visible.
|
|
4295
|
+
{
|
|
4296
|
+
role: "user",
|
|
4297
|
+
content: JSON.stringify([{ type: "text", text: "follow-up" }]),
|
|
4298
|
+
createdAt: 1700003030_000,
|
|
4299
|
+
metadata: metadataEnvelope(userMeta(TS_FOLLOWUP)),
|
|
4300
|
+
},
|
|
4301
|
+
];
|
|
4302
|
+
|
|
4303
|
+
const SLACK_CAPS_CHANNEL: ChannelCapabilities = {
|
|
4304
|
+
channel: "slack",
|
|
4305
|
+
dashboardCapable: false,
|
|
4306
|
+
supportsDynamicUi: false,
|
|
4307
|
+
supportsVoiceInput: false,
|
|
4308
|
+
chatType: "channel",
|
|
4309
|
+
};
|
|
4310
|
+
|
|
4311
|
+
const result = assembleSlackChronologicalMessages(rows, SLACK_CAPS_CHANNEL);
|
|
4312
|
+
expect(result).not.toBeNull();
|
|
4313
|
+
|
|
4314
|
+
// All four tool blocks must appear in the rendered transcript.
|
|
4315
|
+
const allBlocks = result!.flatMap((m) => m.content);
|
|
4316
|
+
const toolUses = allBlocks.filter((b) => b.type === "tool_use");
|
|
4317
|
+
const toolResults = allBlocks.filter((b) => b.type === "tool_result");
|
|
4318
|
+
expect(toolUses.map((b) => (b as { id: string }).id)).toEqual([
|
|
4319
|
+
"tu_abc",
|
|
4320
|
+
"tu_def",
|
|
4321
|
+
]);
|
|
4322
|
+
expect(
|
|
4323
|
+
toolResults.map((b) => (b as { tool_use_id: string }).tool_use_id),
|
|
4324
|
+
).toEqual(["tu_abc", "tu_def"]);
|
|
4325
|
+
|
|
4326
|
+
// tool_use(abc) must come before tool_result(abc), and likewise for def.
|
|
4327
|
+
// Since they sit on adjacent rows, enforcing this via the flat index of
|
|
4328
|
+
// each block is sufficient.
|
|
4329
|
+
const findIdx = (pred: (b: (typeof allBlocks)[number]) => boolean) =>
|
|
4330
|
+
allBlocks.findIndex(pred);
|
|
4331
|
+
const idxTuAbc = findIdx(
|
|
4332
|
+
(b) => b.type === "tool_use" && (b as { id: string }).id === "tu_abc",
|
|
4333
|
+
);
|
|
4334
|
+
const idxTrAbc = findIdx(
|
|
4335
|
+
(b) =>
|
|
4336
|
+
b.type === "tool_result" &&
|
|
4337
|
+
(b as { tool_use_id: string }).tool_use_id === "tu_abc",
|
|
4338
|
+
);
|
|
4339
|
+
const idxTuDef = findIdx(
|
|
4340
|
+
(b) => b.type === "tool_use" && (b as { id: string }).id === "tu_def",
|
|
4341
|
+
);
|
|
4342
|
+
const idxTrDef = findIdx(
|
|
4343
|
+
(b) =>
|
|
4344
|
+
b.type === "tool_result" &&
|
|
4345
|
+
(b as { tool_use_id: string }).tool_use_id === "tu_def",
|
|
4346
|
+
);
|
|
4347
|
+
expect(idxTuAbc).toBeLessThan(idxTrAbc);
|
|
4348
|
+
expect(idxTrAbc).toBeLessThan(idxTuDef);
|
|
4349
|
+
expect(idxTuDef).toBeLessThan(idxTrDef);
|
|
4350
|
+
|
|
4351
|
+
// Slack-visible rows render a tag line; synthetic tool-turn rows do not.
|
|
4352
|
+
// Per-row assertion: we expect 7 messages (one per persisted row).
|
|
4353
|
+
expect(result!.length).toBe(7);
|
|
4354
|
+
|
|
4355
|
+
// Row 1: user tag line only.
|
|
4356
|
+
expect(result![0]).toEqual({
|
|
4357
|
+
role: "user",
|
|
4358
|
+
content: [{ type: "text", text: "[11/14/23 23:03 @alice]: hi" }],
|
|
4359
|
+
});
|
|
4360
|
+
// Row 2: assistant tag line + tool_use(abc).
|
|
4361
|
+
expect(result![1]).toEqual({
|
|
4362
|
+
role: "assistant",
|
|
4363
|
+
content: [
|
|
4364
|
+
{ type: "text", text: "[11/14/23 23:03]: checking..." },
|
|
4365
|
+
{
|
|
4366
|
+
type: "tool_use",
|
|
4367
|
+
id: "tu_abc",
|
|
4368
|
+
name: "search",
|
|
4369
|
+
input: { q: "first" },
|
|
4370
|
+
},
|
|
4371
|
+
],
|
|
4372
|
+
});
|
|
4373
|
+
// Row 3: synthetic tool_result(abc) — no tag line.
|
|
4374
|
+
expect(result![2]).toEqual({
|
|
4375
|
+
role: "user",
|
|
4376
|
+
content: [
|
|
4377
|
+
{ type: "tool_result", tool_use_id: "tu_abc", content: "result 1" },
|
|
4378
|
+
],
|
|
4379
|
+
});
|
|
4380
|
+
// Row 4: assistant tag line + tool_use(def).
|
|
4381
|
+
expect(result![3]).toEqual({
|
|
4382
|
+
role: "assistant",
|
|
4383
|
+
content: [
|
|
4384
|
+
{ type: "text", text: "[11/14/23 23:03]: one more lookup..." },
|
|
4385
|
+
{
|
|
4386
|
+
type: "tool_use",
|
|
4387
|
+
id: "tu_def",
|
|
4388
|
+
name: "search",
|
|
4389
|
+
input: { q: "second" },
|
|
4390
|
+
},
|
|
4391
|
+
],
|
|
4392
|
+
});
|
|
4393
|
+
// Row 5: synthetic tool_result(def) — no tag line.
|
|
4394
|
+
expect(result![4]).toEqual({
|
|
4395
|
+
role: "user",
|
|
4396
|
+
content: [
|
|
4397
|
+
{ type: "tool_result", tool_use_id: "tu_def", content: "result 2" },
|
|
4398
|
+
],
|
|
4399
|
+
});
|
|
4400
|
+
// Row 6: assistant final text-only answer, rendered as tag line only.
|
|
4401
|
+
expect(result![5]).toEqual({
|
|
4402
|
+
role: "assistant",
|
|
4403
|
+
content: [{ type: "text", text: "[11/14/23 23:03]: all done" }],
|
|
4404
|
+
});
|
|
4405
|
+
// Row 7: user follow-up tag line.
|
|
4406
|
+
expect(result![6]).toEqual({
|
|
4407
|
+
role: "user",
|
|
4408
|
+
content: [{ type: "text", text: "[11/14/23 23:03 @alice]: follow-up" }],
|
|
4409
|
+
});
|
|
4410
|
+
});
|
|
4411
|
+
});
|
|
4412
|
+
|
|
4413
|
+
// ---------------------------------------------------------------------------
|
|
4414
|
+
// applyRuntimeInjections blocks.pkbSystemReminder
|
|
4415
|
+
// ---------------------------------------------------------------------------
|
|
4416
|
+
|
|
4417
|
+
describe("applyRuntimeInjections blocks.pkbSystemReminder", () => {
|
|
4418
|
+
const baseMessages: Message[] = [
|
|
4419
|
+
{
|
|
4420
|
+
role: "user",
|
|
4421
|
+
content: [{ type: "text", text: "Hello" }],
|
|
4422
|
+
},
|
|
4423
|
+
];
|
|
4424
|
+
|
|
4425
|
+
test("captures exact reminder bytes when full mode and PKB active", async () => {
|
|
4426
|
+
pkbSearchResults = [];
|
|
4427
|
+
pkbSearchThrows = null;
|
|
4428
|
+
const { blocks } = await applyRuntimeInjections(baseMessages, {
|
|
4429
|
+
pkbActive: true,
|
|
4430
|
+
mode: "full",
|
|
4431
|
+
});
|
|
4432
|
+
|
|
4433
|
+
const expected = buildPkbReminder([]);
|
|
4434
|
+
expect(blocks.pkbSystemReminder).toBe(expected);
|
|
4435
|
+
});
|
|
4436
|
+
|
|
4437
|
+
test("not captured in minimal mode", async () => {
|
|
4438
|
+
const { blocks } = await applyRuntimeInjections(baseMessages, {
|
|
4439
|
+
pkbActive: true,
|
|
4440
|
+
mode: "minimal",
|
|
4441
|
+
});
|
|
4442
|
+
|
|
4443
|
+
expect(blocks.pkbSystemReminder).toBeUndefined();
|
|
4444
|
+
});
|
|
4445
|
+
|
|
4446
|
+
test("not captured when PKB inactive", async () => {
|
|
4447
|
+
const { blocks } = await applyRuntimeInjections(baseMessages, {
|
|
4448
|
+
pkbActive: false,
|
|
4449
|
+
mode: "full",
|
|
4450
|
+
});
|
|
4451
|
+
|
|
4452
|
+
expect(blocks.pkbSystemReminder).toBeUndefined();
|
|
4453
|
+
});
|
|
4454
|
+
});
|