@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
@@ -1,14 +1,16 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { CALL_SITE_DEFAULTS } from "../../config/call-site-defaults.js";
4
3
  import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
5
- import { resolveDefaultProfileKey } from "../../config/llm-resolver.js";
4
+ import {
5
+ resolveDefaultProfileKey,
6
+ resolveShippedDefaultProfileKey,
7
+ } from "../../config/llm-resolver.js";
6
8
  import { loadConfig } from "../../config/loader.js";
7
9
  import {
8
10
  CALL_SITE_CATALOG,
9
11
  CALL_SITE_DOMAINS,
10
12
  } from "../../config/schemas/call-site-catalog.js";
11
- import type { LLMCallSite } from "../../config/schemas/llm.js";
13
+ import type { LLMCallSite, LLMConfig } from "../../config/schemas/llm.js";
12
14
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
13
15
  import type { RouteDefinition } from "./types.js";
14
16
 
@@ -30,7 +32,8 @@ const callSiteEntrySchema = z.object({
30
32
  * `defaultProfile` would scatter pinned or remapped sites across their
31
33
  * winners. Profileless call sites report `balanced`: the resolver's
32
34
  * fallback anchor consults the balanced remap for them, so they follow
33
- * the Balanced tier like any balanced-keyed site.
35
+ * the Balanced tier like any balanced-keyed site. A managed-only tier is
36
+ * omitted when it has no body under the workspace's default provider.
34
37
  */
35
38
  shippedDefaultProfile: z.string().optional(),
36
39
  });
@@ -40,16 +43,22 @@ const callSiteCatalogResponseSchema = z.object({
40
43
  callSites: z.array(callSiteEntrySchema),
41
44
  });
42
45
 
46
+ export function buildCallSiteCatalog(llm: LLMConfig) {
47
+ return CALL_SITE_CATALOG.map((entry) => ({
48
+ ...entry,
49
+ defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
50
+ shippedDefaultProfile: resolveShippedDefaultProfileKey(
51
+ entry.id as LLMCallSite,
52
+ llm,
53
+ ),
54
+ }));
55
+ }
56
+
43
57
  async function handleGetCallSites() {
44
58
  const { llm } = loadConfig();
45
59
  return {
46
60
  domains: CALL_SITE_DOMAINS,
47
- callSites: CALL_SITE_CATALOG.map((entry) => ({
48
- ...entry,
49
- defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
50
- shippedDefaultProfile:
51
- CALL_SITE_DEFAULTS[entry.id]?.profile ?? "balanced",
52
- })),
61
+ callSites: buildCallSiteCatalog(llm),
53
62
  };
54
63
  }
55
64
 
