@vellumai/assistant 0.11.1 → 0.11.2-staging.1

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 +68 -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 +185 -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 +141 -0
  146. package/src/live-voice/live-activity-reporter.ts +35 -6
  147. package/src/live-voice/live-voice-session.ts +171 -41
  148. package/src/live-voice/protocol.ts +23 -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
@@ -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
 
@@ -157,13 +157,24 @@ async function waitFor(
157
157
  function createCapturingTurnStarter(): {
158
158
  startVoiceTurn: LiveVoiceTurnStarter;
159
159
  getCallbacks: () => VoiceTurnCallbacks | undefined;
160
+ announceApprovalPending: () => void;
161
+ announceApprovalsResolved: () => void;
160
162
  } {
161
163
  let callbacks: VoiceTurnCallbacks | undefined;
164
+ let onApprovalPending: ((requestId: string) => void) | undefined;
165
+ let onApprovalsResolved: (() => void) | undefined;
162
166
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
163
167
  callbacks = options.callbacks;
168
+ onApprovalPending = options.onApprovalPending;
169
+ onApprovalsResolved = options.onApprovalsResolved;
164
170
  return { turnId: "bridge-turn-1", abort: mock() };
165
171
  });
166
- return { startVoiceTurn, getCallbacks: () => callbacks };
172
+ return {
173
+ startVoiceTurn,
174
+ getCallbacks: () => callbacks,
175
+ announceApprovalPending: () => onApprovalPending?.("req-1"),
176
+ announceApprovalsResolved: () => onApprovalsResolved?.(),
177
+ };
167
178
  }
168
179
 
169
180
  function createRecordingTtsStreamer(): {
@@ -442,7 +453,7 @@ describe("LiveVoiceSession assistant turn", () => {
442
453
  });
443
454
  });
444
455
 
