@vellumai/assistant 0.6.3 → 0.6.5

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