@@ -90,7 +90,12 @@ const PlatformStatusResponseSchema = z.object({
90
90
  isPlatform: z.boolean(),
91
91
  baseUrl: z.string(),
92
92
  assistantId: z.string(),
93
- hasAssistantApiKey: z.boolean(),
93
+ hasAssistantApiKey: z
94
+ .boolean()
95
+ .nullable()
96
+ .describe(
97
+ "Whether a managed assistant API key is stored. Null when the credential store could not be read, which is not evidence that no key is stored.",
98
+ ),
94
99
  hasWebhookSecret: z.boolean(),
95
100
  clientInstallationId: z.string().nullable(),
96
101
  available: z.boolean(),
@@ -48,7 +48,10 @@ import {
48
48
  type InstalledPluginInfo,
49
49
  listInstalledPlugins,
50
50
  } from "../../cli/lib/list-installed-plugins.js";
51
- import { getPluginCatalog } from "../../cli/lib/plugin-catalog-cache.js";
51
+ import {
52
+ getPluginCatalog,
53
+ getPluginCatalogForInstalledMetadata,
54
+ } from "../../cli/lib/plugin-catalog-cache.js";
52
55
  import { resolvePluginSourceFromCatalog } from "../../cli/lib/plugin-catalog-resolve.js";
53
56
  import {
54
57
  DEFAULT_PIN_HISTORY_LIMIT,
@@ -969,7 +972,7 @@ export async function loadCategoryMapBounded(
969
972
  let timer: ReturnType<typeof setTimeout> | undefined;
970
973
  try {
971
974
  const catalog = await Promise.race([
972
- getPluginCatalog(DEFAULT_PLUGIN_REF, {
975
+ getPluginCatalogForInstalledMetadata(DEFAULT_PLUGIN_REF, {
973
976
  fetch: globalThis.fetch.bind(globalThis),
974
977
  }),
975
978
  new Promise<null>((resolve) => {
@@ -1,22 +1,32 @@
1
1
  /**
2
- * A scheduled, heartbeat, or background turn runs its document tools in a
3
- * sidecar worker, whose local hub has no SSE subscriber. The documents-changed
4
- * broadcast has to be handed to the daemon there, or the document edited while
5
- * the user was not looking stays invisible on every client.
2
+ * A scheduled, heartbeat, or background turn runs its document tools and its
3
+ * conversation writes in a sidecar worker, whose local hub has no SSE
4
+ * subscriber. Those broadcasts have to be handed to the daemon there, or the
5
+ * document edited and the conversation changed while the user was not looking
6
+ * stay invisible on every client.
6
7
  */
7
8
 
8
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
9
10
 
10
11
  let notifyCount = 0;
12
+ const listHandoffs: Array<{ reason: string; conversationIds: string[] }> = [];
11
13
 
12
14
  mock.module("./worker-daemon-notify.js", () => ({
13
15
  NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD:
14
16
  "notify_conversation_persisted_externally",
17
+ NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD:
18
+ "notify_conversation_list_changed_externally",
15
19
  NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD: "notify_documents_changed_externally",
16
20
  NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD:
17
21
  "notify_activation_progress_changed_externally",
18
22
  notifyDaemonActivationProgressChanged: async () => {},
19
23
  notifyDaemonConversationPersisted: async () => {},
24
+ notifyDaemonConversationListChanged: async (
25
+ reason: string,
26
+ conversationIds: string[],
27
+ ) => {
28
+ listHandoffs.push({ reason, conversationIds });
29
+ },
20
30
  notifyDaemonDocumentsChanged: async () => {
21
31
  notifyCount++;
22
32
  },
@@ -30,6 +40,7 @@ import {
30
40
  } from "../assistant-stream-state.js";
31
41
  import {
32
42
  DOCUMENTS_CHANGED_COALESCE_MS,
43
+ publishConversationListAndMetadataChanged,
33
44
  publishDocumentsChanged,
34
45
  } from "./resource-sync-events.js";
35
46
 
@@ -66,6 +77,7 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
66
77
  beforeEach(async () => {
67
78
  await settle();
68
79
  notifyCount = 0;
80
+ listHandoffs.length = 0;
69
81
  _resetStreamStateForTesting();
70
82
  });
71
83
 
@@ -102,3 +114,37 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
102
114
  expect(events[0].message).toMatchObject({ type: "sync_changed" });
103
115
  });
104
116
  });
117
+
118
+ describe("publishConversationListAndMetadataChanged in a sidecar worker", () => {
119
+ beforeEach(async () => {
120
+ await settle();
121
+ listHandoffs.length = 0;
122
+ _resetStreamStateForTesting();
123
+ });
124
+
125
+ test("hands the list invalidation to the daemon instead of publishing into a void", async () => {
126
+ disableStreamSeqStamping();
127
+
128
+ const events = await captureLocalPublishes(() => {
129
+ publishConversationListAndMetadataChanged("reordered", "conv-1");
130
+ });
131
+
132
+ expect(listHandoffs).toEqual([
133
+ { reason: "reordered", conversationIds: ["conv-1"] },
134
+ ]);
135
+ expect(events).toEqual([]);
136
+ });
137
+
138
+ test("publishes on the local hub in the daemon, where subscribers live", async () => {
139
+ const events = await captureLocalPublishes(() => {
140
+ publishConversationListAndMetadataChanged("reordered", "conv-1");
141
+ });
142
+
143
+ expect(listHandoffs).toEqual([]);
144
+ expect(events).toHaveLength(1);
145
+ expect(events[0].message).toMatchObject({
146
+ type: "sync_changed",
147
+ tags: ["conversations:list", "conversation:conv-1:metadata"],
148
+ });
149
+ });
150
+ });
@@ -12,6 +12,7 @@ import { isStreamSeqStampingDisabled } from "../assistant-stream-state.js";
12
12
  import { publishSyncInvalidation } from "./sync-publisher.js";
13
13
  import {
14
14
  notifyDaemonActivationProgressChanged,
15
+ notifyDaemonConversationListChanged,
15
16
  notifyDaemonConversationPersisted,
16
17
  notifyDaemonDocumentsChanged,
17
18
  } from "./worker-daemon-notify.js";
@@ -236,6 +237,16 @@ export function publishConversationListAndMetadataChanged(
236
237
  ? conversationIds
237
238
  : [conversationIds];
238
239
 
240
+ // In a sidecar worker the local hub has no SSE subscribers, so a publish
241
+ // here reaches nobody and the sidebar keeps showing a list the worker has
242
+ // already changed. Hand off to the daemon, which republishes on the hub
243
+ // clients actually subscribe to. A background worker turn has no
244
+ // originating client, so no `originClientId` is forwarded.
245
+ if (isStreamSeqStampingDisabled()) {
246
+ void notifyDaemonConversationListChanged(reason, ids);
247
+ return;
248
+ }
249
+
239
250
  // Shape-changing reasons (`created`, `deleted`, `reordered`) add or
240
251
  // remove rows or change the order of the paginated window — web must
241
252
  // refetch the list, so include the `conversationsList` umbrella tag.
@@ -33,13 +33,43 @@ mock.module("../../ipc/cli-client.js", () => ({
33
33
 
34
34
  import {
35
35
  NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
36
+ NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
36
37
  NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
37
38
  NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD,
38
39
  notifyDaemonActivationProgressChanged,
40
+ notifyDaemonConversationListChanged,
39
41
  notifyDaemonConversationPersisted,
40
42
  notifyDaemonDocumentsChanged,
41
43
  } from "./worker-daemon-notify.js";
42
44
 
45
+ describe("notifyDaemonConversationListChanged", () => {
46
+ beforeEach(() => {
47
+ calls.length = 0;
48
+ nextResult = { ok: true };
49
+ shouldThrow = false;
50
+ });
51
+
52
+ test("hands the reason and ids to the daemon over the shared IPC method", async () => {
53
+ await notifyDaemonConversationListChanged("reordered", ["conv-1"]);
54
+
55
+ expect(calls).toHaveLength(1);
56
+ expect(calls[0]!.method).toBe(NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD);
57
+ expect(calls[0]!.params).toEqual({
58
+ body: { reason: "reordered", conversationIds: ["conv-1"] },
59
+ });
60
+ });
61
+
62
+ test("swallows a thrown IPC error (best-effort, no throw)", async () => {
63
+ shouldThrow = true;
64
+
65
+ const result = await notifyDaemonConversationListChanged("reordered", [
66
+ "conv-1",
67
+ ]);
68
+ expect(result).toBeUndefined();
69
+ expect(calls).toHaveLength(1);
70
+ });
71
+ });
72
+
43
73
  describe("notifyDaemonConversationPersisted", () => {
44
74
  beforeEach(() => {
45
75
  calls.length = 0;
@@ -31,6 +31,14 @@ export const NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD =
31
31
  export const NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD =
32
32
  "notify_documents_changed_externally";
33
33
 
34
+ /**
35
+ * IPC method the daemon exposes for the conversation-list hand-off. Shared by
36
+ * the worker caller and the daemon route registration so the wire name cannot
37
+ * drift.
38
+ */
39
+ export const NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD =
40
+ "notify_conversation_list_changed_externally";
41
+
34
42
  /**
35
43
  * IPC method the daemon exposes for the activation-progress hand-off. Shared
36
44
  * by the worker caller and the daemon route registration so the wire name
@@ -79,6 +87,42 @@ export async function notifyDaemonConversationPersisted(
79
87
  }
80
88
  }
81
89
 
90
+ /**
91
+ * Ask the daemon to republish a conversation-list-and-metadata invalidation.
92
+ *
93
+ * A worker turn can change which rows the sidebar should hold, not just what
94
+ * one row contains: clearing `archived_at` on a Done conversation the turn
95
+ * woke is the case that matters. The list tag has to reach real subscribers
96
+ * for the conversation to reappear, and the worker's own hub has none.
97
+ *
98
+ * Best-effort by design, like the other hand-offs: an unreachable daemon is
99
+ * logged at debug and swallowed, and the client picks the change up on its
100
+ * next list fetch.
101
+ */
102
+ export async function notifyDaemonConversationListChanged(
103
+ reason: string,
104
+ conversationIds: string[],
105
+ ): Promise<void> {
106
+ try {
107
+ const result = await cliIpcCall(
108
+ NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
109
+ { body: { reason, conversationIds } },
110
+ { timeoutMs: NOTIFY_TIMEOUT_MS },
111
+ );
112
+ if (!result.ok) {
113
+ log.debug(
114
+ { reason, conversationIds, error: result.error },
115
+ "daemon conversation-list-changed notify was not acknowledged",
116
+ );
117
+ }
118
+ } catch (err) {
119
+ log.debug(
120
+ { err, reason, conversationIds },
121
+ "daemon conversation-list-changed notify failed",
122
+ );
123
+ }
124
+ }
125
+
82
126
  /**
83
127
  * Ask the daemon to republish the documents-list invalidation.
84
128
  *
@@ -278,7 +278,7 @@ async function startOutboundTelegram(
278
278
  success: false,
279
279
  error: "already_bound",
280
280
  message:
281
- "A guardian is already bound for this channel. Set rebind: true to replace.",
281
+ "A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
282
282
  channel,
283
283
  };
284
284
  }
@@ -820,7 +820,7 @@ async function startOutboundTextChannel(
820
820
  success: false,
821
821
  error: "already_bound",
822
822
  message:
823
- "A guardian is already bound for this channel. Set rebind: true to replace.",
823
+ "A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
824
824
  channel,
825
825
  };
826
826
  }
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Deliberate strict subset of JSON Schema: it covers only the keywords
5
5
  * actually used by `assistant/src/config/bundled-skills/**\/TOOLS.json`
6
- * (required / type / enum / items.type) plus unknown-key detection. Anything
7
- * else (`$ref`, `oneOf`, `anyOf`, `allOf`, `format`, `pattern`, `minimum`,
8
- * `maximum`, object-shape `additionalProperties`, etc.) is silently skipped so
9
- * a richer schema can never cause us to reject a legitimate call.
6
+ * (required / type / enum / nested properties and items / required-only oneOf)
7
+ * plus unknown-key detection. Anything else (`$ref`, richer `oneOf`, `anyOf`,
8
+ * `allOf`, `format`, `pattern`, `minimum`, `maximum`, object-shape
9
+ * `additionalProperties`, etc.) is skipped. Nested objects allow extra keys
10
+ * unless their schema explicitly closes them.
10
11
  *
11
12
  * Each error message is written to be agent-readable and self-correcting
12
13
  * (e.g. `surface_id is required`, `mode must be one of "replace", "append"`).
@@ -313,6 +314,15 @@ export function validateInputAgainstSchema(
313
314
  _toolName: string,
314
315
  input: Record<string, unknown>,
315
316
  schema: Record<string, unknown> | undefined,
317
+ ): InputValidationResult {
318
+ return validateObjectInput(input, schema, "", true);
319
+ }
320
+
321
+ function validateObjectInput(
322
+ input: Record<string, unknown>,
323
+ schema: Record<string, unknown> | undefined,
324
+ path: string,
325
+ rejectUnknown: boolean,
316
326
  ): InputValidationResult {
317
327
  // Skip when there's no schema or no properties block — matches today's
318
328
  // lenient behaviour for tools that declare only `{ type: "object" }`.
@@ -338,17 +348,46 @@ export function validateInputAgainstSchema(
338
348
  continue;
339
349
  }
340
350
  if (!(key in input)) {
341
- errors.push(`${key} is required`);
351
+ errors.push(`${path}${key} is required`);
342
352
  }
343
353
  }
344
354
  }
345
355
 
356
+ // Only enforce alternatives whose complete contract is field presence.
357
+ // Richer unions stay permissive because unsupported keywords can decide
358
+ // which branch matches.
359
+ const alternatives = schema.oneOf;
360
+ if (
361
+ Array.isArray(alternatives) &&
362
+ alternatives.length > 0 &&
363
+ alternatives.every(
364
+ (branch) =>
365
+ isPlainObject(branch) &&
366
+ Object.keys(branch).every(
367
+ (key) => key === "required" || key === "description",
368
+ ) &&
369
+ Array.isArray(branch.required) &&
370
+ branch.required.every((key: unknown) => typeof key === "string"),
371
+ )
372
+ ) {
373
+ const choices = alternatives as { required: string[] }[];
374
+ if (
375
+ choices.filter((branch) => branch.required.every((key) => key in input))
376
+ .length !== 1
377
+ ) {
378
+ errors.push(
379
+ `${path ? path.slice(0, -1) : "input"} must match exactly one required field set: ${choices.map((branch) => quoteList(branch.required)).join(" or ")}`,
380
+ );
381
+ }
382
+ }
383
+
346
384
  // 2. Per-property checks: type, enum, items.type.
347
- for (const [key, rawSubSchema] of Object.entries(properties)) {
348
- if (!(key in input)) {
385
+ for (const [property, rawSubSchema] of Object.entries(properties)) {
386
+ if (!(property in input)) {
349
387
  continue;
350
388
  }
351
- const value = input[key];
389
+ const key = `${path}${property}`;
390
+ const value = input[property];
352
391
  // Skip type-checking for absent values; presence is enforced by the
353
392
  // `required` check above. Note: `null` IS a present value and is
354
393
  // type-checked below — only schemas that explicitly opt in to null via
@@ -381,13 +420,25 @@ export function validateInputAgainstSchema(
381
420
  if (!matchesType(value, type)) {
382
421
  errors.push(
383
422
  type === "boolean" && typeof value === "string"
384
- ? `${key} must be a boolean — pass true or false as a JSON boolean, not a string`
423
+ ? `${key} must be a boolean: pass true or false as a JSON boolean, not a string`
385
424
  : `${key} must be ${typeArticle(type)} ${type}`,
386
425
  );
387
426
  // No point checking enum/items if the base type is wrong.
388
427
  continue;
389
428
  }
390
429
 
430
+ if (type === "object" && isPlainObject(value)) {
431
+ const nested = validateObjectInput(
432
+ value,
433
+ rawSubSchema,
434
+ `${key}.`,
435
+ rawSubSchema.additionalProperties === false,
436
+ );
437
+ if (!nested.ok) {
438
+ errors.push(...nested.errors);
439
+ }
440
+ }
441
+
391
442
  // 2a. Enum (string enums are the only shape used today).
392
443
  const enumValues = rawSubSchema.enum;
393
444
  if (Array.isArray(enumValues) && enumValues.length > 0) {
@@ -412,6 +463,17 @@ export function validateInputAgainstSchema(
412
463
  errors.push(
413
464
  `${key}[${index}] must be ${typeArticle(itemTypeName)} ${itemTypeName}`,
414
465
  );
466
+ } else if (itemTypeName === "object" && isPlainObject(element)) {
467
+ const itemSchema = rawSubSchema.items as Record<string, unknown>;
468
+ const nested = validateObjectInput(
469
+ element,
470
+ itemSchema,
471
+ `${key}[${index}].`,
472
+ itemSchema.additionalProperties === false,
473
+ );
474
+ if (!nested.ok) {
475
+ errors.push(...nested.errors);
476
+ }
415
477
  }
416
478
  });
417
479
  }
@@ -420,10 +482,12 @@ export function validateInputAgainstSchema(
420
482
  }
421
483
 
422
484
  // 3. Unknown keys — parity with the previous `validateNoUnknownParams`.
423
- const unknownKeys = Object.keys(input).filter((k) => !knownKeySet.has(k));
485
+ const unknownKeys = rejectUnknown
486
+ ? Object.keys(input).filter((k) => !knownKeySet.has(k))
487
+ : [];
424
488
  for (const key of unknownKeys) {
425
489
  errors.push(
426
- `Unknown parameter "${key}". Supported: ${quoteList(knownKeys)}`,
490
+ `Unknown parameter "${path}${key}". Supported: ${quoteList(knownKeys)}`,
427
491
  );
428
492
  }
429
493
 
@@ -29,8 +29,16 @@ let emitFails = false;
29
29
 
30
30
  // ── Module mocks ──────────────────────────────────────────────────────
31
31
 
32
+ // Spread the real module: mocks are shared across files in a combined run,
33
+ // and a wholesale replacement drops the exports peer modules import.
34
+ const actualSecureKeys = await import("../../security/secure-keys.js");
32
35
  mock.module("../../security/secure-keys.js", () => ({
36
+ ...actualSecureKeys,
33
37
  getSecureKeyAsync: async (account: string) => secureKeyValues.get(account),
38
+ getSecureKeyResultAsync: async (account: string) => ({
39
+ value: secureKeyValues.get(account),
40
+ unreachable: false,
41
+ }),
34
42
  }));
35
43
 
36
44
  mock.module("../../security/credential-key.js", () => ({
@@ -200,6 +200,44 @@ describe("AskQuestionTool.execute", () => {
200
200
  expect(result.isError).toBe(false);
201
201
  });
202
202
 
203
+ test("says so when the user answered in the chat instead of the card", async () => {
204
+ const q2 = {
205
+ question: "Preferred time?",
206
+ options: [
207
+ { id: "morning", label: "Morning" },
208
+ { id: "afternoon", label: "Afternoon" },
209
+ ],
210
+ };
211
+ setNextResult({
212
+ entries: [
213
+ {
214
+ questionId: "q1",
215
+ decision: "free_text",
216
+ text: "banana, after lunch",
217
+ },
218
+ { questionId: "q2", decision: "skipped" },
219
+ ],
220
+ overall: "completed",
221
+ answeredInChat: true,
222
+ });
223
+
224
+ const result = await askQuestionTool.execute(
225
+ { questions: [singleQ, q2] },
226
+ makeContext(),
227
+ );
228
+
229
+ expect(result.isError).toBe(false);
230
+ const lines = String(result.content).split("\n");
231
+ // The model is told where the answer came from, then reads the batch: the
232
+ // typed text on the first question, the rest unanswered, so it can map the
233
+ // reply onto them itself.
234
+ expect(lines[0]).toContain("replied in the chat");
235
+ expect(lines.slice(1)).toEqual([
236
+ `Question "${singleQ.question}" → Free text: banana, after lunch`,
237
+ `Question "${q2.question}" → Skipped`,
238
+ ]);
239
+ });
240
+
203
241
  test("formats skipped result", async () => {
204
242
  setNextResult(singleCompleted({ decision: "skipped" }));
205
243
  const result = await askQuestionTool.execute(validInput, makeContext());
@@ -243,6 +243,15 @@ export function toAnsweredQuestion(
243
243
  };
244
244
  }
245
245
 
246
+ /**
247
+ * Model-facing note prepended when the user answered by typing in the chat
248
+ * instead of using the card. The text lands as free text on the first
249
+ * question and every other question reads as skipped, so the model is told
250
+ * plainly what happened and left to map the reply onto the batch itself.
251
+ */
252
+ const CHAT_REPLY_PREAMBLE =
253
+ "The user replied in the chat instead of using the question card, so their message is recorded as free text on the first question below and the remaining questions are unanswered. Read their reply as the answer to whichever questions it covers, and ask again only for what it genuinely leaves open.";
254
+
246
255
  // ── Tool ────────────────────────────────────────────────────────────
247
256
 
248
257
  /**
@@ -389,7 +398,9 @@ export const askQuestionTool = {
389
398
 
390
399
  switch (result.overall) {
391
400
  case "completed": {
392
- let content = lines.join("\n");
401
+ let content = result.answeredInChat
402
+ ? [CHAT_REPLY_PREAMBLE, ...lines].join("\n")
403
+ : lines.join("\n");
393
404
  if (desktopHelp) {
394
405
  const entry = result.entries[0];
395
406
  if (entry?.decision === "free_text") {
@@ -1,4 +1,12 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
2
10
 
3
11
  import { setConfig } from "../../../__tests__/helpers/set-config.js";
4
12
 
@@ -133,6 +141,25 @@ describe("executeFirecrawlScrape", () => {
133
141
  expect(result.status).toContain("truncated");
134
142
  });
135
143
 
144
+ test("carries the provider's warning and a start past the end in metadata", async () => {
145
+ spyOn(globalThis, "fetch").mockResolvedValue(
146
+ scrapeResponse({
147
+ success: true,
148
+ warning: "The page was served from cache.",
149
+ data: { markdown: "abcdefghij", metadata: { statusCode: 200 } },
150
+ }),
151
+ );
152
+
153
+ const result = await executeFirecrawlScrape(
154
+ { url: "https://example.com", start_index: 50 },
155
+ { apiKey: "fc-key" },
156
+ );
157
+ expect(result.isError).toBe(false);
158
+ const meta = result.activityMetadata?.webFetch;
159
+ expect(meta?.providerWarning).toBe("The page was served from cache.");
160
+ expect(meta?.startIndexPastEnd).toBe(true);
161
+ });
162
+
136
163
  test("empty markdown yields a no-content marker, not an error", async () => {
137
164
  globalThis.fetch = (async () =>
138
165
  scrapeResponse({ success: true, data: { markdown: "" } })) as any;
@@ -130,6 +130,40 @@ describe("web_fetch activityMetadata", () => {
130
130
  expect(meta?.charCount).toBe(maxChars);
131
131
  });
132
132
 
133
+ test("flags a start_index past the end of a page it downloaded whole", async () => {
134
+ const result = await executeWithMockFetch(
135
+ { url: "https://example.com/short", start_index: 500 },
136
+ {
137
+ requestExecutor: async () =>
138
+ new Response("x".repeat(100), {
139
+ status: 200,
140
+ headers: { "content-type": "text/plain; charset=utf-8" },
141
+ }),
142
+ },
143
+ );
144
+ expect(result.isError).toBe(false);
145
+ expect(result.activityMetadata?.webFetch?.startIndexPastEnd).toBe(true);
146
+ });
147
+
148
+ test("does not call a start_index past a capped download the end of the page", async () => {
149
+ // Over the 2 MB download cap: only a prefix is read, so an offset past it
150
+ // may still be inside the page.
151
+ const result = await executeWithMockFetch(
152
+ { url: "https://example.com/huge", start_index: 2_500_000 },
153
+ {
154
+ requestExecutor: async () =>
155
+ new Response("x".repeat(3_000_000), {
156
+ status: 200,
157
+ headers: { "content-type": "text/plain; charset=utf-8" },
158
+ }),
159
+ },
160
+ );
161
+ expect(result.isError).toBe(false);
162
+ const meta = result.activityMetadata?.webFetch;
163
+ expect(meta?.truncated).toBe(true);
164
+ expect(meta?.startIndexPastEnd).toBe(false);
165
+ });
166
+
133
167
  test("populates errorMessage and status on a 404 response", async () => {
134
168
  globalThis.fetch = (async () =>
135
169
  new Response("<title>Not Found</title>not here", {