@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.
Files changed (1008) hide show
  1. package/.prettierignore +5 -0
  2. package/AGENTS.md +9 -1
  3. package/ARCHITECTURE.md +43 -49
  4. package/Dockerfile +17 -3
  5. package/README.md +3 -4
  6. package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
  7. package/bun.lock +8 -3
  8. package/docs/architecture/integrations.md +33 -59
  9. package/docs/architecture/memory.md +25 -30
  10. package/docs/architecture/security.md +19 -18
  11. package/docs/browser-use-architecture-phase2.md +63 -20
  12. package/docs/error-handling.md +111 -0
  13. package/docs/plugins.md +761 -0
  14. package/docs/skills.md +10 -10
  15. package/docs/stt-provider-onboarding.md +2 -1
  16. package/examples/plugins/echo/README.md +132 -0
  17. package/examples/plugins/echo/package.json +17 -0
  18. package/examples/plugins/echo/register.ts +187 -0
  19. package/knip.json +9 -2
  20. package/node_modules/@vellumai/ces-contracts/package.json +2 -1
  21. package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +471 -0
  22. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +398 -4
  23. package/node_modules/@vellumai/credential-storage/bun.lock +2 -2
  24. package/node_modules/@vellumai/credential-storage/package.json +2 -2
  25. package/node_modules/@vellumai/credential-storage/src/oauth-runtime.ts +20 -2
  26. package/node_modules/@vellumai/egress-proxy/bun.lock +2 -2
  27. package/node_modules/@vellumai/egress-proxy/package.json +2 -2
  28. package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
  29. package/openapi.yaml +334 -78
  30. package/package.json +6 -3
  31. package/scripts/generate-openapi.ts +50 -11
  32. package/src/__tests__/agent-loop-callsite-precedence.test.ts +318 -0
  33. package/src/__tests__/agent-loop-sentry-hygiene.test.ts +137 -0
  34. package/src/__tests__/agent-loop.test.ts +112 -1
  35. package/src/__tests__/anthropic-error-formatting.test.ts +98 -0
  36. package/src/__tests__/anthropic-provider.test.ts +171 -2
  37. package/src/__tests__/app-compiler.test.ts +57 -0
  38. package/src/__tests__/approval-cascade.test.ts +36 -10
  39. package/src/__tests__/approval-routes-http.test.ts +134 -10
  40. package/src/__tests__/assistant-attachments.test.ts +44 -0
  41. package/src/__tests__/assistant-feature-flags-integration.test.ts +29 -0
  42. package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
  43. package/src/__tests__/avatar-generator.test.ts +4 -2
  44. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  45. package/src/__tests__/browser-identifier-parity-guard.test.ts +53 -0
  46. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +23 -33
  47. package/src/__tests__/browser-skill-endstate.test.ts +51 -182
  48. package/src/__tests__/btw-routes.test.ts +47 -1
  49. package/src/__tests__/bundled-asset.test.ts +6 -6
  50. package/src/__tests__/call-controller.test.ts +1 -2
  51. package/src/__tests__/call-site-routing-provider.test.ts +214 -0
  52. package/src/__tests__/catalog-cache.test.ts +96 -4
  53. package/src/__tests__/channel-approval-routes.test.ts +4 -4
  54. package/src/__tests__/channel-reply-delivery.test.ts +300 -2
  55. package/src/__tests__/checker.test.ts +870 -655
  56. package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
  57. package/src/__tests__/cli-command-risk-guard.test.ts +30 -33
  58. package/src/__tests__/compaction-events.test.ts +501 -0
  59. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  60. package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
  61. package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
  62. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  63. package/src/__tests__/config-analysis.test.ts +11 -28
  64. package/src/__tests__/config-loader-backfill.test.ts +174 -0
  65. package/src/__tests__/config-loader-corrupt.test.ts +183 -0
  66. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +202 -0
  67. package/src/__tests__/config-model-image-provider.test.ts +110 -0
  68. package/src/__tests__/config-schema-cmd.test.ts +11 -5
  69. package/src/__tests__/config-schema.test.ts +440 -114
  70. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
  71. package/src/__tests__/config-watcher.test.ts +2 -2
  72. package/src/__tests__/contact-store-user-file.test.ts +72 -73
  73. package/src/__tests__/contacts-tools.test.ts +26 -0
  74. package/src/__tests__/contacts-write.test.ts +4 -4
  75. package/src/__tests__/context-overflow-policy.test.ts +7 -7
  76. package/src/__tests__/context-token-estimator.test.ts +191 -1
  77. package/src/__tests__/context-window-manager.test.ts +883 -4
  78. package/src/__tests__/conversation-abort-tool-results.test.ts +32 -15
  79. package/src/__tests__/conversation-agent-loop-overflow.test.ts +86 -46
  80. package/src/__tests__/conversation-agent-loop.test.ts +435 -216
  81. package/src/__tests__/conversation-attachments.test.ts +1 -1
  82. package/src/__tests__/conversation-confirmation-signals.test.ts +36 -10
  83. package/src/__tests__/conversation-error.test.ts +37 -6
  84. package/src/__tests__/conversation-history-web-search.test.ts +7 -0
  85. package/src/__tests__/conversation-init.benchmark.test.ts +34 -12
  86. package/src/__tests__/conversation-lifecycle.test.ts +336 -0
  87. package/src/__tests__/conversation-load-history-repair.test.ts +27 -10
  88. package/src/__tests__/conversation-pairing.test.ts +174 -10
  89. package/src/__tests__/conversation-pre-run-repair.test.ts +32 -15
  90. package/src/__tests__/conversation-process-callsite.test.ts +309 -0
  91. package/src/__tests__/conversation-provider-retry-repair.test.ts +44 -21
  92. package/src/__tests__/conversation-queue.test.ts +68 -38
  93. package/src/__tests__/conversation-routes-disk-view.test.ts +36 -7
  94. package/src/__tests__/conversation-routes-slash-commands.test.ts +31 -3
  95. package/src/__tests__/conversation-runtime-assembly.test.ts +2877 -152
  96. package/src/__tests__/conversation-runtime-workspace.test.ts +35 -50
  97. package/src/__tests__/conversation-seed-composer.test.ts +2 -2
  98. package/src/__tests__/conversation-skill-tools.test.ts +12 -146
  99. package/src/__tests__/conversation-slash-queue.test.ts +39 -19
  100. package/src/__tests__/conversation-slash-unknown.test.ts +53 -16
  101. package/src/__tests__/conversation-speed-override.test.ts +36 -12
  102. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1035 -0
  103. package/src/__tests__/conversation-surfaces-standalone.test.ts +630 -0
  104. package/src/__tests__/conversation-title-service.test.ts +118 -2
  105. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
  106. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +1 -1
  107. package/src/__tests__/conversation-unread-route.test.ts +2 -2
  108. package/src/__tests__/conversation-usage.test.ts +4 -2
  109. package/src/__tests__/conversation-workspace-cache-state.test.ts +33 -9
  110. package/src/__tests__/conversation-workspace-injection.test.ts +46 -15
  111. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +46 -15
  112. package/src/__tests__/credential-broker-browser-fill.test.ts +110 -0
  113. package/src/__tests__/credential-health-service.test.ts +78 -9
  114. package/src/__tests__/credential-security-invariants.test.ts +5 -2
  115. package/src/__tests__/credential-storage-oauth-compat.test.ts +18 -0
  116. package/src/__tests__/credential-storage-static-compat.test.ts +28 -0
  117. package/src/__tests__/credential-vault-unit.test.ts +135 -19
  118. package/src/__tests__/credentials-cli.test.ts +1 -9
  119. package/src/__tests__/cross-provider-web-search.test.ts +84 -0
  120. package/src/__tests__/daemon-server-persist-and-process-callsite.test.ts +92 -0
  121. package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
  122. package/src/__tests__/delete-propagation.test.ts +437 -0
  123. package/src/__tests__/dm-backfill.test.ts +417 -0
  124. package/src/__tests__/dm-persistence.test.ts +227 -0
  125. package/src/__tests__/edit-propagation.test.ts +280 -0
  126. package/src/__tests__/empty-response-pipeline.test.ts +305 -0
  127. package/src/__tests__/ephemeral-permissions.test.ts +93 -3
  128. package/src/__tests__/estimator-calibration-integration.test.ts +208 -0
  129. package/src/__tests__/estimator-calibration.test.ts +213 -0
  130. package/src/__tests__/extension-id-sync-guard.test.ts +29 -10
  131. package/src/__tests__/file-write-tool.test.ts +151 -1
  132. package/src/__tests__/filing-service.test.ts +255 -0
  133. package/src/__tests__/first-greeting.test.ts +247 -5
  134. package/src/__tests__/gemini-provider.test.ts +0 -3
  135. package/src/__tests__/guardian-grant-minting.test.ts +8 -0
  136. package/src/__tests__/headless-browser-interactions.test.ts +1 -1
  137. package/src/__tests__/headless-browser-mode.test.ts +57 -0
  138. package/src/__tests__/heartbeat-service.test.ts +96 -15
  139. package/src/__tests__/history-repair-pipeline.test.ts +399 -0
  140. package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
  141. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
  142. package/src/__tests__/host-proxy-interface.test.ts +36 -2
  143. package/src/__tests__/host-shell-tool.test.ts +124 -18
  144. package/src/__tests__/http-user-message-parity.test.ts +29 -1
  145. package/src/__tests__/image-credentials.test.ts +137 -0
  146. package/src/__tests__/image-service-dispatcher.test.ts +186 -0
  147. package/src/__tests__/inbound-slack-persistence.test.ts +340 -0
  148. package/src/__tests__/injector-chain.test.ts +526 -0
  149. package/src/__tests__/intent-routing.test.ts +1 -66
  150. package/src/__tests__/llm-call-pipeline.test.ts +285 -0
  151. package/src/__tests__/llm-catalog-parity.test.ts +174 -0
  152. package/src/__tests__/llm-context-normalization.test.ts +121 -0
  153. package/src/__tests__/llm-resolver.test.ts +214 -0
  154. package/src/__tests__/llm-schema.test.ts +223 -0
  155. package/src/__tests__/managed-proxy-context.test.ts +6 -2
  156. package/src/__tests__/media-generate-image.test.ts +119 -13
  157. package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
  158. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
  159. package/src/__tests__/messaging-skill-split.test.ts +3 -34
  160. package/src/__tests__/migration-import-from-url.test.ts +621 -0
  161. package/src/__tests__/model-intents.test.ts +11 -83
  162. package/src/__tests__/notification-broadcaster.test.ts +3 -3
  163. package/src/__tests__/notification-decision-fallback.test.ts +0 -10
  164. package/src/__tests__/notification-decision-identity.test.ts +0 -9
  165. package/src/__tests__/notification-decision-recipient-context.test.ts +0 -9
  166. package/src/__tests__/notification-decision-strategy.test.ts +0 -11
  167. package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
  168. package/src/__tests__/oauth-apps-routes.test.ts +1 -1
  169. package/src/__tests__/oauth-cli.test.ts +14 -12
  170. package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
  171. package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
  172. package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
  173. package/src/__tests__/oauth-providers-routes.test.ts +3 -2
  174. package/src/__tests__/oauth-store.test.ts +46 -78
  175. package/src/__tests__/oauth2-gateway-transport.test.ts +8 -3
  176. package/src/__tests__/oauth2-refresh-retry.test.ts +279 -0
  177. package/src/__tests__/onboarding-template-contract.test.ts +16 -64
  178. package/src/__tests__/openai-image-service.test.ts +368 -0
  179. package/src/__tests__/openai-provider.test.ts +7 -0
  180. package/src/__tests__/openai-responses-provider.test.ts +396 -0
  181. package/src/__tests__/openrouter-provider-only.test.ts +135 -0
  182. package/src/__tests__/outbound-slack-persistence.test.ts +293 -0
  183. package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
  184. package/src/__tests__/permission-checker-host-gate.test.ts +1 -25
  185. package/src/__tests__/permission-mode.test.ts +16 -0
  186. package/src/__tests__/permission-types.test.ts +0 -1
  187. package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
  188. package/src/__tests__/persistence-pipeline.test.ts +377 -0
  189. package/src/__tests__/persona-resolver.test.ts +13 -13
  190. package/src/__tests__/pipeline-runner.test.ts +565 -0
  191. package/src/__tests__/pkb-autoinject.test.ts +37 -1
  192. package/src/__tests__/platform-bash-auto-approve.test.ts +1 -1
  193. package/src/__tests__/platform.test.ts +5 -2
  194. package/src/__tests__/plugin-bootstrap.test.ts +483 -0
  195. package/src/__tests__/plugin-registry.test.ts +273 -0
  196. package/src/__tests__/plugin-route-contribution.test.ts +288 -0
  197. package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
  198. package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
  199. package/src/__tests__/plugin-types.test.ts +320 -0
  200. package/src/__tests__/pricing.test.ts +93 -14
  201. package/src/__tests__/profiler-routes.test.ts +1 -1
  202. package/src/__tests__/provider-commit-message-generator.test.ts +14 -84
  203. package/src/__tests__/provider-env-vars-scope.test.ts +52 -0
  204. package/src/__tests__/provider-error-scenarios.test.ts +135 -6
  205. package/src/__tests__/provider-managed-proxy-integration.test.ts +42 -11
  206. package/src/__tests__/provider-registry-ollama.test.ts +1 -2
  207. package/src/__tests__/proxy-approval-callback.test.ts +69 -9
  208. package/src/__tests__/reaction-persistence.test.ts +561 -0
  209. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
  210. package/src/__tests__/registry.test.ts +0 -2
  211. package/src/__tests__/relay-server.test.ts +1 -1
  212. package/src/__tests__/require-fresh-approval.test.ts +1 -1
  213. package/src/__tests__/retry-openrouter-only-normalization.test.ts +136 -0
  214. package/src/__tests__/retry-thinking-tool-choice.test.ts +226 -0
  215. package/src/__tests__/risk-classifier-parity.test.ts +230 -0
  216. package/src/__tests__/sanitize-config-for-transfer.test.ts +78 -1
  217. package/src/__tests__/schedule-routes.test.ts +131 -1
  218. package/src/__tests__/scheduler-recurrence.test.ts +14 -70
  219. package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
  220. package/src/__tests__/secret-detection-handler.test.ts +0 -10
  221. package/src/__tests__/secret-ingress-http.test.ts +28 -0
  222. package/src/__tests__/secret-prompter-channel-fallback.test.ts +125 -0
  223. package/src/__tests__/secret-routes-managed-proxy.test.ts +2 -3
  224. package/src/__tests__/secret-scanner-executor.test.ts +1 -1
  225. package/src/__tests__/send-endpoint-busy.test.ts +29 -1
  226. package/src/__tests__/server-history-render.test.ts +31 -0
  227. package/src/__tests__/shell-identity.test.ts +0 -134
  228. package/src/__tests__/shell-parser-property.test.ts +13 -13
  229. package/src/__tests__/skill-cache-store.test.ts +182 -0
  230. package/src/__tests__/skills.test.ts +19 -33
  231. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  232. package/src/__tests__/slack-skill.test.ts +3 -8
  233. package/src/__tests__/starter-bundle.test.ts +35 -0
  234. package/src/__tests__/subagent-call-site-routing.test.ts +280 -0
  235. package/src/__tests__/suggestion-routes.test.ts +259 -3
  236. package/src/__tests__/system-prompt.test.ts +22 -35
  237. package/src/__tests__/task-memory-cleanup.test.ts +1 -0
  238. package/src/__tests__/task-runner.test.ts +3 -1
  239. package/src/__tests__/task-scheduler.test.ts +3 -15
  240. package/src/__tests__/tcc-sandbox-deny.test.ts +198 -0
  241. package/src/__tests__/terminal-tools.test.ts +8 -0
  242. package/src/__tests__/test-preload.ts +11 -0
  243. package/src/__tests__/test-support/browser-skill-harness.ts +2 -52
  244. package/src/__tests__/thread-backfill.test.ts +941 -0
  245. package/src/__tests__/title-generate-pipeline.test.ts +224 -0
  246. package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
  247. package/src/__tests__/tool-error-pipeline.test.ts +244 -0
  248. package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
  249. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -8
  250. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -2
  251. package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
  252. package/src/__tests__/tool-executor.test.ts +201 -94
  253. package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
  254. package/src/__tests__/tool-result-truncation.test.ts +0 -110
  255. package/src/__tests__/trust-store.test.ts +442 -109
  256. package/src/__tests__/update-bulletin-job.test.ts +389 -0
  257. package/src/__tests__/usage-cache-backfill-migration.test.ts +3 -1
  258. package/src/__tests__/user-plugin-loader.test.ts +191 -0
  259. package/src/__tests__/verification-control-plane-policy.test.ts +1 -22
  260. package/src/__tests__/voice-session-bridge.test.ts +39 -0
  261. package/src/__tests__/volume-security-guard.test.ts +3 -2
  262. package/src/__tests__/web-search-history.test.ts +337 -0
  263. package/src/__tests__/workspace-migration-039-drop-legacy-llm-keys.test.ts +343 -0
  264. package/src/__tests__/workspace-migration-043-release-notes-latex-rendering.test.ts +202 -0
  265. package/src/__tests__/workspace-migration-045-release-notes-meet-avatar.test.ts +210 -0
  266. package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
  267. package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
  268. package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
  269. package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
  270. package/src/__tests__/workspace-migration-drop-user-md.test.ts +11 -11
  271. package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
  272. package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +841 -0
  273. package/src/__tests__/workspace-policy.test.ts +22 -16
  274. package/src/acp/client-handler.ts +1 -2
  275. package/src/agent/loop.ts +545 -115
  276. package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
  277. package/src/approvals/guardian-request-resolvers.ts +80 -0
  278. package/src/avatar/resvg-lazy.test.ts +136 -0
  279. package/src/avatar/resvg-lazy.ts +82 -9
  280. package/src/avatar/traits-png-sync.ts +21 -1
  281. package/src/backup/__tests__/backup-worker.test.ts +2 -13
  282. package/src/backup/backup-worker.ts +3 -15
  283. package/src/browser/__tests__/operations.test.ts +163 -0
  284. package/src/browser/identifiers.ts +51 -0
  285. package/src/browser/operations.ts +660 -0
  286. package/src/browser/types.ts +81 -0
  287. package/src/bundler/app-compiler.ts +84 -1
  288. package/src/calls/call-state.ts +2 -2
  289. package/src/calls/guardian-question-copy.ts +2 -2
  290. package/src/calls/telephony-stt-routing.ts +1 -1
  291. package/src/calls/voice-session-bridge.ts +1 -0
  292. package/src/channels/__tests__/types.test.ts +3 -3
  293. package/src/channels/types.ts +6 -4
  294. package/src/cli/AGENTS.md +1 -1
  295. package/src/cli/__tests__/notifications.test.ts +87 -211
  296. package/src/cli/commands/__tests__/attachment.test.ts +438 -0
  297. package/src/cli/commands/__tests__/backup.test.ts +1 -1
  298. package/src/cli/commands/__tests__/browser.test.ts +554 -0
  299. package/src/cli/commands/__tests__/cache.test.ts +623 -0
  300. package/src/cli/commands/__tests__/email-list.test.ts +6 -0
  301. package/src/cli/commands/__tests__/email-send.test.ts +93 -1
  302. package/src/cli/commands/__tests__/image-generation.test.ts +886 -0
  303. package/src/cli/commands/__tests__/inference-send.test.ts +463 -0
  304. package/src/cli/commands/__tests__/stt-transcribe.test.ts +454 -0
  305. package/src/cli/commands/__tests__/task.test.ts +913 -0
  306. package/src/cli/commands/__tests__/tts-synthesize.test.ts +606 -0
  307. package/src/cli/commands/__tests__/ui-confirm.test.ts +650 -0
  308. package/src/cli/commands/__tests__/ui.test.ts +1215 -0
  309. package/src/cli/commands/__tests__/watchers.test.ts +716 -0
  310. package/src/cli/commands/attachment.ts +182 -0
  311. package/src/cli/commands/backup.ts +2 -2
  312. package/src/cli/commands/browser.ts +350 -0
  313. package/src/cli/commands/cache.ts +341 -0
  314. package/src/cli/commands/clients.ts +138 -0
  315. package/src/cli/commands/completions.ts +2 -12
  316. package/src/cli/commands/config.ts +6 -6
  317. package/src/cli/commands/conversations-import.ts +347 -0
  318. package/src/cli/commands/conversations.ts +69 -8
  319. package/src/cli/commands/email.ts +234 -194
  320. package/src/cli/commands/image-generation.ts +299 -0
  321. package/src/cli/commands/inference.ts +200 -0
  322. package/src/cli/commands/memory.ts +127 -17
  323. package/src/cli/commands/notifications.ts +68 -103
  324. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
  325. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  326. package/src/cli/commands/oauth/connect.ts +2 -2
  327. package/src/cli/commands/oauth/providers.ts +176 -8
  328. package/src/cli/commands/oauth/status.ts +46 -36
  329. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -1
  330. package/src/cli/commands/platform/__tests__/connect.test.ts +0 -1
  331. package/src/cli/commands/platform/__tests__/disconnect.test.ts +0 -1
  332. package/src/cli/commands/platform/__tests__/status.test.ts +0 -1
  333. package/src/cli/commands/skills.ts +3 -4
  334. package/src/cli/commands/stt.ts +339 -0
  335. package/src/cli/commands/task.ts +795 -0
  336. package/src/cli/commands/trust.ts +50 -19
  337. package/src/cli/commands/tts.ts +273 -0
  338. package/src/cli/commands/ui.ts +670 -0
  339. package/src/cli/commands/watchers.ts +509 -0
  340. package/src/cli/lib/daemon-credential-client.ts +0 -19
  341. package/src/cli/program.ts +39 -24
  342. package/src/cli.ts +0 -37
  343. package/src/config/__tests__/backup-schema.test.ts +7 -2
  344. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  345. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
  346. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
  347. package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
  348. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
  349. package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
  350. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
  351. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
  352. package/src/config/bundled-skills/media-processing/services/reduce.ts +1 -1
  353. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  354. package/src/config/bundled-skills/messaging/TOOLS.json +4 -0
  355. package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
  356. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +20 -1
  357. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +15 -1
  358. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +21 -1
  359. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +69 -12
  360. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +9 -8
  361. package/src/config/bundled-skills/schedule/SKILL.md +8 -3
  362. package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
  363. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
  364. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  365. package/src/config/bundled-tool-registry.ts +0 -190
  366. package/src/config/env.ts +7 -2
  367. package/src/config/feature-flag-registry.json +42 -10
  368. package/src/config/llm-resolver.ts +128 -0
  369. package/src/config/loader.ts +194 -10
  370. package/src/config/raw-config-utils.ts +30 -2
  371. package/src/config/sanitize-for-transfer.ts +35 -0
  372. package/src/config/schema.ts +49 -41
  373. package/src/config/schemas/analysis.ts +3 -22
  374. package/src/config/schemas/backup.ts +1 -1
  375. package/src/config/schemas/calls.ts +0 -4
  376. package/src/config/schemas/conversations.ts +16 -0
  377. package/src/config/schemas/filing.ts +2 -7
  378. package/src/config/schemas/heartbeat.ts +0 -5
  379. package/src/config/schemas/inference.ts +3 -23
  380. package/src/config/schemas/llm.ts +317 -0
  381. package/src/config/schemas/memory-processing.ts +1 -9
  382. package/src/config/schemas/notifications.ts +4 -11
  383. package/src/config/schemas/platform.ts +3 -9
  384. package/src/config/schemas/security.ts +33 -0
  385. package/src/config/schemas/services.ts +9 -4
  386. package/src/config/schemas/stt.ts +1 -0
  387. package/src/config/schemas/tts.ts +64 -0
  388. package/src/config/schemas/updates.ts +1 -1
  389. package/src/config/schemas/workspace-git.ts +3 -40
  390. package/src/config/skill-state.ts +6 -2
  391. package/src/config/skills.ts +96 -7
  392. package/src/context/__tests__/compact-prompt.test.ts +63 -0
  393. package/src/context/__tests__/microcompact.test.ts +805 -0
  394. package/src/context/estimator-calibration.ts +136 -0
  395. package/src/context/microcompact.ts +443 -0
  396. package/src/context/prompts/compact.md +26 -0
  397. package/src/context/token-estimator.ts +61 -3
  398. package/src/context/tool-result-truncation.ts +3 -63
  399. package/src/context/window-manager.ts +417 -39
  400. package/src/credential-execution/approval-bridge.ts +0 -1
  401. package/src/credential-execution/executable-discovery.ts +19 -8
  402. package/src/credential-execution/process-manager.test.ts +109 -0
  403. package/src/credential-execution/process-manager.ts +65 -2
  404. package/src/credential-health/credential-health-service.ts +19 -6
  405. package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
  406. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
  407. package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
  408. package/src/daemon/approval-generators.ts +29 -4
  409. package/src/daemon/assistant-attachments.ts +24 -13
  410. package/src/daemon/classifier.ts +2 -2
  411. package/src/daemon/config-watcher.ts +0 -3
  412. package/src/daemon/context-overflow-policy.ts +4 -13
  413. package/src/daemon/context-overflow-reducer.ts +4 -1
  414. package/src/daemon/conversation-agent-loop-handlers.ts +162 -34
  415. package/src/daemon/conversation-agent-loop.ts +1282 -599
  416. package/src/daemon/conversation-attachments.ts +2 -6
  417. package/src/daemon/conversation-error.ts +36 -1
  418. package/src/daemon/conversation-history.ts +10 -19
  419. package/src/daemon/conversation-lifecycle.ts +59 -17
  420. package/src/daemon/conversation-messaging.ts +73 -4
  421. package/src/daemon/conversation-notifiers.ts +2 -110
  422. package/src/daemon/conversation-process.ts +24 -11
  423. package/src/daemon/conversation-queue-manager.ts +3 -0
  424. package/src/daemon/conversation-runtime-assembly.ts +1063 -211
  425. package/src/daemon/conversation-slash.ts +2 -2
  426. package/src/daemon/conversation-surfaces.ts +389 -1
  427. package/src/daemon/conversation-tool-setup.ts +51 -9
  428. package/src/daemon/conversation-usage.ts +1 -1
  429. package/src/daemon/conversation.ts +197 -64
  430. package/src/daemon/external-plugins-bootstrap.ts +478 -0
  431. package/src/daemon/external-skills-bootstrap.ts +41 -0
  432. package/src/daemon/first-greeting.ts +191 -14
  433. package/src/daemon/guardian-action-generators.ts +34 -14
  434. package/src/daemon/handlers/config-model.test.ts +86 -0
  435. package/src/daemon/handlers/config-model.ts +65 -12
  436. package/src/daemon/handlers/conversations.ts +9 -2
  437. package/src/daemon/handlers/shared.ts +39 -11
  438. package/src/daemon/handlers/skills.ts +7 -3
  439. package/src/daemon/handlers/slack-channel-oauth-install.ts +197 -0
  440. package/src/daemon/lifecycle.ts +109 -82
  441. package/src/daemon/message-types/computer-use.ts +2 -34
  442. package/src/daemon/message-types/conversations.ts +63 -0
  443. package/src/daemon/message-types/messages.ts +21 -1
  444. package/src/daemon/message-types/trust.ts +0 -2
  445. package/src/daemon/parse-actual-tokens-from-error.test.ts +57 -1
  446. package/src/daemon/parse-actual-tokens-from-error.ts +66 -0
  447. package/src/daemon/pkb-context-tracker.test.ts +169 -0
  448. package/src/daemon/pkb-context-tracker.ts +125 -0
  449. package/src/daemon/pkb-reminder-builder.test.ts +70 -0
  450. package/src/daemon/pkb-reminder-builder.ts +31 -0
  451. package/src/daemon/providers-setup.ts +6 -0
  452. package/src/daemon/server.ts +122 -12
  453. package/src/daemon/shutdown-handlers.ts +2 -12
  454. package/src/daemon/tool-side-effects.ts +14 -65
  455. package/src/daemon/web-search-history.ts +126 -0
  456. package/src/events/domain-events.ts +0 -1
  457. package/src/filing/filing-service.ts +9 -10
  458. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
  459. package/src/heartbeat/heartbeat-service.ts +99 -28
  460. package/src/home/__tests__/feed-population-integration.test.ts +312 -0
  461. package/src/home/__tests__/feed-scheduler.test.ts +39 -11
  462. package/src/home/__tests__/rollup-producer.test.ts +44 -0
  463. package/src/home/assistant-feed-authoring.ts +4 -0
  464. package/src/home/emit-feed-event.ts +11 -0
  465. package/src/home/feed-scheduler.ts +20 -4
  466. package/src/home/feed-types.ts +97 -4
  467. package/src/home/relationship-state-writer.ts +2 -2
  468. package/src/home/rewrite-command-preview.ts +66 -0
  469. package/src/home/rollup-producer.ts +34 -5
  470. package/src/home/suggested-prompts.ts +101 -0
  471. package/src/ipc/__tests__/attachment-ipc.test.ts +213 -0
  472. package/src/ipc/__tests__/browser-ipc.test.ts +339 -0
  473. package/src/ipc/__tests__/cache-ipc.test.ts +266 -0
  474. package/src/ipc/__tests__/socket-path.test.ts +34 -0
  475. package/src/ipc/__tests__/task-ipc.test.ts +577 -0
  476. package/src/ipc/__tests__/ui-request-route.test.ts +495 -0
  477. package/src/ipc/__tests__/watcher-ipc.test.ts +295 -0
  478. package/src/ipc/cli-client.ts +2 -1
  479. package/src/ipc/cli-server.ts +26 -8
  480. package/src/ipc/gateway-client.ts +6 -3
  481. package/src/ipc/routes/attachment.ts +114 -0
  482. package/src/ipc/routes/browser-context.ts +63 -0
  483. package/src/ipc/routes/browser.ts +97 -0
  484. package/src/ipc/routes/cache.ts +96 -0
  485. package/src/ipc/routes/get-contact.ts +16 -0
  486. package/src/ipc/routes/index.ts +31 -1
  487. package/src/ipc/routes/list-clients.ts +31 -0
  488. package/src/ipc/routes/merge-contacts.ts +17 -0
  489. package/src/ipc/routes/notification.ts +133 -0
  490. package/src/ipc/routes/rename-conversation.ts +59 -0
  491. package/src/ipc/routes/search-contacts.ts +19 -0
  492. package/src/ipc/routes/task-queue.ts +226 -0
  493. package/src/ipc/routes/task.ts +173 -0
  494. package/src/ipc/routes/ui-request.ts +50 -0
  495. package/src/ipc/routes/upsert-contact.ts +25 -0
  496. package/src/ipc/routes/watcher.ts +203 -0
  497. package/src/ipc/socket-path.ts +76 -0
  498. package/src/media/app-icon-generator.ts +23 -46
  499. package/src/media/avatar-router.ts +26 -41
  500. package/src/media/gemini-image-service.ts +8 -41
  501. package/src/media/image-credentials.ts +73 -0
  502. package/src/media/image-service.ts +85 -0
  503. package/src/media/openai-image-service.ts +131 -0
  504. package/src/media/types.ts +46 -0
  505. package/src/memory/__tests__/conversation-analyze-job.test.ts +9 -8
  506. package/src/memory/__tests__/conversation-group-migration.test.ts +99 -0
  507. package/src/memory/admin.ts +18 -0
  508. package/src/memory/conversation-analyze-job.ts +14 -13
  509. package/src/memory/conversation-attention-store.ts +13 -6
  510. package/src/memory/conversation-crud.ts +133 -3
  511. package/src/memory/conversation-group-migration.ts +38 -6
  512. package/src/memory/conversation-queries.ts +57 -4
  513. package/src/memory/conversation-title-service.ts +32 -4
  514. package/src/memory/db-init.ts +10 -0
  515. package/src/memory/embedding-backend.ts +1 -1
  516. package/src/memory/embedding-gemini.test.ts +41 -2
  517. package/src/memory/embedding-gemini.ts +6 -1
  518. package/src/memory/graph/bootstrap.test.ts +282 -0
  519. package/src/memory/graph/bootstrap.ts +8 -5
  520. package/src/memory/graph/compaction.ts +299 -0
  521. package/src/memory/graph/consolidation.ts +4 -4
  522. package/src/memory/graph/conversation-graph-memory.ts +89 -29
  523. package/src/memory/graph/extraction.test.ts +272 -2
  524. package/src/memory/graph/extraction.ts +183 -53
  525. package/src/memory/graph/graph-search.test.ts +93 -0
  526. package/src/memory/graph/graph-search.ts +4 -1
  527. package/src/memory/graph/inspect.ts +2 -2
  528. package/src/memory/graph/narrative.ts +2 -2
  529. package/src/memory/graph/pattern-scan.ts +2 -2
  530. package/src/memory/graph/retriever.test.ts +459 -0
  531. package/src/memory/graph/retriever.ts +237 -48
  532. package/src/memory/graph/store.ts +41 -0
  533. package/src/memory/graph/tool-handlers.ts +27 -0
  534. package/src/memory/graph/tools.ts +6 -1
  535. package/src/memory/indexer.ts +5 -5
  536. package/src/memory/job-handlers/conversation-starters.ts +23 -20
  537. package/src/memory/job-handlers/summarization.ts +2 -2
  538. package/src/memory/job-utils.ts +7 -1
  539. package/src/memory/jobs/embed-pkb-file.test.ts +168 -0
  540. package/src/memory/jobs/embed-pkb-file.ts +54 -0
  541. package/src/memory/jobs-store.ts +44 -3
  542. package/src/memory/jobs-worker.ts +4 -0
  543. package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
  544. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +1 -1
  545. package/src/memory/migrations/149-oauth-tables.ts +1 -0
  546. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +2 -2
  547. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +82 -0
  548. package/src/memory/migrations/223-schedule-script-column.ts +11 -0
  549. package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
  550. package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
  551. package/src/memory/migrations/index.ts +5 -0
  552. package/src/memory/pkb/pkb-index.test.ts +369 -0
  553. package/src/memory/pkb/pkb-index.ts +255 -0
  554. package/src/memory/pkb/pkb-reconcile.test.ts +252 -0
  555. package/src/memory/pkb/pkb-reconcile.ts +148 -0
  556. package/src/memory/pkb/pkb-search.test.ts +499 -0
  557. package/src/memory/pkb/pkb-search.ts +159 -0
  558. package/src/memory/pkb/types.ts +53 -0
  559. package/src/memory/qdrant-client.test.ts +60 -0
  560. package/src/memory/qdrant-client.ts +147 -1
  561. package/src/memory/schema/infrastructure.ts +1 -0
  562. package/src/memory/schema/oauth.ts +4 -1
  563. package/src/memory/slack-thread-store.ts +37 -0
  564. package/src/messaging/providers/gmail/adapter.ts +6 -16
  565. package/src/messaging/providers/gmail/client.ts +22 -0
  566. package/src/messaging/providers/gmail/types.ts +7 -0
  567. package/src/messaging/providers/slack/adapter.ts +14 -2
  568. package/src/messaging/providers/slack/backfill.test.ts +257 -0
  569. package/src/messaging/providers/slack/backfill.ts +101 -0
  570. package/src/messaging/providers/slack/message-metadata.test.ts +316 -0
  571. package/src/messaging/providers/slack/message-metadata.ts +123 -0
  572. package/src/messaging/providers/slack/render-transcript.test.ts +1421 -0
  573. package/src/messaging/providers/slack/render-transcript.ts +501 -0
  574. package/src/messaging/style-analyzer.ts +5 -2
  575. package/src/notifications/README.md +9 -5
  576. package/src/notifications/conversation-pairing.ts +78 -19
  577. package/src/notifications/copy-composer.ts +0 -5
  578. package/src/notifications/decision-engine.ts +3 -9
  579. package/src/notifications/emit-signal.ts +1 -1
  580. package/src/notifications/preference-extractor.ts +2 -6
  581. package/src/notifications/signal.ts +1 -2
  582. package/src/oauth/AGENTS.md +1 -1
  583. package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
  584. package/src/oauth/connect-orchestrator.ts +8 -34
  585. package/src/oauth/connect-types.ts +6 -10
  586. package/src/oauth/manual-token-connection.ts +23 -0
  587. package/src/oauth/oauth-store.ts +31 -14
  588. package/src/oauth/platform-connection.test.ts +47 -0
  589. package/src/oauth/platform-connection.ts +15 -5
  590. package/src/oauth/provider-serializer.ts +6 -1
  591. package/src/oauth/seed-providers.ts +56 -106
  592. package/src/outbound-proxy/http-forwarder.ts +9 -0
  593. package/src/permissions/approval-policy.test.ts +1223 -0
  594. package/src/permissions/approval-policy.ts +309 -0
  595. package/src/permissions/arg-parser.test.ts +161 -0
  596. package/src/permissions/arg-parser.ts +141 -0
  597. package/src/permissions/bash-risk-classifier.test.ts +1620 -0
  598. package/src/permissions/bash-risk-classifier.ts +950 -0
  599. package/src/permissions/checker.ts +348 -711
  600. package/src/permissions/command-registry.test.ts +774 -0
  601. package/src/permissions/command-registry.ts +1005 -0
  602. package/src/permissions/defaults.ts +28 -79
  603. package/src/permissions/file-risk-classifier.test.ts +535 -0
  604. package/src/permissions/file-risk-classifier.ts +274 -0
  605. package/src/permissions/gateway-threshold-reader.ts +196 -0
  606. package/src/permissions/prompter.ts +4 -0
  607. package/src/permissions/risk-types.ts +262 -0
  608. package/src/permissions/schedule-risk-classifier.test.ts +129 -0
  609. package/src/permissions/schedule-risk-classifier.ts +85 -0
  610. package/src/permissions/secret-prompter.ts +53 -2
  611. package/src/permissions/shell-identity.ts +2 -42
  612. package/src/permissions/skill-risk-classifier.test.ts +311 -0
  613. package/src/permissions/skill-risk-classifier.ts +214 -0
  614. package/src/permissions/trust-client.ts +52 -25
  615. package/src/permissions/trust-store-interface.ts +1 -6
  616. package/src/permissions/trust-store.ts +161 -62
  617. package/src/permissions/types.ts +25 -14
  618. package/src/permissions/web-risk-classifier.test.ts +170 -0
  619. package/src/permissions/web-risk-classifier.ts +89 -0
  620. package/src/permissions/workspace-policy.ts +9 -19
  621. package/src/platform/client.ts +19 -1
  622. package/src/plugins/defaults/circuit-breaker.ts +146 -0
  623. package/src/plugins/defaults/compaction.ts +145 -0
  624. package/src/plugins/defaults/empty-response.ts +126 -0
  625. package/src/plugins/defaults/history-repair.ts +85 -0
  626. package/src/plugins/defaults/index.ts +116 -0
  627. package/src/plugins/defaults/injectors.ts +491 -0
  628. package/src/plugins/defaults/llm-call.ts +82 -0
  629. package/src/plugins/defaults/memory-retrieval.ts +226 -0
  630. package/src/plugins/defaults/overflow-reduce.ts +181 -0
  631. package/src/plugins/defaults/persistence.ts +129 -0
  632. package/src/plugins/defaults/title-generate.ts +95 -0
  633. package/src/plugins/defaults/token-estimate.ts +104 -0
  634. package/src/plugins/defaults/tool-error.ts +126 -0
  635. package/src/plugins/defaults/tool-execute.ts +89 -0
  636. package/src/plugins/defaults/tool-result-truncate.ts +88 -0
  637. package/src/plugins/pipeline.ts +316 -0
  638. package/src/plugins/plugin-skill-contributions.ts +292 -0
  639. package/src/plugins/registry.ts +241 -0
  640. package/src/plugins/types.ts +1134 -0
  641. package/src/plugins/user-loader.ts +177 -0
  642. package/src/prompts/persona-resolver.ts +3 -3
  643. package/src/prompts/system-prompt.ts +19 -20
  644. package/src/prompts/templates/BOOTSTRAP.md +27 -77
  645. package/src/prompts/templates/SOUL.md +2 -2
  646. package/src/prompts/update-bulletin-job.ts +190 -0
  647. package/src/providers/__tests__/context-overflow-error.test.ts +328 -0
  648. package/src/providers/__tests__/provider-env-vars.test.ts +102 -0
  649. package/src/providers/__tests__/retry-callsite.test.ts +424 -0
  650. package/src/providers/anthropic/client.ts +183 -14
  651. package/src/providers/call-site-routing.ts +71 -0
  652. package/src/providers/gemini/client.ts +65 -2
  653. package/src/providers/managed-proxy/constants.ts +2 -1
  654. package/src/providers/model-catalog.ts +524 -33
  655. package/src/providers/model-intents.ts +4 -4
  656. package/src/providers/openai/chat-completions-provider.ts +57 -1
  657. package/src/providers/openai/responses-provider.ts +86 -9
  658. package/src/providers/openrouter/client.ts +80 -9
  659. package/src/providers/provider-env-vars.ts +56 -0
  660. package/src/providers/provider-send-message.ts +22 -5
  661. package/src/providers/ratelimit.ts +4 -0
  662. package/src/providers/registry.ts +19 -8
  663. package/src/providers/retry.ts +174 -39
  664. package/src/providers/speech-to-text/__tests__/resolve.test.ts +55 -0
  665. package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
  666. package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
  667. package/src/providers/speech-to-text/google-gemini-live-stream.ts +4 -4
  668. package/src/providers/speech-to-text/provider-catalog.ts +17 -0
  669. package/src/providers/speech-to-text/resolve.ts +7 -0
  670. package/src/providers/speech-to-text/xai-realtime.test.ts +646 -0
  671. package/src/providers/speech-to-text/xai-realtime.ts +821 -0
  672. package/src/providers/speech-to-text/xai.test.ts +155 -0
  673. package/src/providers/speech-to-text/xai.ts +97 -0
  674. package/src/providers/types.ts +93 -3
  675. package/src/runtime/AGENTS.md +27 -18
  676. package/src/runtime/__tests__/agent-wake.test.ts +43 -2
  677. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
  678. package/src/runtime/__tests__/client-registry.test.ts +293 -0
  679. package/src/runtime/__tests__/interactive-ui.test.ts +673 -0
  680. package/src/runtime/agent-wake.ts +63 -22
  681. package/src/runtime/auth/route-policy.ts +4 -0
  682. package/src/runtime/btw-sidechain.ts +13 -3
  683. package/src/runtime/channel-reply-delivery.ts +106 -2
  684. package/src/runtime/client-registry.ts +261 -0
  685. package/src/runtime/decision-token.ts +116 -0
  686. package/src/runtime/gateway-client.ts +2 -2
  687. package/src/runtime/http-router.ts +32 -0
  688. package/src/runtime/http-server.ts +129 -9
  689. package/src/runtime/http-types.ts +23 -3
  690. package/src/runtime/interactive-ui.ts +362 -0
  691. package/src/runtime/invite-instruction-generator.ts +2 -2
  692. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +176 -0
  693. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +390 -0
  694. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +221 -0
  695. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +1540 -0
  696. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +453 -0
  697. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +222 -0
  698. package/src/runtime/migrations/gcs-signed-url.ts +162 -0
  699. package/src/runtime/migrations/vbundle-builder.ts +1 -22
  700. package/src/runtime/migrations/vbundle-importer.ts +154 -9
  701. package/src/runtime/migrations/vbundle-metadata-merge.ts +124 -0
  702. package/src/runtime/migrations/vbundle-streaming-importer.ts +2522 -0
  703. package/src/runtime/migrations/vbundle-streaming-validator.ts +244 -0
  704. package/src/runtime/migrations/vbundle-tar-stream.ts +217 -0
  705. package/src/runtime/migrations/vbundle-validator.ts +15 -6
  706. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +111 -0
  707. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +114 -75
  708. package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +246 -0
  709. package/src/runtime/routes/approval-prompt-ts-tracker.ts +78 -0
  710. package/src/runtime/routes/approval-routes.ts +29 -17
  711. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +9 -0
  712. package/src/runtime/routes/avatar-routes.ts +20 -4
  713. package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
  714. package/src/runtime/routes/btw-routes.ts +1 -4
  715. package/src/runtime/routes/conversation-management-routes.ts +20 -2
  716. package/src/runtime/routes/conversation-routes.ts +351 -138
  717. package/src/runtime/routes/debug-routes.ts +1 -1
  718. package/src/runtime/routes/diagnostics-routes.ts +6 -4
  719. package/src/runtime/routes/events-routes.ts +16 -0
  720. package/src/runtime/routes/guardian-approval-interception.ts +33 -3
  721. package/src/runtime/routes/guardian-approval-prompt.ts +13 -3
  722. package/src/runtime/routes/home-feed-routes.ts +120 -2
  723. package/src/runtime/routes/inbound-message-handler.ts +987 -2
  724. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +113 -2
  725. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -3
  726. package/src/runtime/routes/inbound-stages/edit-intercept.ts +129 -6
  727. package/src/runtime/routes/integrations/slack/channel.ts +25 -3
  728. package/src/runtime/routes/llm-context-normalization.ts +23 -1
  729. package/src/runtime/routes/memory-item-routes.test.ts +1 -0
  730. package/src/runtime/routes/migration-routes.ts +720 -127
  731. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
  732. package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
  733. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
  734. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
  735. package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
  736. package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
  737. package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
  738. package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
  739. package/src/runtime/routes/playground/deps.ts +56 -0
  740. package/src/runtime/routes/playground/force-compact.ts +73 -0
  741. package/src/runtime/routes/playground/guard.ts +37 -0
  742. package/src/runtime/routes/playground/index.ts +28 -0
  743. package/src/runtime/routes/playground/inject-failures.ts +159 -0
  744. package/src/runtime/routes/playground/reset-circuit.ts +115 -0
  745. package/src/runtime/routes/playground/seed-conversation.ts +139 -0
  746. package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
  747. package/src/runtime/routes/playground/state.ts +78 -0
  748. package/src/runtime/routes/schedule-routes.ts +89 -8
  749. package/src/runtime/routes/settings-routes.ts +4 -2
  750. package/src/runtime/routes/trust-rules-routes.ts +30 -14
  751. package/src/runtime/routes/work-items-routes.test.ts +1 -1
  752. package/src/runtime/routes/work-items-routes.ts +3 -2
  753. package/src/runtime/services/__tests__/analyze-conversation.test.ts +25 -43
  754. package/src/runtime/services/analyze-conversation.ts +12 -16
  755. package/src/runtime/skill-route-registry.ts +97 -15
  756. package/src/schedule/run-script.ts +68 -0
  757. package/src/schedule/schedule-store.ts +7 -1
  758. package/src/schedule/scheduler.ts +56 -8
  759. package/src/security/__tests__/provider-key-env-fallback.test.ts +119 -0
  760. package/src/security/__tests__/untrusted-content.test.ts +109 -0
  761. package/src/security/oauth2.ts +98 -35
  762. package/src/security/secure-keys.ts +7 -8
  763. package/src/security/token-manager.ts +27 -13
  764. package/src/security/untrusted-content.ts +102 -0
  765. package/src/skills/catalog-cache.ts +35 -9
  766. package/src/skills/catalog-install.ts +31 -3
  767. package/src/skills/skill-cache-store.ts +97 -0
  768. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +76 -0
  769. package/src/stt/daemon-batch-transcriber.ts +33 -0
  770. package/src/stt/stt-stream-session.ts +8 -1
  771. package/src/stt/types.ts +5 -1
  772. package/src/subagent/manager.ts +41 -13
  773. package/src/tasks/ephemeral-permissions.ts +9 -4
  774. package/src/telemetry/usage-telemetry-reporter.ts +27 -5
  775. package/src/tools/browser/__tests__/browser-status.test.ts +234 -2
  776. package/src/tools/browser/browser-execution.ts +150 -54
  777. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
  778. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
  779. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +22 -0
  780. package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
  781. package/src/tools/browser/cdp-client/factory.ts +15 -4
  782. package/src/tools/credentials/tool-policy.ts +39 -5
  783. package/src/tools/credentials/vault.ts +9 -4
  784. package/src/tools/executor.ts +129 -73
  785. package/src/tools/filesystem/write.ts +52 -0
  786. package/src/tools/host-terminal/host-shell.ts +45 -5
  787. package/src/tools/memory/register.test.ts +185 -0
  788. package/src/tools/memory/register.ts +3 -1
  789. package/src/tools/network/script-proxy/session-manager.ts +37 -1
  790. package/src/tools/network/web-fetch.ts +20 -10
  791. package/src/tools/network/web-search.ts +19 -4
  792. package/src/tools/permission-checker.ts +116 -46
  793. package/src/tools/policy-context.ts +29 -8
  794. package/src/tools/registry.ts +195 -6
  795. package/src/tools/schedule/create.ts +23 -8
  796. package/src/tools/schedule/update.ts +3 -1
  797. package/src/tools/secret-detection-handler.ts +0 -51
  798. package/src/tools/side-effects.ts +0 -11
  799. package/src/tools/skills/execute.ts +2 -2
  800. package/src/tools/skills/sandbox-runner.ts +5 -2
  801. package/src/tools/system/avatar-generator.ts +6 -2
  802. package/src/tools/terminal/backends/native.ts +51 -2
  803. package/src/tools/terminal/safe-env.ts +3 -2
  804. package/src/tools/terminal/shell.ts +1 -0
  805. package/src/tools/tool-manifest.ts +6 -21
  806. package/src/tools/types.ts +40 -5
  807. package/src/tools/verification-control-plane-policy.ts +1 -1
  808. package/src/tts/__tests__/provider-adapters.test.ts +240 -13
  809. package/src/tts/provider-catalog.ts +18 -0
  810. package/src/tts/providers/index.ts +2 -0
  811. package/src/tts/providers/xai-provider.ts +224 -0
  812. package/src/tts/types.ts +46 -0
  813. package/src/types/tar-stream.d.ts +66 -0
  814. package/src/util/json.ts +17 -0
  815. package/src/util/platform.ts +9 -4
  816. package/src/util/pricing.ts +41 -8
  817. package/src/watcher/engine.ts +1 -1
  818. package/src/watcher/providers/google-calendar.ts +134 -8
  819. package/src/watcher/providers/outlook-calendar.ts +42 -2
  820. package/src/workspace/git-service.ts +23 -4
  821. package/src/workspace/migrations/006-services-config.ts +2 -4
  822. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
  823. package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +516 -0
  824. package/src/workspace/migrations/039-drop-legacy-llm-keys.ts +171 -0
  825. package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +154 -0
  826. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +56 -0
  827. package/src/workspace/migrations/042-fix-backfill-google-gmail-settings-scope.ts +70 -0
  828. package/src/workspace/migrations/043-release-notes-latex-rendering.ts +75 -0
  829. package/src/workspace/migrations/044-bump-stale-provider-stream-timeout.ts +51 -0
  830. package/src/workspace/migrations/045-release-notes-meet-avatar.ts +130 -0
  831. package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
  832. package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
  833. package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
  834. package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
  835. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
  836. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
  837. package/src/workspace/migrations/AGENTS.md +1 -1
  838. package/src/workspace/migrations/registry.ts +28 -0
  839. package/src/workspace/provider-commit-message-generator.ts +19 -38
  840. package/tsconfig.json +1 -1
  841. package/hook-templates/debug-prompt-logger/hook.json +0 -7
  842. package/hook-templates/debug-prompt-logger/run.sh +0 -66
  843. package/src/__tests__/context-overflow-approval.test.ts +0 -156
  844. package/src/__tests__/gmail-archive-fallback.test.ts +0 -193
  845. package/src/__tests__/gmail-archive-gate.test.ts +0 -246
  846. package/src/__tests__/gmail-preferences.test.ts +0 -117
  847. package/src/__tests__/hooks-blocking.test.ts +0 -178
  848. package/src/__tests__/hooks-cli.test.ts +0 -182
  849. package/src/__tests__/hooks-config.test.ts +0 -108
  850. package/src/__tests__/hooks-discovery.test.ts +0 -211
  851. package/src/__tests__/hooks-integration.test.ts +0 -196
  852. package/src/__tests__/hooks-manager.test.ts +0 -226
  853. package/src/__tests__/hooks-runner.test.ts +0 -175
  854. package/src/__tests__/hooks-settings.test.ts +0 -160
  855. package/src/__tests__/hooks-templates.test.ts +0 -169
  856. package/src/__tests__/hooks-ts-runner.test.ts +0 -170
  857. package/src/__tests__/hooks-watch.test.ts +0 -112
  858. package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
  859. package/src/__tests__/oauth-scope-policy.test.ts +0 -180
  860. package/src/__tests__/outlook-attachments.test.ts +0 -301
  861. package/src/__tests__/outlook-automation-tools.test.ts +0 -425
  862. package/src/__tests__/outlook-categories.test.ts +0 -212
  863. package/src/__tests__/outlook-compose-tools.test.ts +0 -325
  864. package/src/__tests__/outlook-declutter-tools.test.ts +0 -585
  865. package/src/__tests__/outlook-follow-up.test.ts +0 -196
  866. package/src/__tests__/outlook-trash.test.ts +0 -77
  867. package/src/__tests__/outlook-unsubscribe.test.ts +0 -279
  868. package/src/__tests__/send-notification-tool.test.ts +0 -83
  869. package/src/__tests__/update-bulletin-format.test.ts +0 -181
  870. package/src/__tests__/update-bulletin-state.test.ts +0 -135
  871. package/src/__tests__/update-bulletin.test.ts +0 -478
  872. package/src/__tests__/update-template-contract.test.ts +0 -29
  873. package/src/cli/commands/doctor.ts +0 -341
  874. package/src/cli/commands/shotgun.ts +0 -266
  875. package/src/config/bundled-skills/browser/SKILL.md +0 -88
  876. package/src/config/bundled-skills/browser/TOOLS.json +0 -516
  877. package/src/config/bundled-skills/browser/tools/browser-attach.ts +0 -12
  878. package/src/config/bundled-skills/browser/tools/browser-click.ts +0 -12
  879. package/src/config/bundled-skills/browser/tools/browser-close.ts +0 -12
  880. package/src/config/bundled-skills/browser/tools/browser-detach.ts +0 -12
  881. package/src/config/bundled-skills/browser/tools/browser-extract.ts +0 -12
  882. package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +0 -12
  883. package/src/config/bundled-skills/browser/tools/browser-hover.ts +0 -12
  884. package/src/config/bundled-skills/browser/tools/browser-navigate.ts +0 -12
  885. package/src/config/bundled-skills/browser/tools/browser-press-key.ts +0 -12
  886. package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +0 -12
  887. package/src/config/bundled-skills/browser/tools/browser-scroll.ts +0 -12
  888. package/src/config/bundled-skills/browser/tools/browser-select-option.ts +0 -12
  889. package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +0 -12
  890. package/src/config/bundled-skills/browser/tools/browser-status.ts +0 -12
  891. package/src/config/bundled-skills/browser/tools/browser-type.ts +0 -12
  892. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +0 -49
  893. package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +0 -12
  894. package/src/config/bundled-skills/chatgpt-import/SKILL.md +0 -27
  895. package/src/config/bundled-skills/chatgpt-import/TOOLS.json +0 -27
  896. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +0 -378
  897. package/src/config/bundled-skills/conversations/SKILL.md +0 -20
  898. package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
  899. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -66
  900. package/src/config/bundled-skills/gmail/SKILL.md +0 -221
  901. package/src/config/bundled-skills/gmail/TOOLS.json +0 -588
  902. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +0 -256
  903. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +0 -112
  904. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +0 -44
  905. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +0 -81
  906. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +0 -108
  907. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +0 -146
  908. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +0 -53
  909. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +0 -347
  910. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +0 -59
  911. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +0 -82
  912. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +0 -26
  913. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +0 -347
  914. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +0 -29
  915. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +0 -122
  916. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +0 -67
  917. package/src/config/bundled-skills/gmail/tools/scan-result-store.ts +0 -100
  918. package/src/config/bundled-skills/gmail/tools/shared.ts +0 -47
  919. package/src/config/bundled-skills/google-calendar/SKILL.md +0 -51
  920. package/src/config/bundled-skills/google-calendar/TOOLS.json +0 -226
  921. package/src/config/bundled-skills/google-calendar/calendar-client.ts +0 -223
  922. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +0 -27
  923. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +0 -48
  924. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +0 -19
  925. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +0 -36
  926. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +0 -58
  927. package/src/config/bundled-skills/google-calendar/tools/shared.ts +0 -17
  928. package/src/config/bundled-skills/google-calendar/types.ts +0 -97
  929. package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
  930. package/src/config/bundled-skills/notifications/SKILL.md +0 -40
  931. package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
  932. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
  933. package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
  934. package/src/config/bundled-skills/outlook/SKILL.md +0 -196
  935. package/src/config/bundled-skills/outlook/TOOLS.json +0 -530
  936. package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +0 -85
  937. package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +0 -77
  938. package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +0 -84
  939. package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +0 -94
  940. package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +0 -49
  941. package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +0 -237
  942. package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +0 -161
  943. package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +0 -32
  944. package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +0 -272
  945. package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +0 -29
  946. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +0 -129
  947. package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +0 -87
  948. package/src/config/bundled-skills/outlook/tools/shared.ts +0 -20
  949. package/src/config/bundled-skills/outlook-calendar/SKILL.md +0 -51
  950. package/src/config/bundled-skills/outlook-calendar/TOOLS.json +0 -221
  951. package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +0 -252
  952. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +0 -53
  953. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +0 -74
  954. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +0 -18
  955. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +0 -46
  956. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +0 -36
  957. package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +0 -17
  958. package/src/config/bundled-skills/outlook-calendar/types.ts +0 -120
  959. package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
  960. package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
  961. package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
  962. package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
  963. package/src/config/bundled-skills/slack/SKILL.md +0 -108
  964. package/src/config/bundled-skills/tasks/SKILL.md +0 -37
  965. package/src/config/bundled-skills/tasks/TOOLS.json +0 -353
  966. package/src/config/bundled-skills/tasks/icon.svg +0 -34
  967. package/src/config/bundled-skills/tasks/tools/task-delete.ts +0 -12
  968. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +0 -12
  969. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +0 -12
  970. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +0 -12
  971. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +0 -12
  972. package/src/config/bundled-skills/tasks/tools/task-list.ts +0 -12
  973. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +0 -12
  974. package/src/config/bundled-skills/tasks/tools/task-run.ts +0 -12
  975. package/src/config/bundled-skills/tasks/tools/task-save.ts +0 -12
  976. package/src/config/bundled-skills/watcher/SKILL.md +0 -31
  977. package/src/config/bundled-skills/watcher/TOOLS.json +0 -167
  978. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +0 -12
  979. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +0 -12
  980. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +0 -12
  981. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +0 -12
  982. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +0 -12
  983. package/src/daemon/context-overflow-approval.ts +0 -52
  984. package/src/daemon/watch-handler.ts +0 -399
  985. package/src/hooks/cli.ts +0 -253
  986. package/src/hooks/config.ts +0 -100
  987. package/src/hooks/discovery.ts +0 -135
  988. package/src/hooks/manager.ts +0 -179
  989. package/src/hooks/runner.ts +0 -117
  990. package/src/hooks/templates.ts +0 -77
  991. package/src/hooks/types.ts +0 -75
  992. package/src/oauth/scope-policy.ts +0 -89
  993. package/src/prompts/templates/UPDATES.md +0 -50
  994. package/src/prompts/update-bulletin-format.ts +0 -85
  995. package/src/prompts/update-bulletin-state.ts +0 -58
  996. package/src/prompts/update-bulletin-template-path.ts +0 -13
  997. package/src/prompts/update-bulletin.ts +0 -139
  998. package/src/runtime/gateway-internal-client.ts +0 -94
  999. package/src/runtime/routes/watch-routes.ts +0 -156
  1000. package/src/shared/provider-env-vars.ts +0 -19
  1001. package/src/signals/shotgun.ts +0 -203
  1002. package/src/tools/watch/screen-watch.ts +0 -144
  1003. package/src/tools/watch/watch-state.ts +0 -142
  1004. package/src/tools/watcher/create.ts +0 -86
  1005. package/src/tools/watcher/delete.ts +0 -36
  1006. package/src/tools/watcher/digest.ts +0 -54
  1007. package/src/tools/watcher/list.ts +0 -83
  1008. 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(baseMessages, fullOptions);
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("<pkb>");
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("<pkb>");
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(baseMessages, {
686
- ...fullOptions,
687
- mode: "full",
688
- });
689
- const minimalResult = applyRuntimeInjections(baseMessages, {
690
- ...fullOptions,
691
- mode: "minimal",
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
- // injectNowScratchpad
714
- // ---------------------------------------------------------------------------
715
-
716
- describe("injectNowScratchpad", () => {
717
- const baseUserMessage: Message = {
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
- describe("injectSubagentStatus", () => {
1704
- test("appends status block to user message", () => {
1705
- const msg: Message = {
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
+ });