@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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 (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -33,6 +33,10 @@ export const GMAIL_API_BASE_URL =
33
33
  * requests Calendar-only). Resolving against Gmail read access turns a
34
34
  * downstream 403 into an actionable "reconnect Google and grant Gmail" error
35
35
  * at resolution time when the selected connection cannot read Gmail.
36
+ *
37
+ * Managed connections never carry this scope: Vellum's app grants Gmail as
38
+ * `https://mail.google.com/`, which `scopeDifference` in `oauth/scope-utils.ts`
39
+ * accepts as covering it.
36
40
  */
37
41
  export const GMAIL_REQUIRED_SCOPES = [
38
42
  "https://www.googleapis.com/auth/gmail.readonly",
@@ -13,7 +13,7 @@ Producer → NotificationSignal → Source-Active Gate → Candidate Generation
13
13
 
14
14
  ### 1. Signal
15
15
 
16
- A producer calls `emitNotificationSignal()` with a free-form event name, attention hints (urgency, requiresAction, deadlineAt), and a context payload. The signal is persisted as a `notification_events` row.
16
+ A producer calls `emitNotificationSignal()` with a free-form event name, attention hints (urgency, requiresAction, deadlineAt), and a context payload. The signal is persisted as a `notification_events` row. A `dedupeKey` the producer passes is claimed by that row on insert, with no time window: a later signal carrying a key an existing row holds gets no row of its own and returns `deduplicated: true` before anything below runs. See [Choosing `dedupeKey`](#choosing-dedupekey).
17
17
 
18
18
  Immediately after persistence, a **source-active pre-gate** runs (`checkSourceActiveSuppression`): when `visibleInSourceNow` is set, a hard signal-only invariant the decision engine cannot override, the signal is suppressed and short-circuits here, before candidate generation and the LLM decision. This keeps an always-suppressed signal (e.g. trusted-contact `verification_sent`) from spending an LLM inference whose result would be discarded. The `notification_events` row is still written for the audit trail.
19
19
 
@@ -65,7 +65,7 @@ Hard invariants that the LLM cannot override:
65
65
 
66
66
  - **Schema validity** -- fail-closed if the decision is malformed
67
67
  - **Channel availability** -- at least one selected channel must be connected
68
- - **Deduplication** -- same `dedupeKey` within the dedupe window (1 hour default) is suppressed
68
+ - **Deduplication** -- the decision's `dedupeKey` matches another event row younger than the dedupe window (1 hour default). This is the engine's own key; the producer's key was already checked at event insert in step 1, with no window.
69
69
  - **Rendered copy quality** -- fail-closed on empty copy or a fallback leak (body equal to the raw event name)
70
70
 
71
71
  Source-active suppression depends only on the signal, so it runs earlier — as the pre-decision gate in `emit-signal.ts` (see step 1), not as a pre-send check here.
@@ -470,7 +470,15 @@ Step 1 suppresses on this hint before anything else runs, and nothing downstream
470
470
  - **Opt in only when the notification duplicates something that conversation has already rendered, and only behind a flag that names your producer.** `resolveVisibleInSourceNow()` reads `activity-presence-suppression`, which gates the background-activity failure producer alone, so a new producer needs its own flag or an extension of that one before it calls the resolver. Otherwise the flag silently governs a producer its description does not cover. Having a conversation id in scope is not sufficient; the producer must know that _this_ arrival put the announced thing on screen there. `runtime/background-job-runner.ts` is the worked example: one catch block is reached from several directions, and it passes `presenceConversationId` only when the failure carries a `turnFailure`, the one arrival whose conversation is guaranteed to have emitted a `conversation_error` the user can read. A runner timeout leaves the turn still rendering as in progress and a bootstrap throw never reaches the agent loop, so both keep notifying.
471
471
  - **Pass a literal `false` for global and infra signals.** Heartbeat, credential health, webhook health, and worker liveness have no source surface to watch, so there is nothing to duplicate.
472
472
  - **Never opt in a `guardian.question` producer.** The card _is_ the prompt, so a `true` suppresses the question's only rendering and the tool hangs until the prompt timeout. `runtime/question-request-guardian-bridge.ts` carries the rationale at its hint block, and three regression pins hold the line: `runtime/__tests__/question-request-guardian-bridge.test.ts`, `__tests__/confirmation-request-guardian-bridge.test.ts`, and `__tests__/notification-guardian-path.test.ts`.
473
- - **A producer running inside the schedule worker cannot read presence at all.** `schedule/worker.ts` is a standalone OS process and the sole runner of schedule execution, while the resolver reads `assistantEventHub` through `isWebConversationFocused()` and that hub's SSE clients exist only in the assistant process. Every presence read from the worker resolves `false`, whatever the user is watching. The trap is in testing: a test that drives the producer and mocks presence in one process passes while the shipped worker suppresses nothing. Tracked as JARVIS-1711.
473
+ - **A producer running outside the main assistant process cannot read presence at all.** The schedule worker (`schedule/worker.ts`) and the memory jobs worker (`plugins/defaults/memory/worker.ts`) are standalone OS processes, while the resolver reads `assistantEventHub` through `isWebConversationFocused()` and that hub's SSE clients exist only in the assistant process. Every presence read from a worker resolves `false`, whatever the user is watching. The trap is in testing: a test that drives the producer and mocks presence in one process passes while the shipped worker suppresses nothing. Tracked as JARVIS-1711. The boundary runs the other way too: a hub publish from a worker is forwarded to the assistant process best-effort (`runtime/assistant-event-hub.ts`, logged rather than thrown on failure). A worker-side producer is therefore guaranteed its event row and nothing past it: the home-feed write is best-effort in every process (`writeHomeFeedItemForSignal` returns `null` for an ineligible signal, a missing summary, or a failed write, and `emit-signal.ts` only logs a rejection), and the live push to open clients depends on the forward as well.
474
+
475
+ ### Choosing `dedupeKey`
476
+
477
+ There are two keys and they do different jobs. Choose the producer's by what it names, not by how often it fires.
478
+
479
+ - **The producer's key is a permanent claim, not a rate limit.** `createEvent` refuses a second row for a key any row already holds (`events-store.ts`), however old that row is. Key on a durable identity (a job row, a request id) to attempt a thing at most once ever; key on that identity plus a time bucket to attempt it at most once per bucket. A key with no identity in it silences every later instance for the whole bucket, whether or not it was the same thing. At most once, not exactly once: the claim lands at insert, before the decision and dispatch, so a process that dies between the two leaves the key claimed with nothing announced, and the retry short-circuits `deduplicated: true` and reports success. A producer that must not lose an announcement across a mid-pipeline shutdown (routine during an app update) needs its own record of delivery, not the key.
480
+ - **A failed pipeline releases the claim; a retry re-emits.** When the pipeline fails after the row landed and no channel ran (`hasChannelSideEffect` is false), `emit-signal.ts` writes the key back to `null`, so the same producer can retry the same key. When any channel already ran, the claim stays: releasing it would double-send to those channels. This is what makes a crash retry idempotent against a double send for a producer keyed on a durable row id: `plugins/defaults/memory/skill-update-receipt-job.ts` keys on its job row, so a retry past the event row meets the claim, is reported `deduplicated: true, pipelineFailed: false`, and completes.
481
+ - **The decision's key is windowed.** The engine picks its own key per decision; step 4 checks it against event rows inside the 1-hour window and writes it onto the row only when the producer passed none. A producer never passes the engine's key and never appeals its verdict.
474
482
 
475
483
  ## Audit Trail
476
484
 
@@ -20,7 +20,7 @@ const messageAppends: Array<{
20
20
  conversationId: string;
21
21
  role: string;
22
22
  content: string;
23
- options?: { skipIndexing?: boolean };
23
+ options?: { skipIndexing?: boolean; skipResurface?: boolean };
24
24
  }> = [];
25
25
  const messageRewrites: Array<{ messageId: string; content: string }> = [];
26
26
  /** messageId -> the conversation it belongs to, for the scoped lookup. */
@@ -86,7 +86,7 @@ mock.module("../../persistence/conversation-crud.js", () => ({
86
86
  conversationId: string,
87
87
  role: string,
88
88
  content: string,
89
- options?: { skipIndexing?: boolean },
89
+ options?: { skipIndexing?: boolean; skipResurface?: boolean },
90
90
  ) => {
91
91
  if (messageAppendShouldThrow) {
92
92
  throw new Error("simulated message write failure");
@@ -1039,6 +1039,88 @@ describe("writeHomeFeedItemForSignal", () => {
1039
1039
  expect(appendCalls).toHaveLength(1);
1040
1040
  });
1041
1041
 
1042
+ // ── skill-update receipts ───────────────────────────────────────────
1043
+
1044
+ const RECEIPT_UPDATES = [
1045
+ {
1046
+ skillId: "skill-a",
1047
+ name: "Skill A",
1048
+ summary: "Changed a step.",
1049
+ conversationId: "conv-source-1",
1050
+ },
1051
+ { skillId: "skill-b", name: "Skill B", summary: "Changed another." },
1052
+ ];
1053
+
1054
+ test("a receipt's entries land as the typed updates, out of metadata, under the list panel", async () => {
1055
+ conversationRow = { conversationType: "background" };
1056
+ const signal = makeSignal({
1057
+ sourceChannel: "assistant_tool",
1058
+ sourceEventName: "activity.complete",
1059
+ contextPayload: {
1060
+ title: "2 skills updated",
1061
+ body: "- Skill A: Changed a step.\n- Skill B: Changed another.",
1062
+ updates: RECEIPT_UPDATES,
1063
+ skillId: "skill-a",
1064
+ },
1065
+ });
1066
+
1067
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
1068
+
1069
+ expect(item?.updates).toEqual(RECEIPT_UPDATES);
1070
+ expect(item?.detailPanel).toEqual({ kind: "updatesList" });
1071
+ expect(item?.metadata).not.toHaveProperty("updates");
1072
+ expect(item?.metadata?.skillId).toBe("skill-a");
1073
+ });
1074
+
1075
+ test("a receipt's summary is the payload body, never model copy", async () => {
1076
+ // The pipeline's seed message and body win for every other item; a
1077
+ // paraphrase of a receipt can drop an entry, so the list is kept.
1078
+ conversationRow = { conversationType: "background" };
1079
+ const signal = makeSignal({
1080
+ sourceChannel: "assistant_tool",
1081
+ sourceEventName: "activity.complete",
1082
+ contextPayload: {
1083
+ title: "2 skills updated",
1084
+ body: "- Skill A: Changed a step.\n- Skill B: Changed another.",
1085
+ updates: RECEIPT_UPDATES,
1086
+ },
1087
+ });
1088
+ const decision = makeDecision({
1089
+ selectedChannels: ["vellum"],
1090
+ renderedCopy: {
1091
+ vellum: {
1092
+ title: "Skills refreshed",
1093
+ body: "Two skills were refreshed.",
1094
+ conversationSeedMessage:
1095
+ "The assistant refreshed two of your skills after reviewing recent work.",
1096
+ },
1097
+ },
1098
+ });
1099
+
1100
+ const item = await writeHomeFeedItemForSignal(signal, decision);
1101
+
1102
+ expect(item?.summary).toBe(
1103
+ "- Skill A: Changed a step.\n- Skill B: Changed another.",
1104
+ );
1105
+ });
1106
+
1107
+ test("a malformed or empty updates list leaves the item an ordinary notification", async () => {
1108
+ conversationRow = { conversationType: "background" };
1109
+ for (const updates of [[], [{ name: "no id" }], "not a list"]) {
1110
+ appendCalls.length = 0;
1111
+ const signal = makeSignal({
1112
+ sourceChannel: "assistant_tool",
1113
+ sourceEventName: "activity.complete",
1114
+ contextPayload: { title: "Skill updated", body: "Body", updates },
1115
+ });
1116
+
1117
+ const item = await writeHomeFeedItemForSignal(signal, makeDecision());
1118
+
1119
+ expect(item?.updates).toBeUndefined();
1120
+ expect(item?.detailPanel).toBeUndefined();
1121
+ }
1122
+ });
1123
+
1042
1124
  // ── noteworthy derivation ────────────────────────────────────────────
1043
1125
 
1044
1126
  test("assistant_tool source marks the feed item noteworthy", async () => {
@@ -1264,7 +1346,9 @@ describe("writeHomeFeedItemForSignal", () => {
1264
1346
  conversationId: "conv-source-1",
1265
1347
  role: "assistant",
1266
1348
  content: "Three things today.",
1267
- options: { skipIndexing: true },
1349
+ // Bookkeeping about the conversation, so it never resurfaces a
1350
+ // chat the user marked Done.
1351
+ options: { skipIndexing: true, skipResurface: true },
1268
1352
  });
1269
1353
  expect(item?.metadata?.notificationConversationMessageId).toBe("msg-1");
1270
1354
  // A client with the conversation open refetches only on the messages
@@ -61,6 +61,7 @@ import {
61
61
  isConversationSeedSane,
62
62
  } from "./conversation-seed-composer.js";
63
63
  import { isGuardianRequestSignalEvent } from "./guardian-feed-projection.js";
64
+ import { signalMirrorsToHomeFeed } from "./home-feed-mirror.js";
64
65
  import type { NotificationSignal } from "./signal.js";
65
66
  import type {
66
67
  ConversationAction,
@@ -274,7 +275,7 @@ export async function pairDeliveryWithConversation(
274
275
  signal.sourceEventName === ASSISTANT_REPLY_EVENT
275
276
  ) {
276
277
  return {
277
- conversationId: resolveSourceConversationId(signal),
278
+ conversationId: resolveSourceConversation(signal)?.id ?? null,
278
279
  messageId: null,
279
280
  strategy,
280
281
  createdNewConversation: false,
@@ -767,12 +768,15 @@ async function resolveChannelDeliveryHome(params: {
767
768
  * Indexing is skipped for parity with the other notification write paths:
768
769
  * notification copy is delivery audit, not conversational memory.
769
770
  */
770
- function resolveSourceConversationId(signal: NotificationSignal): string | null {
771
+ function resolveSourceConversation(
772
+ signal: NotificationSignal,
773
+ ): { id: string; conversationType?: string } | null {
771
774
  if (!signal.sourceContextId) {
772
775
  return null;
773
776
  }
774
777
  try {
775
- return getConversation(signal.sourceContextId)?.id ?? null;
778
+ const row = getConversation(signal.sourceContextId);
779
+ return row ? { id: row.id, conversationType: row.conversationType } : null;
776
780
  } catch {
777
781
  return null;
778
782
  }
@@ -783,14 +787,25 @@ async function appendBodyToSourceConversation(
783
787
  channel: NotificationChannel,
784
788
  messageContent: string,
785
789
  ): Promise<{ conversationId: string; messageId: string } | null> {
786
- const conversationId = resolveSourceConversationId(signal);
787
- if (!conversationId) {
790
+ const source = resolveSourceConversation(signal);
791
+ if (!source) {
788
792
  return null;
789
793
  }
794
+ const conversationId = source.id;
790
795
 
791
- const message = await addMessage(conversationId, "assistant", messageContent, {
792
- skipIndexing: true,
793
- });
796
+ const message = await addMessage(
797
+ conversationId,
798
+ "assistant",
799
+ messageContent,
800
+ {
801
+ skipIndexing: true,
802
+ // A notification the bell carries is bookkeeping about the
803
+ // conversation, not activity in it, so it must not bounce a chat the
804
+ // user just marked Done back into the sidebar. One the bell does not
805
+ // carry has this transcript as its only home, and the chat resurfaces.
806
+ skipResurface: signalMirrorsToHomeFeed(signal, source.conversationType),
807
+ },
808
+ );
794
809
  // `addMessage` projects attention metadata alone, so a client with this
795
810
  // conversation open needs the messages tag to refetch the transcript. A
796
811
  // notification the user taps through to has every chance of landing on an
@@ -0,0 +1,39 @@
1
+ import { isBackgroundConversationType } from "../persistence/conversation-types.js";
2
+ import { readChannelAllowlist } from "./channel-allowlist.js";
3
+ import type { NotificationSignal } from "./signal.js";
4
+
5
+ /**
6
+ * Whether the home feed carries a card for `signal`, judged from the signal
7
+ * and the conversation it names. The feed side effect's mirror decision is
8
+ * this plus two checks only it can make (a guardian request always mirrors;
9
+ * a delivery that materialized an assistant-initiated thread never does).
10
+ *
11
+ * The one other reader is the passive vellum append: a notification the
12
+ * bell carries is bookkeeping about the conversation it is appended to, so
13
+ * that append must not resurface a Done chat, while a notification the bell
14
+ * does not carry has the transcript as its only home and must.
15
+ *
16
+ * `assistant_tool` mirrors unconditionally because the documented
17
+ * `notifications send` skill (and background-job failure emits) deliberately
18
+ * target the home feed, unless an exclusive channel allowlist leaves vellum
19
+ * out; `chat.assistant_reply` follows the same rule. An async-background
20
+ * hint or a background-typed source conversation mirrors as well.
21
+ */
22
+ export function signalMirrorsToHomeFeed(
23
+ signal: NotificationSignal,
24
+ sourceConversationType: string | undefined,
25
+ ): boolean {
26
+ if (
27
+ signal.sourceChannel === "assistant_tool" ||
28
+ signal.sourceEventName === "chat.assistant_reply"
29
+ ) {
30
+ const allowlist = readChannelAllowlist(signal.contextPayload);
31
+ if (!allowlist || allowlist.includes("vellum")) {
32
+ return true;
33
+ }
34
+ }
35
+ if (signal.attentionHints.isAsyncBackground) {
36
+ return true;
37
+ }
38
+ return isBackgroundConversationType(sourceConversationType);
39
+ }
@@ -15,6 +15,8 @@ import {
15
15
  type FeedItemCategory,
16
16
  type FeedItemDetailPanelKind,
17
17
  feedItemSchema,
18
+ type FeedItemUpdate,
19
+ feedItemUpdateSchema,
18
20
  } from "../home/feed-types.js";
19
21
  import { appendFeedItem } from "../home/feed-writer.js";
20
22
  import {
@@ -23,14 +25,11 @@ import {
23
25
  getMessageById,
24
26
  updateMessageContent,
25
27
  } from "../persistence/conversation-crud.js";
26
- import {
27
- ASSISTANT_INITIATED_SOURCE,
28
- isBackgroundConversationType,
29
- } from "../persistence/conversation-types.js";
28
+ import { ASSISTANT_INITIATED_SOURCE } from "../persistence/conversation-types.js";
30
29
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
31
30
  import { getLogger } from "../util/logger.js";
31
+ import { isPlainObject } from "../util/object.js";
32
32
  import { normalizeTitle, stripMarkdown } from "../util/short-title.js";
33
- import { readChannelAllowlist } from "./channel-allowlist.js";
34
33
  import { isConversationSeedSane } from "./conversation-seed-composer.js";
35
34
  import { deriveTitle } from "./copy-composer.js";
36
35
  import {
@@ -39,6 +38,7 @@ import {
39
38
  isGuardianRequestSignalEvent,
40
39
  receiptGuardianFeedItemIfRequestTerminal,
41
40
  } from "./guardian-feed-projection.js";
41
+ import { signalMirrorsToHomeFeed } from "./home-feed-mirror.js";
42
42
  import { readPayloadString } from "./notification-utils.js";
43
43
  import type { NotificationSignal } from "./signal.js";
44
44
  import type {
@@ -65,6 +65,30 @@ const log = getLogger("home-feed-side-effect");
65
65
  */
66
66
  const CONVERSATION_MESSAGE_ID_KEY = "notificationConversationMessageId";
67
67
 
68
+ /**
69
+ * Payload key a skill-update receipt carries its entries under. Read here
70
+ * through the wire schema and mapped onto the item's typed `updates`, so the
71
+ * panel the client opens is decided by the shape, never by copy.
72
+ */
73
+ const UPDATES_KEY = "updates";
74
+
75
+ /**
76
+ * The receipt entries in `contextPayload`, when it carries a non-empty list
77
+ * that parses as the wire shape; otherwise undefined, and the item renders
78
+ * as an ordinary notification. A malformed list is dropped rather than
79
+ * mirrored, since a client would render whatever survived.
80
+ */
81
+ function readUpdates(contextPayload: unknown): FeedItemUpdate[] | undefined {
82
+ if (!isPlainObject(contextPayload)) {
83
+ return undefined;
84
+ }
85
+ const parsed = feedItemUpdateSchema
86
+ .array()
87
+ .min(1)
88
+ .safeParse(contextPayload[UPDATES_KEY]);
89
+ return parsed.success ? parsed.data : undefined;
90
+ }
91
+
68
92
  /**
69
93
  * Append a `FeedItem` for the given notification signal when the
70
94
  * filter criteria pass.
@@ -106,12 +130,19 @@ export async function writeHomeFeedItemForSignal(
106
130
  readPayloadString(signal.contextPayload, "body") ??
107
131
  readPayloadString(signal.contextPayload, "requestedMessage");
108
132
 
133
+ // A receipt's summary is the producer's own list of what changed, which
134
+ // the panel renders from the typed entries and a client without the panel
135
+ // reads as-is. Model copy is not allowed to rewrite it: a paraphrase can
136
+ // drop an entry, and the list is the receipt's whole point.
137
+ const updates = readUpdates(signal.contextPayload);
138
+
109
139
  // Prefer conversationSeedMessage over body for the home feed: the seed
110
140
  // message is richer and may contain structured markdown (lists, headers,
111
141
  // bold) that the detail panel renders. The popup-oriented `body` is
112
142
  // intentionally short (≤ 2 sentences) and loses formatting.
113
143
  const seedCandidate = renderedCopy?.conversationSeedMessage;
114
144
  const resolvedSummary =
145
+ (updates ? payloadBody?.trim() : undefined) ||
115
146
  (isConversationSeedSane(seedCandidate)
116
147
  ? seedCandidate.trim()
117
148
  : undefined) ||
@@ -146,7 +177,7 @@ export async function writeHomeFeedItemForSignal(
146
177
  const now = new Date().toISOString();
147
178
 
148
179
  const category = deriveCategory(signal);
149
- const panelKind = deriveDetailPanelKind(signal);
180
+ const panelKind = updates ? "updatesList" : deriveDetailPanelKind(signal);
150
181
 
151
182
  const baseMetadata =
152
183
  signal.contextPayload &&
@@ -157,6 +188,9 @@ export async function writeHomeFeedItemForSignal(
157
188
  // Producer payloads reach the card verbatim, and this key addresses a
158
189
  // message row for rewriting, so only this module may set it.
159
190
  delete baseMetadata?.[CONVERSATION_MESSAGE_ID_KEY];
191
+ // The entries ride on the item as its typed `updates`, not in the
192
+ // free-form bag, so a client reads them from one place.
193
+ delete baseMetadata?.[UPDATES_KEY];
160
194
 
161
195
  // Link scheduled-run notifications back to their schedule. `notify`-mode
162
196
  // jobs put `scheduleId` directly in the context payload; `execute`-mode (and
@@ -205,6 +239,7 @@ export async function writeHomeFeedItemForSignal(
205
239
  ...(urgency ? { urgency } : {}),
206
240
  ...(sourceConversationId ? { conversationId: sourceConversationId } : {}),
207
241
  ...(panelKind ? { detailPanel: { kind: panelKind } } : {}),
242
+ ...(updates ? { updates } : {}),
208
243
  ...(metadata ? { metadata } : {}),
209
244
  };
210
245
 
@@ -322,6 +357,10 @@ async function appendSummaryToFeedTarget(
322
357
  try {
323
358
  const message = await addMessage(conversationId, "assistant", summary, {
324
359
  skipIndexing: true,
360
+ // The body is bookkeeping about the conversation, not activity in it:
361
+ // a receipt for work the conversation already saw must not bounce a
362
+ // chat the user just marked Done back into the sidebar.
363
+ skipResurface: true,
325
364
  });
326
365
  publishConversationMessagesChanged(conversationId);
327
366
  log.info(
@@ -553,19 +592,7 @@ function resolveHomeFeedMirror(
553
592
  if (isAssistantInitiatedThreadDelivery(fallbackConversationId)) {
554
593
  return { mirror: false };
555
594
  }
556
- if (
557
- signal.sourceChannel === "assistant_tool" ||
558
- signal.sourceEventName === "chat.assistant_reply"
559
- ) {
560
- const allowlist = readChannelAllowlist(signal.contextPayload);
561
- if (!allowlist || allowlist.includes("vellum")) {
562
- return { mirror: true, sourceConversationId, sourceScheduleJobId };
563
- }
564
- }
565
- if (signal.attentionHints.isAsyncBackground) {
566
- return { mirror: true, sourceConversationId, sourceScheduleJobId };
567
- }
568
- if (isBackgroundConversationType(sourceRow?.conversationType)) {
595
+ if (signalMirrorsToHomeFeed(signal, sourceRow?.conversationType)) {
569
596
  return { mirror: true, sourceConversationId, sourceScheduleJobId };
570
597
  }
571
598
  return { mirror: false };
@@ -14,6 +14,12 @@ Add an entry to `PROVIDER_SEED_DATA`. Required fields: `provider`, `authorizeUrl
14
14
 
15
15
  If the provider will support managed mode, set `managedServiceConfigKey` to a slug matching the key you will add to `ServicesSchema` (e.g. `"acme-oauth"`).
16
16
 
17
+ #### Scopes: your-own vs. managed
18
+
19
+ `defaultScopes` here is requested only by the user's own OAuth client. A managed connection authorizes against Vellum's OAuth app, whose defaults live in the platform's `provider_registry.json`. Google shows users warnings or errors for scopes not approved for the app, so the managed start endpoint rejects anything outside the platform registry's `extra_config.allowed_scopes`. Managed Google, for example, grants Gmail only as `https://mail.google.com/`, never `gmail.readonly` / `gmail.modify` / `gmail.send`.
20
+
21
+ So anything that names scopes for a managed connection (a hardcoded constant, a client hook, `requestedScopes` on an `oauth_connect` surface, a `--scopes` example in a skill) must use scopes from that approved list, not the `defaultScopes` names. A required-scope check that runs against managed grants must accept the approved equivalent: `grantedScopeCoversRequiredScope` in `scope-utils.ts` treats `mail.google.com` as covering `gmail.readonly`. Adding a scope to a managed flow means getting it approved in the provider console and adding it to `allowed_scopes` first.
22
+
17
23
  ### 2. _(managed only)_ Add a service schema — `../config/schemas/services.ts`
18
24
 
19
25
  Create a schema and export its type:
@@ -98,7 +104,7 @@ A grant is pinned when the caller passed `--account` or the resolved connection
98
104
 
99
105
  The grant carries one scope for one route, and four checks hold it there:
100
106
 
101
- - **Gateway edge auth** (`gateway/src/http/middleware/auth.ts`) refuses it on every gateway-native route, with no loopback fallback. `isSingleRouteGrant` calls `isNarrowScopeProfile` from `gateway/src/auth/scopes.ts`, and also catches a proxy subject carrying some other profile. The passthrough itself never passes through edge auth: it falls to the runtime-proxy catch-all, which re-mints the grant's own subject and profile for the daemon. That holds whether or not the gateway requires client-facing auth (`gateway.runtimeProxyRequireAuth`, off under `dev:proxy`): a presented grant is validated the same way in both modes and only a caller presenting no valid grant is answered with the gateway's own service token, so the minted URL works in either and neither mode hands the grant more than `oauth.proxy`.
107
+ - **Gateway edge auth** (`gateway/src/http/middleware/auth.ts`) refuses it on every gateway-native route, with no loopback fallback. `isSingleRouteGrant` calls `isNarrowScopeProfile` from `gateway/src/auth/scopes.ts`, and also catches a proxy subject carrying some other profile. The passthrough itself never passes through edge auth: it falls to the runtime-proxy catch-all, which re-mints the grant's own subject and profile for the daemon. That holds whether or not the gateway requires client-facing auth (`gateway.runtimeProxyRequireAuth`): a presented grant is validated the same way in both modes and only a caller presenting no valid grant is answered with the gateway's own service token, so the minted URL works in either and neither mode hands the grant more than `oauth.proxy`.
102
108
  - **The gateway's IPC fast path** (`gateway/src/http/routes/ipc-runtime-proxy.ts`) refuses it against any daemon route naming no scope. The daemon's IPC server runs no policy check of its own, so this is the only enforcement an IPC-served request gets.
103
109
  - **`enforcePolicy`** (`../runtime/auth/route-policy.ts`) refuses the same on the HTTP path, then applies the route's own scopes. `oauth.proxy` reaches the passthrough and nothing else. It runs wherever `routeRequest` dispatches through the router, which is every `/v1` route and the shareable-pages path bar three groups that earlier branches answer first, each carrying a gate of its own: `GET /v1/audio/:audioId`, deliberately public and served before `authenticateRequest` runs at all because Twilio fetches it directly; the Twilio webhook paths (`TWILIO_WEBHOOK_RE`), also pre-auth and gated on Twilio's request signature; and the WebSocket upgrades in the next bullet, which never reach the route table. A route added anywhere else is covered.
104
110
  - **WebSocket upgrades** (`/v1/calls/media-stream`, `/v1/stt/stream`, `/v1/live-voice`, `/v1/watch/stream`) are handled before the route table on both hops, so neither edge auth nor `enforcePolicy` sees them, and each carries its own identity gate. The daemon admits only a private-network peer presenting a gateway service token; at the edge it is an actor principal on `runtime-audio-stream` and `live-voice`, the `speech.relay` scope on the relay, and the relay-token identity on `twilio-media-websocket`.
@@ -58,6 +58,14 @@ export interface QuestionPromptEntryResult {
58
58
  export interface QuestionPromptResult {
59
59
  entries: QuestionPromptEntryResult[];
60
60
  overall: "completed" | "closed" | "timed_out" | "aborted";
61
+ /**
62
+ * True when the answer arrived as a message typed into the chat while the
63
+ * card was still open, rather than through the card itself. Only the first
64
+ * question carries the typed text; the rest stay `skipped`. The
65
+ * `ask_question` tool says so in the model-facing result, so the model can
66
+ * map the reply onto the questions itself.
67
+ */
68
+ answeredInChat?: boolean;
61
69
  }
62
70
 
63
71
  /**
@@ -178,10 +186,12 @@ export interface QuestionBatchMetadata {
178
186
  *
179
187
  * The idle timeout is a backstop (`getConfig().timeouts.questionResponseTimeoutSec`,
180
188
  * default 30 min), not the primary way a prompt is dismissed. An interactive
181
- * user who moves on instead of answering enqueues another message, which
182
- * supersedes the open prompt at the enqueue handler (see conversation-routes.ts);
183
- * a non-interactive turn resolves immediately at the tool. The backstop only
184
- * fires when a prompt is left open with no response and no follow-up message.
189
+ * user who types into the chat instead of using the card settles the prompt at
190
+ * the enqueue handler: the typed text answers it, or, when it cannot stand in
191
+ * for an answer, supersedes it (see
192
+ * `daemon/handlers/conversations.ts`). A non-interactive turn resolves
193
+ * immediately at the tool. The backstop only fires when a prompt is left open
194
+ * with no response and no follow-up message.
185
195
  */
186
196
  export class QuestionPrompter {
187
197
  async prompt(params: QuestionPromptParams): Promise<QuestionPromptOutcome> {