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