445
- test("records tool_use_start on the active turn without emitting frames", async () => {
456
+ test("records tool_use_start on the active turn and publishes what it is doing", async () => {
446
457
  let callbacks: VoiceTurnCallbacks | undefined;
447
458
  const startVoiceTurn = mock(async (options: VoiceTurnOptions) => {
448
459
  callbacks = options.callbacks;
@@ -459,9 +470,8 @@ describe("LiveVoiceSession assistant turn", () => {
459
470
  "thinking",
460
471
  ]);
461
472
 
462
- const frameCountBefore = frames.length;
463
473
  callbacks?.tool_use_start?.("some_tool");
464
- await flushAsyncCallbacks();
474
+ await waitForFrameCount(frames, 4);
465
475
 
466
476
  const activeTurn = (
467
477
  session as unknown as {
@@ -469,20 +479,30 @@ describe("LiveVoiceSession assistant turn", () => {
469
479
  }
470
480
  ).activeAssistantTurn;
471
481
  expect(activeTurn?.toolUseStarted).toBe(true);
472
- expect(frames).toHaveLength(frameCountBefore);
482
+ // One activity frame, so a silent stretch of tool work is visible on the
483
+ // surfaces that show the session. An unrecognized tool still gets a line.
484
+ expect(frames.at(-1)).toMatchObject({
485
+ type: "activity",
486
+ label: "Working on it",
487
+ });
473
488
 
474
489
  callbacks?.message_complete?.({
475
490
  type: "message_complete",
476
491
  conversationId: "conversation-123",
477
492
  messageId: "assistant-message-123",
478
493
  });
479
- await waitForFrameCount(frames, 4);
494
+ await waitForFrameCount(frames, 6);
480
495
  expect(frames.map((frame) => frame.type)).toEqual([
481
496
  "ready",
482
497
  "stt_final",
483
498
  "thinking",
499
+ "activity",
484
500
  "tts_done",
501
+ // The turn is over, so the line clears: no surface should keep showing
502
+ // the last tool it touched through the silence that follows.
503
+ "activity",
485
504
  ]);
505
+ expect(frames.at(-1)).toMatchObject({ type: "activity", label: "" });
486
506
  });
487
507
 
488
508
  test("interrupt aborts the in-flight turn and ignores late bridge events", async () => {
@@ -609,7 +629,7 @@ describe("LiveVoiceSession tool-use spoken ack", () => {
609
629
  });
610
630
  });
611
631
 
612
- describe("LiveVoiceSession minimize-room marker", () => {
632
+ describe("LiveVoiceSession room reveal", () => {
613
633
  function createMarkerHarness() {
614
634
  const { startVoiceTurn, getCallbacks } = createCapturingTurnStarter();
615
635
  const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
@@ -624,11 +644,10 @@ describe("LiveVoiceSession minimize-room marker", () => {
624
644
  }
625
645
 
626
646
  /**
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`.
647
+ * Harness for the leg that can actually put something on screen: routing
648
+ * fronts every turn with the fast leg, so the front-door call is scripted to
649
+ * escalate ("[1] Working on it.") and the returned callbacks drive the
650
+ * ESCALATED leg; the toolless front door can never run a ui tool.
632
651
  */
633
652
  function createEscalatedMarkerHarness() {
634
653
  let escalatedCallbacks: VoiceTurnCallbacks | undefined;
@@ -664,35 +683,175 @@ describe("LiveVoiceSession minimize-room marker", () => {
664
683
  };
665
684
  }
666
685
 
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();
686
+ /** Run a ui tool to completion, as the agent loop would. */
687
+ function runUiTool(
688
+ getCallbacks: () => VoiceTurnCallbacks | undefined,
689
+ toolName: string,
690
+ opts?: { isError?: boolean },
691
+ ): void {
692
+ getCallbacks()?.tool_use_start?.(toolName);
693
+ getCallbacks()?.tool_result?.({
694
+ toolName,
695
+ resultPreview: "",
696
+ ...(opts?.isError === true ? { isError: true } : {}),
697
+ });
698
+ }
699
+
700
+ // Showing a surface is what reveals the screen. Nothing the model says does,
701
+ // so the user never loses the reveal to a forgotten token.
702
+ test("a ui tool emits minimize_room after tts_done", async () => {
703
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
670
704
 
671
705
  await startReleasedTurn(session, getCallbacks);
672
- emitTextDelta(getCallbacks, "hello world. [-1]");
706
+ runUiTool(getCallbacks, "ui_show");
707
+ emitTextDelta(getCallbacks, "Here is the summary.");
673
708
  emitMessageComplete(getCallbacks);
674
709
  await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
675
710
 
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
711
  const ttsDoneIndex = frames.findIndex((frame) => frame.type === "tts_done");
681
712
  const minimizeIndex = frames.findIndex(
682
713
  (frame) => frame.type === "minimize_room",
683
714
  );
684
715
  expect(ttsDoneIndex).toBeGreaterThanOrEqual(0);
716
+ // After the speech drains, never mid-sentence.
685
717
  expect(minimizeIndex).toBeGreaterThan(ttsDoneIndex);
686
718
  expect(frames[minimizeIndex]).toMatchObject({
687
719
  type: "minimize_room",
688
720
  turnId: "live-turn-1",
689
721
  });
722
+ // At most once, however many surfaces the turn touched.
690
723
  expect(
691
724
  frames.filter((frame) => frame.type === "minimize_room"),
692
725
  ).toHaveLength(1);
693
726
  });
694
727
 
695
- test("holds a marker split across deltas, never leaks the partial, and still emits", async () => {
728
+ // The reveal follows the surface, not the attempt: a rejected call (no
729
+ // surface_type, an empty card, a client that never acks) rendered nothing,
730
+ // and minimizing to show nothing is worse than staying put.
731
+ test("a failed ui call reveals nothing", async () => {
732
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
733
+
734
+ await startReleasedTurn(session, getCallbacks);
735
+ runUiTool(getCallbacks, "ui_show", { isError: true });
736
+ emitTextDelta(getCallbacks, "I could not show that.");
737
+ emitMessageComplete(getCallbacks);
738
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
739
+
740
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
741
+ });
742
+
743
+ test("a surface taken away before the reply ends reveals nothing", async () => {
744
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
745
+
746
+ await startReleasedTurn(session, getCallbacks);
747
+ runUiTool(getCallbacks, "ui_show");
748
+ runUiTool(getCallbacks, "ui_dismiss");
749
+ emitTextDelta(getCallbacks, "Never mind, sorted it.");
750
+ emitMessageComplete(getCallbacks);
751
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
752
+
753
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
754
+ });
755
+
756
+ // Opening an app puts something on screen just as a card does; a list keyed
757
+ // on "ui_" would have missed it.
758
+ test("opening an app reveals the screen", async () => {
759
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
760
+
761
+ await startReleasedTurn(session, getCallbacks);
762
+ runUiTool(getCallbacks, "app_open");
763
+ emitTextDelta(getCallbacks, "Opened it up.");
764
+ emitMessageComplete(getCallbacks);
765
+ await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
766
+
767
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(true);
768
+ });
769
+
770
+ // A blocked turn has no speech left to drain, and the card it is blocked on
771
+ // renders behind the room. Waiting for a drain that is not coming would
772
+ // leave the call silent in front of a decision the user cannot see.
773
+ test("a pending approval reveals the screen immediately", async () => {
774
+ const { startVoiceTurn, announceApprovalPending } =
775
+ createCapturingTurnStarter();
776
+ const { frames, session } = createSessionHarness({ startVoiceTurn });
777
+
778
+ await session.start();
779
+ await session.handleClientFrame({ type: "ptt_release" });
780
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
781
+
782
+ announceApprovalPending();
783
+ await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
784
+
785
+ // Before any tts_done, unlike the drain-scoped reveal a shown surface gets.
786
+ expect(frames.some((frame) => frame.type === "tts_done")).toBe(false);
787
+ });
788
+
789
+ // Every phrase progress narration has describes work in flight ("still on
790
+ // it", "almost there"). While the turn is blocked on the user, all of them
791
+ // are false, and the one that matters is the one about who is being waited
792
+ // on.
793
+ test("says it is waiting, once, and stops narrating progress", async () => {
794
+ const {
795
+ startVoiceTurn,
796
+ announceApprovalPending,
797
+ announceApprovalsResolved,
798
+ } = createCapturingTurnStarter();
799
+ const { streamTtsAudio, ttsTexts } = createRecordingTtsStreamer();
800
+ const { frames, session } = createSessionHarness({
801
+ startVoiceTurn,
802
+ streamTtsAudio,
803
+ });
804
+
805
+ await session.start();
806
+ await session.handleClientFrame({ type: "ptt_release" });
807
+ await waitFor(() => frames.some((frame) => frame.type === "thinking"));
808
+
809
+ announceApprovalPending();
810
+ await waitFor(() => ttsTexts.join(" ").includes("okay"));
811
+ // Repeat announcements do not repeat the line.
812
+ announceApprovalPending();
813
+ await flushAsyncCallbacks();
814
+
815
+ const saidWaiting = ttsTexts.filter((text) => text.includes("okay")).length;
816
+ expect(saidWaiting).toBe(1);
817
+
818
+ announceApprovalsResolved();
819
+ await flushAsyncCallbacks();
820
+ });
821
+
822
+ test("dismissing a surface does not reveal the screen", async () => {
823
+ const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
824
+
825
+ await startReleasedTurn(session, getCallbacks);
826
+ runUiTool(getCallbacks, "ui_dismiss");
827
+ emitTextDelta(getCallbacks, "Cleared that away.");
828
+ emitMessageComplete(getCallbacks);
829
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
830
+
831
+ // Minimizing to show the user something that is no longer there is the
832
+ // opposite of the point.
833
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
834
+ });
835
+
836
+ // The marker is no longer taught to any leg, so this is about a model that
837
+ // emits one anyway: it must not be spoken, and it must not move the room.
838
+ test("a terminal [-1] is stripped from deltas and TTS and reveals nothing", async () => {
839
+ const { frames, session, getCallbacks, ttsTexts } =
840
+ createEscalatedMarkerHarness();
841
+
842
+ await startReleasedTurn(session, getCallbacks);
843
+ emitTextDelta(getCallbacks, "hello world. [-1]");
844
+ emitMessageComplete(getCallbacks);
845
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
846
+
847
+ const joined = assistantDeltaTexts(frames).join("");
848
+ expect(joined).toContain("hello world. ");
849
+ expect(joined).not.toContain("[-1]");
850
+ expect(ttsTexts.join(" ")).not.toContain("[-1]");
851
+ expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
852
+ });
853
+
854
+ test("holds a marker split across deltas and never leaks the partial", async () => {
696
855
  const { frames, session, getCallbacks, ttsTexts } =
697
856
  createEscalatedMarkerHarness();
698
857
 
@@ -703,14 +862,14 @@ describe("LiveVoiceSession minimize-room marker", () => {
703
862
 
704
863
  emitTextDelta(getCallbacks, "1]");
705
864
  emitMessageComplete(getCallbacks);
706
- await waitFor(() => frames.some((frame) => frame.type === "minimize_room"));
865
+ await waitFor(() => frames.some((frame) => frame.type === "tts_done"));
707
866
 
708
867
  const joined = assistantDeltaTexts(frames).join("");
709
868
  expect(joined).not.toContain("[-");
710
869
  expect(ttsTexts.join(" ")).not.toContain("[-");
711
870
  });
712
871
 
713
- test("a mid-reply [-1] (content, not command) is stripped from speech but never minimizes", async () => {
872
+ test("a mid-reply [-1] is stripped from speech and never minimizes", async () => {
714
873
  const { frames, session, getCallbacks } = createMarkerHarness();
715
874
 
716
875
  await startReleasedTurn(session, getCallbacks);
@@ -728,7 +887,8 @@ describe("LiveVoiceSession minimize-room marker", () => {
728
887
  const { frames, session, getCallbacks } = createEscalatedMarkerHarness();
729
888
 
730
889
  await startReleasedTurn(session, getCallbacks);
731
- emitTextDelta(getCallbacks, "Minimizing now. [-1]");
890
+ runUiTool(getCallbacks, "ui_show");
891
+ emitTextDelta(getCallbacks, "Showing you now.");
732
892
  await flushAsyncCallbacks();
733
893
 
734
894
  await session.handleClientFrame({ type: "interrupt" });
@@ -738,7 +898,7 @@ describe("LiveVoiceSession minimize-room marker", () => {
738
898
  expect(frames.some((frame) => frame.type === "minimize_room")).toBe(false);
739
899
  });
740
900
 
741
- test("a turn without the marker emits no minimize_room frame", async () => {
901
+ test("a turn that shows nothing emits no minimize_room frame", async () => {
742
902
  const { frames, session, getCallbacks } = createMarkerHarness();
743
903
 
744
904
  await startReleasedTurn(session, getCallbacks);
@@ -254,13 +254,13 @@ describe("LiveVoiceSession archive and metrics events", () => {
254
254
  userMessageInterface: "macos",
255
255
  assistantMessageInterface: "macos",
256
256
  // Pins the full production control prompt for the FRONT-DOOR leg (the
257
- // first leg of every routed turn): the no-UI rule (voice turns are
258
- // non-interactive — everything is conveyed in speech) and the shared
259
- // no-setup-flows rule (no OAuth/browser flows mid-call). The [-1]
257
+ // first leg of every routed turn): the speech-first rule (which now
258
+ // permits a surface, since the room reveals one by itself) and the
259
+ // shared no-setup-flows rule (no OAuth/browser flows mid-call). The [-1]
260
260
  // room-minimize teaching is deliberately absent — only the escalated
261
261
  // leg learns it (see live-voice-triage-escalate.test.ts).
262
262
  voiceControlPrompt:
263
- "You are speaking in a local live voice session. Keep replies brief and conversational. You cannot display cards, forms, or any on-screen UI during the call — convey everything in speech. " +
263
+ "You are speaking in a local live voice session. Keep replies brief and conversational. Speech is the main channel: say the answer, and do not narrate a surface instead of answering. You can also put something on screen when it genuinely helps (a form, a list to pick from, a progress card for long work); the call overlay minimizes by itself once you finish speaking, so the user sees it without doing anything. Never tell the user you cannot show them something. " +
264
264
  VOICE_NO_SETUP_FLOWS_RULE,
265
265
  });
266
266
  callbacks?.assistant_text_delta?.(makeTextDelta("Hello there."));
@@ -190,7 +190,7 @@ describe("live-voice triage-and-escalate routing", () => {
190
190
  expect(escalated?.content).toBe(ESCALATION_CONTINUATION_CONTENT);
191
191
  });
192
192
 
193
- test("the [-1] teaching reaches the escalated leg's prompt but never the front-door leg's", async () => {
193
+ test("the screen-reveal teaching reaches the escalated leg's prompt but never the front-door leg's", async () => {
194
194
  const { starter } = scriptedStartVoiceTurn({
195
195
  frontDoor: ["[1] ", "Let me think about that."],
196
196
  escalated: ["The detailed answer is 42."],
@@ -205,10 +205,14 @@ describe("live-voice triage-and-escalate routing", () => {
205
205
  starter.mock.calls[0]?.[0]?.voiceControlPrompt ?? "";
206
206
  const escalatedPrompt =
207
207
  starter.mock.calls[1]?.[0]?.voiceControlPrompt ?? "";
208
- // The toolless fast leg has nothing to show and its decision rule promises
209
- // verbatim speech — it must never learn the marker.
208
+ // The toolless fast leg has nothing to show, so it is never told the
209
+ // screen will be revealed.
210
+ expect(frontDoorPrompt).not.toContain("the overlay minimizes");
211
+ expect(escalatedPrompt).toContain("the overlay minimizes");
212
+ // No leg is taught a marker any more: the reveal is decided by whether a
213
+ // ui tool ran, never by anything the model emits.
210
214
  expect(frontDoorPrompt).not.toContain("[-1]");
211
- expect(escalatedPrompt).toContain("[-1]");
215
+ expect(escalatedPrompt).not.toContain("[-1]");
212
216
  });
213
217
 
214
218
  test("the verdict token and any text past the bridge cap never reach the transcript", async () => {
@@ -1207,8 +1207,16 @@ describe("LiveVoiceSession server VAD", () => {
1207
1207
  });
1208
1208
  // The barge-in utterance transcribes to nothing, so no follow-up turn
1209
1209
  // consumes the stash before the interrupt lands.
1210
+ //
1211
+ // "third question" is repeated because finals are handed out by transcriber
1212
+ // creation order, and the session pre-arms a transcriber for the next
1213
+ // utterance. The interrupt below can discard that pre-armed one and re-arm,
1214
+ // which shifts the final utterance onto the following index — with a
1215
+ // 3-entry list it would fall off the end and transcribe to the "utterance
1216
+ // N" placeholder, so the wait for "third question" would time out rather
1217
+ // than fail on the assertion. Both landing spots carry the same text.
1210
1218
  const { frames, session } = createHarness({
1211
- finals: ["first question", "", "third question"],
1219
+ finals: ["first question", "", "third question", "third question"],
1212
1220
  startVoiceTurn,
1213
1221
  streamTtsAudio,
1214
1222
  spawnBackgroundContinuation,
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Turns a tool call into one short user-facing line for the surfaces that show
3
+ * a running voice session.
4
+ *
5
+ * ## Why the copy lives here
6
+ *
7
+ * The iOS Live Activity has two drivers that must carry identical content: the
8
+ * live-voice socket, and an APNs push the daemon dispatches when the app is
9
+ * backgrounded and the web layer is suspended. Only one of them runs web code,
10
+ * so a label composed client-side could not be reproduced by the other. Both
11
+ * are therefore handed the same string, and this is where it is made.
12
+ *
13
+ * That does not contradict the rule that phase wording belongs to the web
14
+ * layer. The reason phase copy lives there is cadence: the iOS shell ships on
15
+ * App Store review while the web bundle deploys continuously, so a native
16
+ * `switch` would fossilize. This module deploys with the daemon, i.e.
17
+ * continuously, and it is the only layer that knows a tool ran at all.
18
+ *
19
+ * ## What makes a good label
20
+ *
21
+ * It is read on a Lock Screen, in a glance, by someone who is mid-conversation
22
+ * and not looking for detail. So: a present-participle phrase, no tool names,
23
+ * no arguments, no punctuation, and short enough to survive one line. Arguments
24
+ * are deliberately absent even though {@link summarizeToolInput} could supply
25
+ * them: a path or a URL on a Lock Screen is unreadable at a glance and can
26
+ * carry content the user would not choose to show a passer-by.
27
+ */
28
+
29
+ /**
30
+ * The tools that put something on screen worth looking at.
31
+ *
32
+ * `app_open` belongs here with the ui-surface tools: it proxies to a connected
33
+ * client to bring an app up, which is the same thing from the user's side, and
34
+ * a list keyed on "ui_" would have missed it.
35
+ *
36
+ * `ui_dismiss` is deliberately absent, and is handled as its own case: it
37
+ * retires a surface, and revealing the screen to show the user something that
38
+ * is no longer there is the opposite of the point.
39
+ */
40
+ const UI_REVEAL_TOOLS: ReadonlySet<string> = new Set([
41
+ "ui_show",
42
+ "ui_update",
43
+ "app_open",
44
+ ]);
45
+
46
+ /** The tool that takes a surface away again. */
47
+ const UI_DISMISS_TOOL = "ui_dismiss";
48
+
49
+ /**
50
+ * Whether running `toolName` leaves something on screen the user should be
51
+ * shown, which is what makes a live-voice turn reveal the screen behind the
52
+ * call overlay.
53
+ */
54
+ export function revealsUiSurface(toolName: string): boolean {
55
+ return UI_REVEAL_TOOLS.has(toolName);
56
+ }
57
+
58
+ /** Whether `toolName` takes a surface off the screen. */
59
+ export function dismissesUiSurface(toolName: string): boolean {
60
+ return toolName === UI_DISMISS_TOOL;
61
+ }
62
+
63
+ /**
64
+ * Present-participle phrases for the built-in tools, by exact name.
65
+ *
66
+ * Grouped by what the user would say is happening, not by how the tool is
67
+ * implemented: `bash` and `host_bash` differ in where they run, which is not a
68
+ * distinction anyone glancing at a Lock Screen is making.
69
+ */
70
+ const TOOL_ACTIVITY_LABELS: Readonly<Record<string, string>> = {
71
+ bash: "Running a command",
72
+ terminal: "Running a command",
73
+ host_bash: "Running a command",
74
+ file_read: "Reading a file",
75
+ host_file_read: "Reading a file",
76
+ file_write: "Writing a file",
77
+ host_file_write: "Writing a file",
78
+ file_edit: "Editing a file",
79
+ host_file_edit: "Editing a file",
80
+ host_file_transfer: "Moving a file",
81
+ file_search: "Searching files",
82
+ web_search: "Searching the web",
83
+ web_fetch: "Reading a page",
84
+ network_request: "Making a request",
85
+ browser: "Using the browser",
86
+ computer_use: "Using the computer",
87
+ memory: "Checking memory",
88
+ schedule: "Checking the schedule",
89
+ ui_show: "Putting something on screen",
90
+ ui_update: "Putting something on screen",
91
+ app_open: "Opening an app",
92
+ };
93
+
94
+ /**
95
+ * Prefix-matched fallbacks, tried in order when a tool has no exact entry.
96
+ *
97
+ * The tool vocabulary is open: plugins, MCP servers, and skills all contribute
98
+ * names this module has never seen, and a new built-in should not have to be
99
+ * registered here to avoid a blank island. Prefixes cover the families whose
100
+ * names are structured; {@link GENERIC_ACTIVITY_LABEL} covers the rest.
101
+ */
102
+ const TOOL_ACTIVITY_LABEL_PREFIXES: ReadonlyArray<readonly [string, string]> = [
103
+ ["browser_", "Using the browser"],
104
+ ["computer_", "Using the computer"],
105
+ ["file_", "Working with files"],
106
+ ["host_file_", "Working with files"],
107
+ ["web_", "Looking something up"],
108
+ ["mcp_", "Using a connected app"],
109
+ ["skill_", "Running a skill"],
110
+ ["subagent", "Working on it"],
111
+ ];
112
+
113
+ /**
114
+ * What an unrecognized tool reads as.
115
+ *
116
+ * Deliberately vague rather than derived from the tool's name. A humanized
117
+ * `slack_conversations_history` is worse than "Working on it": it is longer
118
+ * than the slot, it leaks an internal vocabulary, and it reads like an error
119
+ * message. The label's job is to show the turn is alive and doing something,
120
+ * and this does that job for every tool that will ever exist.
121
+ */
122
+ export const GENERIC_ACTIVITY_LABEL = "Working on it";
123
+
124
+ /**
125
+ * The line to show while `toolName` runs.
126
+ *
127
+ * Total, by construction: every tool gets a label, because the alternative is
128
+ * a surface that goes blank precisely when the assistant is busiest.
129
+ */
130
+ export function activityLabelForTool(toolName: string): string {
131
+ const exact = TOOL_ACTIVITY_LABELS[toolName];
132
+ if (exact !== undefined) {
133
+ return exact;
134
+ }
135
+ for (const [prefix, label] of TOOL_ACTIVITY_LABEL_PREFIXES) {
136
+ if (toolName.startsWith(prefix)) {
137
+ return label;
138
+ }
139
+ }
140
+ return GENERIC_ACTIVITY_LABEL;
141
+ }