@vellumai/assistant 0.11.1 → 0.11.2-staging.2

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 (318) hide show
  1. package/Dockerfile +1 -3
  2. package/README.md +1 -1
  3. package/eslint.config.mjs +28 -8
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
  12. package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
  13. package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
  14. package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
  15. package/openapi.yaml +292 -5
  16. package/package.json +1 -1
  17. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  18. package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
  19. package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
  20. package/src/__tests__/compaction-events.test.ts +139 -2
  21. package/src/__tests__/config-loader-backfill.test.ts +35 -3
  22. package/src/__tests__/config-schema.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +180 -7
  25. package/src/__tests__/conversation-error.test.ts +211 -4
  26. package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
  27. package/src/__tests__/conversation-queue.test.ts +126 -0
  28. package/src/__tests__/conversation-retry-route.test.ts +46 -7
  29. package/src/__tests__/conversation-slash.test.ts +4 -2
  30. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  31. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  32. package/src/__tests__/document-create-dedupe.test.ts +2 -1
  33. package/src/__tests__/document-find-replace.test.ts +2 -1
  34. package/src/__tests__/document-tool-security.test.ts +2 -1
  35. package/src/__tests__/document-update-default-surface.test.ts +2 -1
  36. package/src/__tests__/document-workspace-file.test.ts +467 -0
  37. package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
  38. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  39. package/src/__tests__/http-user-message-parity.test.ts +66 -0
  40. package/src/__tests__/list-messages-attachments.test.ts +87 -0
  41. package/src/__tests__/list-messages-provider-error.test.ts +143 -0
  42. package/src/__tests__/list-messages-system-card.test.ts +100 -0
  43. package/src/__tests__/llm-resolver.test.ts +19 -9
  44. package/src/__tests__/managed-profile-guard.test.ts +23 -0
  45. package/src/__tests__/notification-platform-adapter.test.ts +130 -2
  46. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  47. package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
  48. package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
  49. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  50. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  51. package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
  52. package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
  53. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
  54. package/src/__tests__/skills.test.ts +27 -0
  55. package/src/__tests__/slack-channels-routes.test.ts +0 -2
  56. package/src/__tests__/slack-share-routes.test.ts +0 -3
  57. package/src/__tests__/slack-users-routes.test.ts +0 -2
  58. package/src/__tests__/subagent-tools.test.ts +11 -0
  59. package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
  60. package/src/__tests__/tool-result-spool.test.ts +5 -4
  61. package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
  62. package/src/__tests__/turn-events-store.test.ts +26 -0
  63. package/src/__tests__/ui-visual-surface.test.ts +695 -0
  64. package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
  65. package/src/__tests__/visible-app-context.test.ts +189 -0
  66. package/src/__tests__/workspace-git-service.test.ts +173 -33
  67. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
  68. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  69. package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
  70. package/src/agent/loop.ts +36 -7
  71. package/src/api/events/context-window-usage.ts +31 -0
  72. package/src/api/events/notification-intent.ts +8 -0
  73. package/src/api/events/ui-surface-pending.ts +35 -0
  74. package/src/api/index.ts +14 -0
  75. package/src/api/responses/conversation-message.ts +25 -4
  76. package/src/api/surfaces.ts +90 -1
  77. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  78. package/src/approvals/guardian-decision-primitive.ts +4 -0
  79. package/src/calls/__tests__/call-setup-router.test.ts +156 -26
  80. package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
  81. package/src/calls/call-setup-router.ts +80 -36
  82. package/src/calls/voice-session-bridge.ts +173 -26
  83. package/src/cli/commands/inference.help.ts +3 -3
  84. package/src/cli/commands/notifications.help.ts +3 -2
  85. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
  86. package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
  87. package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
  88. package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
  89. package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
  90. package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
  91. package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
  92. package/src/cli/commands/platform/index.help.ts +92 -0
  93. package/src/cli/commands/platform/index.ts +7 -0
  94. package/src/cli/commands/platform/invoices.ts +132 -0
  95. package/src/cli/commands/usage.help.ts +1 -1
  96. package/src/cli/lib/list-installed-plugins.ts +2 -1
  97. package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
  98. package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
  99. package/src/config/assistant-feature-flags.ts +7 -2
  100. package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
  101. package/src/config/bundled-skills/subagent/SKILL.md +3 -1
  102. package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
  103. package/src/config/bundled-skills/visualize/SKILL.md +163 -0
  104. package/src/config/call-site-defaults.ts +3 -2
  105. package/src/config/default-profile-catalog.ts +75 -70
  106. package/src/config/default-profile-names.ts +13 -28
  107. package/src/config/env-registry.ts +1 -0
  108. package/src/config/feature-flag-registry.json +16 -0
  109. package/src/config/llm-resolver.ts +21 -5
  110. package/src/config/loader.ts +18 -11
  111. package/src/config/schemas/llm.ts +1 -9
  112. package/src/config/schemas/memory-retrospective.ts +9 -0
  113. package/src/config/schemas/monitoring.ts +28 -2
  114. package/src/config/schemas/workspace-git.ts +15 -0
  115. package/src/config/seed-inference-profiles.ts +19 -5
  116. package/src/context/post-turn-tool-result-truncation.ts +2 -2
  117. package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
  118. package/src/conversations/message-consolidation.ts +17 -15
  119. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
  120. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
  121. package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
  122. package/src/daemon/conversation-agent-loop.ts +107 -22
  123. package/src/daemon/conversation-error.ts +169 -48
  124. package/src/daemon/conversation-messaging.ts +87 -4
  125. package/src/daemon/conversation-process.ts +57 -45
  126. package/src/daemon/conversation-runtime-assembly.ts +57 -0
  127. package/src/daemon/conversation-store.ts +36 -1
  128. package/src/daemon/conversation-surfaces.ts +68 -6
  129. package/src/daemon/conversation-turn-finalize.ts +76 -18
  130. package/src/daemon/conversation.ts +122 -27
  131. package/src/daemon/lifecycle.ts +9 -0
  132. package/src/daemon/message-types/conversations.ts +8 -0
  133. package/src/daemon/message-types/surfaces.ts +3 -0
  134. package/src/documents/document-store.ts +247 -10
  135. package/src/home/__tests__/feed-types.test.ts +43 -8
  136. package/src/home/__tests__/feed-writer.test.ts +59 -0
  137. package/src/home/feed-types.ts +34 -7
  138. package/src/home/feed-writer.ts +15 -6
  139. package/src/live-voice/__tests__/activity-label.test.ts +95 -0
  140. package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
  141. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
  142. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  143. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  144. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  145. package/src/live-voice/activity-label.ts +169 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +282 -41
  148. package/src/live-voice/protocol.ts +35 -0
  149. package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
  150. package/src/messaging/providers/slack/adapter.ts +86 -19
  151. package/src/messaging/providers/slack/api.test.ts +85 -1
  152. package/src/messaging/providers/slack/api.ts +121 -288
  153. package/src/messaging/providers/slack/client.ts +20 -251
  154. package/src/messaging/providers/slack/send.test.ts +4 -9
  155. package/src/messaging/providers/slack/send.ts +1 -1
  156. package/src/messaging/providers/slack/types.ts +5 -0
  157. package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
  158. package/src/messaging/providers/slack/web-api-transport.ts +367 -0
  159. package/src/messaging/providers/slack/withdraw.ts +6 -14
  160. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  161. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  162. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  163. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  164. package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
  165. package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
  166. package/src/monitoring/file-descriptors.ts +262 -0
  167. package/src/monitoring/process-memory.ts +4 -11
  168. package/src/monitoring/resource-sampler.ts +5 -0
  169. package/src/monitoring/worker.ts +12 -0
  170. package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
  171. package/src/notifications/__tests__/broadcaster.test.ts +347 -7
  172. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  173. package/src/notifications/__tests__/decision-engine.test.ts +349 -29
  174. package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
  175. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  176. package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
  177. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  178. package/src/notifications/adapters/macos.ts +3 -0
  179. package/src/notifications/adapters/platform.ts +90 -14
  180. package/src/notifications/adapters/telegram.ts +6 -4
  181. package/src/notifications/assistant-reply-producer.ts +219 -0
  182. package/src/notifications/broadcaster.ts +523 -317
  183. package/src/notifications/copy-composer.ts +27 -6
  184. package/src/notifications/decision-engine.ts +154 -77
  185. package/src/notifications/deterministic-checks.ts +6 -7
  186. package/src/notifications/edit-notification.ts +8 -4
  187. package/src/notifications/emit-signal.ts +49 -16
  188. package/src/notifications/guardian-delivery-recorder.ts +8 -2
  189. package/src/notifications/home-feed-side-effect.ts +43 -6
  190. package/src/notifications/notification-utils.ts +39 -4
  191. package/src/notifications/signal.ts +10 -0
  192. package/src/notifications/types.ts +17 -0
  193. package/src/persistence/bookmark-crud.ts +18 -9
  194. package/src/persistence/conversation-attention-store.ts +26 -0
  195. package/src/persistence/conversation-crud.ts +79 -32
  196. package/src/persistence/conversation-queries.ts +30 -15
  197. package/src/persistence/conversation-title-service.ts +5 -170
  198. package/src/persistence/conversation-types.ts +192 -1
  199. package/src/persistence/db-init.ts +14 -1
  200. package/src/persistence/db-maintenance.ts +5 -4
  201. package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
  202. package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
  203. package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
  204. package/src/persistence/embeddings/embedding-local.ts +2 -1
  205. package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
  206. package/src/persistence/embeddings/plugin-index.ts +84 -12
  207. package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
  208. package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
  209. package/src/persistence/planner-statistics.ts +14 -0
  210. package/src/persistence/schema/documents.ts +26 -11
  211. package/src/persistence/steps.ts +2 -0
  212. package/src/platform/client.test.ts +172 -2
  213. package/src/platform/client.ts +178 -55
  214. package/src/plugin-api/conversation-turn.ts +13 -0
  215. package/src/plugin-api/model-profiles.test.ts +6 -2
  216. package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
  217. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
  218. package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
  219. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
  220. package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
  224. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
  225. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
  226. package/src/plugins/defaults/memory/host-utils.ts +5 -0
  227. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
  228. package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
  229. package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
  230. package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
  231. package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
  232. package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
  233. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
  234. package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
  235. package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
  236. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
  237. package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
  238. package/src/plugins/defaults/memory/v2/injection.ts +48 -3
  239. package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
  240. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
  241. package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
  242. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  243. package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
  244. package/src/plugins/types.ts +17 -0
  245. package/src/prompts/templates/system-sections.ts +2 -2
  246. package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
  247. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
  248. package/src/providers/call-site-routing.ts +77 -14
  249. package/src/providers/connection-resolution.ts +42 -2
  250. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  251. package/src/providers/inference/adapter-factory.ts +70 -12
  252. package/src/providers/model-catalog.ts +0 -11
  253. package/src/providers/model-intents.ts +11 -0
  254. package/src/providers/registry.ts +8 -0
  255. package/src/providers/retry.ts +85 -17
  256. package/src/providers/types.ts +8 -1
  257. package/src/runtime/__tests__/agent-wake.test.ts +181 -0
  258. package/src/runtime/agent-wake.ts +132 -9
  259. package/src/runtime/channel-approval-types.ts +25 -0
  260. package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
  261. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
  262. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
  263. package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
  264. package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
  265. package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
  266. package/src/runtime/routes/consolidation-routes.ts +1 -1
  267. package/src/runtime/routes/conversation-management-routes.ts +18 -6
  268. package/src/runtime/routes/conversation-query-routes.ts +14 -23
  269. package/src/runtime/routes/conversation-routes.ts +113 -8
  270. package/src/runtime/routes/credential-routes.ts +1 -5
  271. package/src/runtime/routes/documents-routes.ts +207 -12
  272. package/src/runtime/routes/identity-routes.ts +3 -93
  273. package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
  274. package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
  275. package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
  276. package/src/runtime/routes/notification-routes.ts +4 -1
  277. package/src/runtime/routes/platform-routes.ts +238 -3
  278. package/src/runtime/routes/playground/guard.ts +1 -2
  279. package/src/runtime/routes/slack-channel-routes.ts +2 -4
  280. package/src/runtime/routes/workspace-commit-routes.ts +49 -2
  281. package/src/runtime/routes/workspace-routes.ts +11 -12
  282. package/src/runtime/routes/workspace-utils.ts +49 -2
  283. package/src/runtime/services/conversation-serializer.ts +2 -4
  284. package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
  285. package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
  286. package/src/subagent/__tests__/consult-context.test.ts +84 -0
  287. package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
  288. package/src/subagent/consult-context.ts +410 -0
  289. package/src/subagent/consult-prompt.ts +38 -5
  290. package/src/subagent/manager.ts +9 -4
  291. package/src/subagent/types.ts +8 -0
  292. package/src/telemetry/telemetry-event-sources.ts +7 -0
  293. package/src/telemetry/telemetry-wire-source.json +1 -1
  294. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  295. package/src/telemetry/turn-events-store.ts +30 -1
  296. package/src/telemetry/types.ts +24 -0
  297. package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
  298. package/src/tools/browser/pinned-tabs.ts +3 -1
  299. package/src/tools/subagent/spawn.ts +23 -1
  300. package/src/tools/terminal/safe-env.ts +1 -0
  301. package/src/tools/ui-surface/definitions.ts +39 -1
  302. package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
  303. package/src/tools/ui-surface/visual-validation.ts +787 -0
  304. package/src/tools/workflows/run-workflow.test.ts +1 -0
  305. package/src/util/__tests__/short-title.test.ts +229 -0
  306. package/src/util/__tests__/worker-compute.test.ts +65 -0
  307. package/src/util/cgroup-cpu.ts +93 -0
  308. package/src/util/errors.ts +27 -0
  309. package/src/util/process-tree.ts +19 -0
  310. package/src/util/short-title.ts +189 -0
  311. package/src/util/worker-compute.ts +83 -0
  312. package/src/workspace/byok-default-profile-ensure.ts +25 -15
  313. package/src/workspace/git-service.ts +79 -30
  314. package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
  315. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  316. package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
  317. package/src/workspace/migrations/registry.ts +6 -0
  318. package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tests for the voice activity line.
3
+ *
4
+ * The invariant worth guarding is totality: the tool vocabulary is open
5
+ * (plugins, MCP servers, and skills all contribute names this module has never
6
+ * seen), and the surface this feeds is a Lock Screen the user cannot correct.
7
+ * A label that came back empty would blank the island exactly when the
8
+ * assistant is busiest.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ activityLabelForTool,
15
+ approvalActivityLabel,
16
+ GENERIC_ACTIVITY_LABEL,
17
+ } from "../activity-label.js";
18
+
19
+ describe("activityLabelForTool", () => {
20
+ test("names what the user would say is happening, not the tool", () => {
21
+ expect(activityLabelForTool("bash")).toBe("Running a command");
22
+ expect(activityLabelForTool("web_search")).toBe("Searching the web");
23
+ expect(activityLabelForTool("file_read")).toBe("Reading a file");
24
+ });
25
+
26
+ // Where a tool runs is not a distinction anyone glancing at a Lock Screen is
27
+ // making.
28
+ test("host and sandbox variants read the same", () => {
29
+ expect(activityLabelForTool("host_bash")).toBe(
30
+ activityLabelForTool("bash"),
31
+ );
32
+ expect(activityLabelForTool("host_file_read")).toBe(
33
+ activityLabelForTool("file_read"),
34
+ );
35
+ });
36
+
37
+ test("falls back by prefix for structured tool families", () => {
38
+ expect(activityLabelForTool("browser_click")).toBe("Using the browser");
39
+ expect(activityLabelForTool("mcp_notion_search")).toBe(
40
+ "Using a connected app",
41
+ );
42
+ });
43
+
44
+ test("every tool gets a label, including ones this module has never seen", () => {
45
+ for (const name of [
46
+ "slack_conversations_history",
47
+ "",
48
+ "a",
49
+ "some_vendor::weird.tool/name",
50
+ ]) {
51
+ expect(activityLabelForTool(name)).toBe(GENERIC_ACTIVITY_LABEL);
52
+ }
53
+ });
54
+
55
+ test("labels stay short enough for the slot they land in", () => {
56
+ for (const name of [
57
+ "bash",
58
+ "web_search",
59
+ "host_file_transfer",
60
+ "computer_use",
61
+ "ui_show",
62
+ "unknown_tool",
63
+ ]) {
64
+ const label = activityLabelForTool(name);
65
+ expect(label.length).toBeLessThanOrEqual(32);
66
+ expect(label).not.toMatch(/[.!?]$/);
67
+ }
68
+ });
69
+ });
70
+
71
+ describe("approvalActivityLabel", () => {
72
+ // The turn is not running the tool, it is waiting to be allowed to. Saying
73
+ // the former is the misstatement this exists to stop — and the line has to
74
+ // name *something*, or the island asks the user to approve a blank.
75
+ test("keeps the tool's phrase and says who is being waited on", () => {
76
+ expect(approvalActivityLabel("bash")).toBe(
77
+ "Running a command — needs your okay",
78
+ );
79
+ expect(approvalActivityLabel("web_search")).toBe(
80
+ "Searching the web — needs your okay",
81
+ );
82
+ });
83
+
84
+ test("an unrecognized tool still reads as a wait", () => {
85
+ expect(approvalActivityLabel("some_vendor_tool")).toBe(
86
+ `${GENERIC_ACTIVITY_LABEL} — needs your okay`,
87
+ );
88
+ });
89
+
90
+ // A confirmation from a proxy or network prompter has no tool behind it, and
91
+ // "Working on it — needs your okay" would claim work that is not happening.
92
+ test("drops the phrase entirely when there is no tool to name", () => {
93
+ expect(approvalActivityLabel("")).toBe("Needs your okay");
94
+ });
95
+ });
@@ -34,16 +34,26 @@ function sttFinal(text: string): LiveVoiceServerFramePayload {
34
34
 
35
35
  /** A reporter whose dispatches are captured instead of sent. */
36
36
  class RecordingReporter extends LiveActivityReporter {
37
- readonly dispatched: Array<{ phase: string; event: string }> = [];
37
+ readonly dispatched: Array<{ phase: string; event: string; detail: string }> =
38
+ [];
38
39
 
39
40
  protected override async dispatch(
40
41
  phase: string,
41
42
  event: "update" | "end",
43
+ detail: string,
42
44
  ): Promise<void> {
43
- this.dispatched.push({ phase, event });
45
+ this.dispatched.push({ phase, event, detail });
44
46
  }
45
47
  }
46
48
 
49
+ function activity(label: string): LiveVoiceServerFramePayload {
50
+ return {
51
+ type: "activity",
52
+ turnId: "t1",
53
+ label,
54
+ } as LiveVoiceServerFramePayload;
55
+ }
56
+
47
57
  describe("phaseForFrame", () => {
48
58
  test("maps the frames the client derives phases from", () => {
49
59
  expect(phaseForFrame(frame("utterance_end"), "listening")).toBe(
@@ -92,7 +102,7 @@ describe("LiveActivityReporter", () => {
92
102
  reporter.report(frame("thinking"));
93
103
 
94
104
  expect(reporter.dispatched).toEqual([
95
- { phase: "thinking", event: "update" },
105
+ { phase: "thinking", event: "update", detail: "" },
96
106
  ]);
97
107
  });
98
108
 
@@ -105,7 +115,7 @@ describe("LiveActivityReporter", () => {
105
115
  reporter.report(frame("tts_audio"));
106
116
 
107
117
  expect(reporter.dispatched).toEqual([
108
- { phase: "speaking", event: "update" },
118
+ { phase: "speaking", event: "update", detail: "" },
109
119
  ]);
110
120
  });
111
121
 
@@ -144,6 +154,7 @@ describe("LiveActivityReporter", () => {
144
154
  expect(reporter.dispatched.at(-1)).toEqual({
145
155
  phase: "ending",
146
156
  event: "end",
157
+ detail: "",
147
158
  });
148
159
  });
149
160
 
@@ -165,7 +176,77 @@ describe("LiveActivityReporter", () => {
165
176
  reporter.end();
166
177
  reporter.report(frame("tts_audio"));
167
178
 
168
- expect(reporter.dispatched).toEqual([{ phase: "ending", event: "end" }]);
179
+ expect(reporter.dispatched).toEqual([
180
+ { phase: "ending", event: "end", detail: "" },
181
+ ]);
182
+ });
183
+ });
184
+
185
+ describe("LiveActivityReporter activity line", () => {
186
+ test("carries the activity label alongside the phase it holds", () => {
187
+ const reporter = new RecordingReporter("conv-1");
188
+
189
+ reporter.report(frame("utterance_end"));
190
+ reporter.report(frame("thinking"));
191
+ reporter.report(activity("Running a command"));
192
+
193
+ expect(reporter.dispatched.at(-1)).toEqual({
194
+ phase: "thinking",
195
+ event: "update",
196
+ detail: "Running a command",
197
+ });
198
+ });
199
+
200
+ // A push replaces the whole content state, so the label has to ride along on
201
+ // phase changes too, or the next phase would blank it.
202
+ test("keeps the label through a later phase change", () => {
203
+ const reporter = new RecordingReporter("conv-1");
204
+
205
+ reporter.report(frame("thinking"));
206
+ reporter.report(activity("Reading a file"));
207
+ reporter.report(frame("tts_audio"));
208
+
209
+ expect(reporter.dispatched.at(-1)).toEqual({
210
+ phase: "speaking",
211
+ event: "update",
212
+ detail: "Reading a file",
213
+ });
214
+ });
215
+
216
+ test("an empty label clears the line", () => {
217
+ const reporter = new RecordingReporter("conv-1");
218
+
219
+ reporter.report(frame("thinking"));
220
+ reporter.report(activity("Reading a file"));
221
+ reporter.report(activity(""));
222
+
223
+ expect(reporter.dispatched.at(-1)?.detail).toBe("");
224
+ });
225
+
226
+ test("does not dispatch a label it already sent", () => {
227
+ const reporter = new RecordingReporter("conv-1");
228
+
229
+ reporter.report(frame("thinking"));
230
+ reporter.report(activity("Reading a file"));
231
+ const before = reporter.dispatched.length;
232
+ reporter.report(activity("Reading a file"));
233
+
234
+ expect(reporter.dispatched.length).toBe(before);
235
+ });
236
+
237
+ // A tool can start before any phase-bearing frame lands. There is no content
238
+ // state to attach a label to yet, and the phase that follows carries it.
239
+ test("holds a label that arrives before any phase", () => {
240
+ const reporter = new RecordingReporter("conv-1");
241
+
242
+ reporter.report(activity("Searching the web"));
243
+ expect(reporter.dispatched).toEqual([]);
244
+
245
+ reporter.report(frame("thinking"));
246
+
247
+ expect(reporter.dispatched).toEqual([
248
+ { phase: "thinking", event: "update", detail: "Searching the web" },
249
+ ]);
169
250
  });
170
251
  });
171
252
 
@@ -154,16 +154,42 @@ async function waitFor(
154
154
  throw new Error(message);
155
155
  }
156
156
 
157
+ /**
158
+ * The most recent `activity` frame, or `undefined` when none has been sent.
159
+ * `findLast` is past this project's lib target, so the filter-and-take-last
160
+ * form stands in for it.
161
+ */
162
+ function lastActivityFrame(
163
+ frames: LiveVoiceServerFrame[],
164
+ ): Extract<LiveVoiceServerFrame, { type: "activity" }> | undefined {
165
+ const activity = frames.filter(
166
+ (frame): frame is Extract<LiveVoiceServerFrame, { type: "activity" }> =>
167
+ frame.type === "activity",
168
+ );
169
+ return activity[activity.length - 1];
170
+ }
171
+
157
172
  function createCapturingTurnStarter(): {
158
173
  startVoiceTurn: LiveVoiceTurnStarter;
159
174
  getCallbacks: () => VoiceTurnCallbacks | undefined;
175
+ announceApprovalPending: () => void;
176
+ announceApprovalsResolved: () => void;
160
177
  } {
161
178
  let callbacks: VoiceTurnCallbacks | undefined;
179
+ let onApprovalPending: ((requestId: string) => void) | undefined;
180
+ let onApprovalsResolved: (() => void) | undefined;
162
181
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
163
182
  callbacks = options.callbacks;
183
+ onApprovalPending = options.onApprovalPending;
184
+ onApprovalsResolved = options.onApprovalsResolved;
164
185
  return { turnId: "bridge-turn-1", abort: mock() };
165
186
  });
166
- return { startVoiceTurn, getCallbacks: () => callbacks };
187
+ return {
188
+ startVoiceTurn,
189
+ getCallbacks: () => callbacks,
190
+ announceApprovalPending: () => onApprovalPending?.("req-1"),
191
+ announceApprovalsResolved: () => onApprovalsResolved?.(),
192
+ };
167
193
  }
168
194
 
169
195
  function createRecordingTtsStreamer(): {
@@ -442,7 +468,7 @@ describe("LiveVoiceSession assistant turn", () => {
442
468
  });
443
469
  });
444
470
 
445
- test("records tool_use_start on the active turn without emitting frames", async () => {
471
+ test("records tool_use_start on the active turn and publishes what it is doing", async () => {
446
472
  let callbacks: VoiceTurnCallbacks | undefined;
447
473
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
448
474
  callbacks = options.callbacks;
@@ -459,9 +485,8 @@ describe("LiveVoiceSession assistant turn", () => {
459
485
  "thinking",
460
486
  ]);
461
487
 
462
- const frameCountBefore = frames.length;
463
488
  callbacks?.tool_use_start?.("some_tool");
464
- await flushAsyncCallbacks();
489
+ await waitForFrameCount(frames, 4);
465
490
 
466
491
  const activeTurn = (
467
492
  session as unknown as {
@@ -469,20 +494,30 @@ describe("LiveVoiceSession assistant turn", () => {
469
494
  }
470
495
  ).activeAssistantTurn;
471
496
  expect(activeTurn?.toolUseStarted).toBe(true);
472
- expect(frames).toHaveLength(frameCountBefore);
497
+ // One activity frame, so a silent stretch of tool work is visible on the
498
+ // surfaces that show the session. An unrecognized tool still gets a line.
499
+ expect(frames.at(-1)).toMatchObject({
500
+ type: "activity",
501
+ label: "Working on it",
502
+ });
473
503
 
474
504
  callbacks?.message_complete?.({
475
505
  type: "message_complete",
476
506
  conversationId: "conversation-123",
477
507
  messageId: "assistant-message-123",
478
508
  });
479
- await waitForFrameCount(frames, 4);
509
+ await waitForFrameCount(frames, 6);
480
510
  expect(frames.map((frame) => frame.type)).toEqual([
481
511
  "ready",
482
512
  "stt_final",
483
513
  "thinking",
514
+ "activity",
484
515
  "tts_done",
516
+ // The turn is over, so the line clears: no surface should keep showing
517
+ // the last tool it touched through the silence that follows.
518
+ "activity",
485
519
  ]);
520
+ expect(frames.at(-1)).toMatchObject({ type: "activity", label: "" });
486
521
  });
487
522
 
488
523
  test("interrupt aborts the in-flight turn and ignores late bridge events", async () => {
@@ -609,7 +644,7 @@ describe("LiveVoiceSession tool-use spoken ack", () => {
609
644
  });
610
645
  });
611
646
 
612
- describe("LiveVoiceSession minimize-room marker", () => {
647
+ describe("LiveVoiceSession room reveal", () => {
613
648
  function createMarkerHarness() {
614
649
  const { startVoiceTurn, getCallbacks } = createCapturingTurnStarter();
615
650
  const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
@@ -624,11 +659,10 @@ describe("LiveVoiceSession minimize-room marker", () => {
624
659
  }
625
660
 
626
661
  /**
627
- * Marker-command harness for the leg that can actually put something on
628
- * screen: routing fronts every turn with the fast leg, so the front-door
629
- * call is scripted to escalate ("[1] Working on it.") and the returned
630
- * callbacks drive the ESCALATED leg the only leg whose completion may
631
- * latch `minimizeRequested`.
662
+ * Harness for the leg that can actually put something on screen: routing
663
+ * fronts every turn with the fast leg, so the front-door call is scripted to
664
+ * escalate ("[1] Working on it.") and the returned callbacks drive the
665
+ * ESCALATED leg; the toolless front door can never run a ui tool.
632
666
  */
633
667
  function createEscalatedMarkerHarness() {
634
668
  let escalatedCallbacks: VoiceTurnCallbacks | undefined;
@@ -664,35 +698,285 @@ describe("LiveVoiceSession minimize-room marker", () => {
664
698
  };
665
699
  }
666
700
 
667
- test("strips a terminal [-1] from deltas and TTS and emits minimize_room after tts_done", async () => {
668
- const { frames, session, getCallbacks, ttsTexts } =
669
- createEscalatedMarkerHarness();
701
+ /** Run a ui tool to completion, as the agent loop would. */
702
+ function runUiTool(
703
+ getCallbacks: () => VoiceTurnCallbacks | undefined,
704
+ toolName: string,
705
+ opts?: { isError?: boolean },
706
+ ): void {
707
+ getCallbacks()?.tool_use_start?.(toolName);
708
+ getCallbacks()?.tool_result?.({
709
+ toolName,
710
+ resultPreview: "",
711
+ ...(opts?.isError === true ? { isError: true } : {}),
712
+ });
713
+ }
714
+
715
+ // Showing a surface is what reveals the screen. Nothing the model says does,
716
+ // so the user never loses the reveal to a forgotten token.
717
+ test("a ui tool emits minimize_room after tts_done", async () => {
718
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
670
719
 
671
720
  await startReleasedTurn(session, getCallbacks);
672
- emitTextDelta(getCallbacks, "hello world. [-1]");
721
+ runUiTool(getCallbacks, "ui_show");
722
+ emitTextDelta(getCallbacks, "Here is the summary.");
673
723
  emitMessageComplete(getCallbacks);
674
724
  await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
675
725
 
676
- const joined = assistantDeltaTexts(frames).join("");
677
- expect(joined).toContain("hello world. ");
678
- expect(joined).not.toContain("[-1]");
679
- expect(ttsTexts.join(" ")).not.toContain("[-1]");
680
726
  const ttsDoneIndex = frames.findIndex((frame) => frame.type === "tts_done");
681
727
  const minimizeIndex = frames.findIndex(
682
728
  (frame) => frame.type === "minimize_room",
683
729
  );
684
730
  expect(ttsDoneIndex).toBeGreaterThanOrEqual(0);
731
+ // After the speech drains, never mid-sentence.
685
732
  expect(minimizeIndex).toBeGreaterThan(ttsDoneIndex);
686
733
  expect(frames[minimizeIndex]).toMatchObject({
687
734
  type: "minimize_room",
688
735
  turnId: "live-turn-1",
689
736
  });
737
+ // At most once, however many surfaces the turn touched.
690
738
  expect(
691
739
  frames.filter((frame) => frame.type === "minimize_room"),
692
740
  ).toHaveLength(1);
693
741
  });
694
742
 
695
- test("holds a marker split across deltas, never leaks the partial, and still emits", async () => {
743
+ // The reveal follows the surface, not the attempt: a rejected call (no
744
+ // surface_type, an empty card, a client that never acks) rendered nothing,
745
+ // and minimizing to show nothing is worse than staying put.
746
+ test("a failed ui call reveals nothing", async () => {
747
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
748
+
749
+ await startReleasedTurn(session, getCallbacks);
750
+ runUiTool(getCallbacks, "ui_show", { isError: true });
751
+ emitTextDelta(getCallbacks, "I could not show that.");
752
+ emitMessageComplete(getCallbacks);
753
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
754
+
755
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
756
+ });
757
+
758
+ test("a surface taken away before the reply ends reveals nothing", async () => {
759
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
760
+
761
+ await startReleasedTurn(session, getCallbacks);
762
+ runUiTool(getCallbacks, "ui_show");
763
+ runUiTool(getCallbacks, "ui_dismiss");
764
+ emitTextDelta(getCallbacks, "Never mind, sorted it.");
765
+ emitMessageComplete(getCallbacks);
766
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
767
+
768
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
769
+ });
770
+
771
+ // Opening an app puts something on screen just as a card does; a list keyed
772
+ // on "ui_" would have missed it.
773
+ test("opening an app reveals the screen", async () => {
774
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
775
+
776
+ await startReleasedTurn(session, getCallbacks);
777
+ runUiTool(getCallbacks, "app_open");
778
+ emitTextDelta(getCallbacks, "Opened it up.");
779
+ emitMessageComplete(getCallbacks);
780
+ await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
781
+
782
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(true);
783
+ });
784
+
785
+ // A blocked turn has no speech left to drain, and the card it is blocked on
786
+ // renders behind the room. Waiting for a drain that is not coming would
787
+ // leave the call silent in front of a decision the user cannot see.
788
+ test("a pending approval reveals the screen immediately", async () => {
789
+ const { startVoiceTurn, announceApprovalPending } =
790
+ createCapturingTurnStarter();
791
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
792
+
793
+ await session.start();
794
+ await session.handleClientFrame({ type: "ptt_release" });
795
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
796
+
797
+ announceApprovalPending();
798
+ await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
799
+
800
+ // Before any tts_done, unlike the drain-scoped reveal a shown surface gets.
801
+ expect(frames.some((frame) => frame.type === "tts_done")).toBe(false);
802
+ });
803
+
804
+ // Every phrase progress narration has describes work in flight ("still on
805
+ // it", "almost there"). While the turn is blocked on the user, all of them
806
+ // are false, and the one that matters is the one about who is being waited
807
+ // on.
808
+ test("says it is waiting, once, and stops narrating progress", async () => {
809
+ const {
810
+ startVoiceTurn,
811
+ announceApprovalPending,
812
+ announceApprovalsResolved,
813
+ } = createCapturingTurnStarter();
814
+ const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
815
+ const { frames, session } = createSessionHarness({
816
+ startVoiceTurn,
817
+ streamTtsAudio,
818
+ });
819
+
820
+ await session.start();
821
+ await session.handleClientFrame({ type: "ptt_release" });
822
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
823
+
824
+ announceApprovalPending();
825
+ await waitFor(() => ttsTexts.join(" ").includes("okay"));
826
+ // Repeat announcements do not repeat the line.
827
+ announceApprovalPending();
828
+ await flushAsyncCallbacks();
829
+
830
+ const saidWaiting = ttsTexts.filter((text) => text.includes("okay")).length;
831
+ expect(saidWaiting).toBe(1);
832
+
833
+ announceApprovalsResolved();
834
+ await flushAsyncCallbacks();
835
+ });
836
+
837
+ // `tool_use_start` fires before the approval gate blocks, so without this the
838
+ // island keeps saying "Running a command" for the whole wait — a claim about
839
+ // work in flight made at the one moment nothing is in flight. The request id
840
+ // is what turns that line from accurate into answerable: it is what the Live
841
+ // Activity's Approve and Deny send back.
842
+ test("a pending approval publishes an answerable activity line", async () => {
843
+ const { startVoiceTurn, getCallbacks, announceApprovalPending } =
844
+ createCapturingTurnStarter();
845
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
846
+
847
+ await session.start();
848
+ await session.handleClientFrame({ type: "ptt_release" });
849
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
850
+
851
+ getCallbacks()?.tool_use_start?.("bash");
852
+ await waitFor(() =>
853
+ frames.some(
854
+ (frame) => frame.type === "activity" && frame.label.length > 0,
855
+ ),
856
+ );
857
+ announceApprovalPending();
858
+ await waitFor(() =>
859
+ frames.some(
860
+ (frame) =>
861
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
862
+ ),
863
+ );
864
+
865
+ const waiting = lastActivityFrame(frames);
866
+ // The tool's own phrase, kept, plus who is being waited on — so the line
867
+ // beside the buttons names what is being approved without naming the tool
868
+ // or its arguments to a Lock Screen.
869
+ expect(waiting).toMatchObject({
870
+ label: "Running a command — needs your okay",
871
+ approvalRequestId: "req-1",
872
+ });
873
+ });
874
+
875
+ // However the decision is made — the card in the app, the daemon's own
876
+ // timeout, a superseding message — the buttons must go with it. They are
877
+ // rendered from the id, so retiring the id is what retires them.
878
+ test("resolving the approval retires the request id", async () => {
879
+ const {
880
+ startVoiceTurn,
881
+ getCallbacks,
882
+ announceApprovalPending,
883
+ announceApprovalsResolved,
884
+ } = createCapturingTurnStarter();
885
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
886
+
887
+ await session.start();
888
+ await session.handleClientFrame({ type: "ptt_release" });
889
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
890
+
891
+ getCallbacks()?.tool_use_start?.("bash");
892
+ announceApprovalPending();
893
+ await waitFor(() =>
894
+ frames.some(
895
+ (frame) =>
896
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
897
+ ),
898
+ );
899
+
900
+ announceApprovalsResolved();
901
+ await waitFor(
902
+ () => lastActivityFrame(frames)?.approvalRequestId === undefined,
903
+ );
904
+
905
+ const resumed = lastActivityFrame(frames);
906
+ // Back to the tool that is now genuinely running, with nothing to answer.
907
+ expect(resumed).toMatchObject({ label: "Running a command" });
908
+ expect(resumed?.approvalRequestId).toBeUndefined();
909
+ });
910
+
911
+ // A turn can start and finish other work while it is blocked. Each of those
912
+ // events republishes the activity line, and a republish composed as if
913
+ // nothing were pending would take the request id down with it — retiring the
914
+ // island's buttons while the turn was still waiting on them.
915
+ test("a parallel tool event mid-wait does not retire the buttons", async () => {
916
+ const { startVoiceTurn, getCallbacks, announceApprovalPending } =
917
+ createCapturingTurnStarter();
918
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
919
+
920
+ await session.start();
921
+ await session.handleClientFrame({ type: "ptt_release" });
922
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
923
+
924
+ getCallbacks()?.tool_use_start?.("bash");
925
+ announceApprovalPending();
926
+ await waitFor(() =>
927
+ frames.some(
928
+ (frame) =>
929
+ frame.type === "activity" && frame.approvalRequestId === "req-1",
930
+ ),
931
+ );
932
+
933
+ getCallbacks()?.tool_use_start?.("web_search");
934
+ getCallbacks()?.tool_result?.({
935
+ toolName: "web_search",
936
+ resultPreview: "",
937
+ });
938
+ await flushAsyncCallbacks();
939
+
940
+ const latest = lastActivityFrame(frames);
941
+ expect(latest?.approvalRequestId).toBe("req-1");
942
+ // And still naming the tool the decision is about, not the one that ran
943
+ // alongside it.
944
+ expect(latest?.label).toBe("Running a command — needs your okay");
945
+ });
946
+
947
+ test("dismissing a surface does not reveal the screen", async () => {
948
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
949
+
950
+ await startReleasedTurn(session, getCallbacks);
951
+ runUiTool(getCallbacks, "ui_dismiss");
952
+ emitTextDelta(getCallbacks, "Cleared that away.");
953
+ emitMessageComplete(getCallbacks);
954
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
955
+
956
+ // Minimizing to show the user something that is no longer there is the
957
+ // opposite of the point.
958
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
959
+ });
960
+
961
+ // The marker is no longer taught to any leg, so this is about a model that
962
+ // emits one anyway: it must not be spoken, and it must not move the room.
963
+ test("a terminal [-1] is stripped from deltas and TTS and reveals nothing", async () => {
964
+ const { frames, session, getCallbacks, ttsTexts } =
965
+ createEscalatedMarkerHarness();
966
+
967
+ await startReleasedTurn(session, getCallbacks);
968
+ emitTextDelta(getCallbacks, "hello world. [-1]");
969
+ emitMessageComplete(getCallbacks);
970
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
971
+
972
+ const joined = assistantDeltaTexts(frames).join("");
973
+ expect(joined).toContain("hello world. ");
974
+ expect(joined).not.toContain("[-1]");
975
+ expect(ttsTexts.join(" ")).not.toContain("[-1]");
976
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
977
+ });
978
+
979
+ test("holds a marker split across deltas and never leaks the partial", async () => {
696
980
  const { frames, session, getCallbacks, ttsTexts } =
697
981
  createEscalatedMarkerHarness();
698
982
 
@@ -703,14 +987,14 @@ describe("LiveVoiceSession minimize-room marker", () => {
703
987
 
704
988
  emitTextDelta(getCallbacks, "1]");
705
989
  emitMessageComplete(getCallbacks);
706
- await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
990
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
707
991
 
708
992
  const joined = assistantDeltaTexts(frames).join("");
709
993
  expect(joined).not.toContain("[-");
710
994
  expect(ttsTexts.join(" ")).not.toContain("[-");
711
995
  });
712
996
 
713
- test("a mid-reply [-1] (content, not command) is stripped from speech but never minimizes", async () => {
997
+ test("a mid-reply [-1] is stripped from speech and never minimizes", async () => {
714
998
  const { frames, session, getCallbacks } = createMarkerHarness();
715
999
 
716
1000
  await startReleasedTurn(session, getCallbacks);
@@ -728,7 +1012,8 @@ describe("LiveVoiceSession minimize-room marker", () => {
728
1012
  const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
729
1013
 
730
1014
  await startReleasedTurn(session, getCallbacks);
731
- emitTextDelta(getCallbacks, "Minimizing now. [-1]");
1015
+ runUiTool(getCallbacks, "ui_show");
1016
+ emitTextDelta(getCallbacks, "Showing you now.");
732
1017
  await flushAsyncCallbacks();
733
1018
 
734
1019
  await session.handleClientFrame({ type: "interrupt" });
@@ -738,7 +1023,7 @@ describe("LiveVoiceSession minimize-room marker", () => {
738
1023
  expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
739
1024
  });
740
1025
 
741
- test("a turn without the marker emits no minimize_room frame", async () => {
1026
+ test("a turn that shows nothing emits no minimize_room frame", async () => {
742
1027
  const { frames, session, getCallbacks } = createMarkerHarness();
743
1028
 
744
1029
  await startReleasedTurn(session, getCallbacks);