@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -1,7 +1,8 @@
1
- import { utimesSync, writeFileSync } from "node:fs";
1
+ import { readFileSync, utimesSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
+ import { waitFor } from "../../../__tests__/helpers/wait-for.js";
5
6
  import {
6
7
  sampleConcepts as sharedSampleConcepts,
7
8
  sampleConfig,
@@ -50,11 +51,14 @@ mock.module("../../../persistence/embeddings/embedding-backend.js", () => ({
50
51
  },
51
52
  }));
52
53
 
54
+ import type { AssistantEventEnvelope } from "../../../api/index.js";
53
55
  import { BACKUP_PROFILE_KEYS } from "../../../config/default-profile-names.js";
54
56
  import {
55
57
  getConfig,
58
+ invalidateConfigCache,
56
59
  loadConfig,
57
60
  loadRawConfig,
61
+ setNestedValue,
58
62
  } from "../../../config/loader.js";
59
63
  import { AssistantConfigSchema } from "../../../config/schema.js";
60
64
  import { LLMConfigBase } from "../../../config/schemas/llm.js";
@@ -82,7 +86,8 @@ import {
82
86
  createConnection,
83
87
  getConnection,
84
88
  } from "../../../providers/inference/connections.js";
85
- import { ROUTES } from "../conversation-query-routes.js";
89
+ import { assistantEventHub } from "../../assistant-event-hub.js";
90
+ import { commitConfigWrite, ROUTES } from "../conversation-query-routes.js";
86
91
 
87
92
  // Local subset: this test only exercises a single concept row.
88
93
  const sampleConcepts: MemoryV2ConceptRowRecord[] = sharedSampleConcepts.slice(
@@ -2047,3 +2052,371 @@ describe("config writes to a per-agent acp entry", () => {
2047
2052
  expect(loadConfig().acp.agents.claude?.model).toBe("sonnet");
2048
2053
  });
2049
2054
  });
2055
+
2056
+ describe("persisted chat settings", () => {
2057
+ const getRoute = ROUTES.find((r) => r.operationId === "config_get")!;
2058
+ const patchRoute = ROUTES.find((r) => r.operationId === "config_patch")!;
2059
+ const setRoute = ROUTES.find((r) => r.operationId === "config_set")!;
2060
+
2061
+ beforeEach(() => {
2062
+ initializeProvidersCalls = 0;
2063
+ clearEmbeddingBackendCacheCalls = 0;
2064
+ rawConfigFixture = {
2065
+ conversations: {
2066
+ skipAutoRetitling: true,
2067
+ autoArchive: { enabled: true, afterDays: 14, futureOption: "keep" },
2068
+ },
2069
+ notifications: { newMessageEnabled: false, futureOption: "keep" },
2070
+ maxStepsPerSession: 75,
2071
+ };
2072
+ seedRawConfig();
2073
+ invalidateConfigCache();
2074
+ });
2075
+
2076
+ const writers = ["PATCH", "SET", "canonical"] as const;
2077
+ async function writeSetting(
2078
+ writer: (typeof writers)[number],
2079
+ path: string,
2080
+ value: unknown,
2081
+ ): Promise<unknown> {
2082
+ if (writer === "SET") {
2083
+ return setRoute.handler({ body: { path, value } });
2084
+ }
2085
+ const raw = writer === "canonical" ? loadRawConfig() : {};
2086
+ setNestedValue(raw, path, value);
2087
+ return writer === "PATCH"
2088
+ ? patchRoute.handler({ body: raw })
2089
+ : commitConfigWrite(raw, "test");
2090
+ }
2091
+
2092
+ for (const writer of writers) {
2093
+ test.each([
2094
+ { conversations: false },
2095
+ { conversations: [] },
2096
+ { conversations: null },
2097
+ { conversations: { autoArchive: "legacy" } },
2098
+ { conversations: { autoArchive: [] } },
2099
+ { conversations: { autoArchive: null } },
2100
+ { conversations: { autoArchive: { enabled: "true", afterDays: 2 } } },
2101
+ { conversations: { autoArchive: { enabled: null, afterDays: null } } },
2102
+ { notifications: "legacy" },
2103
+ { notifications: [] },
2104
+ { notifications: null },
2105
+ { notifications: { newMessageEnabled: "false" } },
2106
+ { notifications: { newMessageEnabled: null } },
2107
+ ])(
2108
+ `${writer} unrelated writes preserve untouched legacy values: %j`,
2109
+ async (legacy) => {
2110
+ rawConfigFixture = { ...legacy, maxStepsPerSession: 75 };
2111
+ seedRawConfig();
2112
+ invalidateConfigCache();
2113
+
2114
+ await writeSetting(writer, "maxStepsPerSession", 100);
2115
+
2116
+ expect(loadRawConfig()).toEqual({
2117
+ ...rawConfigFixture,
2118
+ maxStepsPerSession: 100,
2119
+ });
2120
+ expect(initializeProvidersCalls).toBe(1);
2121
+ expect(clearEmbeddingBackendCacheCalls).toBe(1);
2122
+ },
2123
+ );
2124
+
2125
+ test(`${writer} can repair one leaf without changing other legacy values`, async () => {
2126
+ rawConfigFixture = {
2127
+ conversations: { autoArchive: { enabled: "true", afterDays: 2 } },
2128
+ notifications: null,
2129
+ };
2130
+ seedRawConfig();
2131
+ invalidateConfigCache();
2132
+
2133
+ await writeSetting(writer, "conversations.autoArchive.enabled", true);
2134
+
2135
+ expect(loadRawConfig()).toEqual({
2136
+ conversations: { autoArchive: { enabled: true, afterDays: 2 } },
2137
+ notifications: null,
2138
+ });
2139
+ });
2140
+
2141
+ test(`${writer} rejects an invalid leaf change beside an unchanged invalid sibling`, async () => {
2142
+ rawConfigFixture = {
2143
+ conversations: { autoArchive: { enabled: "true", afterDays: 2 } },
2144
+ };
2145
+ seedRawConfig();
2146
+ invalidateConfigCache();
2147
+ const configPath = join(process.env.VELLUM_WORKSPACE_DIR!, "config.json");
2148
+ const originalBytes = readFileSync(configPath, "utf-8");
2149
+
2150
+ await expect(
2151
+ writeSetting(writer, "conversations.autoArchive.afterDays", 3),
2152
+ ).rejects.toMatchObject({ statusCode: 400 });
2153
+
2154
+ expect(readFileSync(configPath, "utf-8")).toBe(originalBytes);
2155
+ expect(initializeProvidersCalls).toBe(0);
2156
+ });
2157
+
2158
+ test.each([
2159
+ ["conversations", []],
2160
+ ["conversations.autoArchive", { enabled: "true" }],
2161
+ ["conversations.autoArchive", { afterDays: 2 }],
2162
+ ["notifications.newMessageEnabled", "true"],
2163
+ ["notifications.newMessageEnabled", 1],
2164
+ ])(
2165
+ `${writer} rejects changed invalid settings at %s over legacy config`,
2166
+ async (path, value) => {
2167
+ rawConfigFixture = {
2168
+ conversations: false,
2169
+ notifications: { newMessageEnabled: "false" },
2170
+ maxStepsPerSession: 75,
2171
+ };
2172
+ seedRawConfig();
2173
+ invalidateConfigCache();
2174
+ const configPath = join(
2175
+ process.env.VELLUM_WORKSPACE_DIR!,
2176
+ "config.json",
2177
+ );
2178
+ const originalBytes = readFileSync(configPath, "utf-8");
2179
+
2180
+ await expect(
2181
+ writeSetting(writer, path as string, value),
2182
+ ).rejects.toMatchObject({
2183
+ statusCode: 400,
2184
+ });
2185
+
2186
+ expect(readFileSync(configPath, "utf-8")).toBe(originalBytes);
2187
+ expect(initializeProvidersCalls).toBe(0);
2188
+ expect(clearEmbeddingBackendCacheCalls).toBe(0);
2189
+ },
2190
+ );
2191
+ }
2192
+
2193
+ test("PATCH resets only the explicitly nulled legacy leaf", async () => {
2194
+ rawConfigFixture = {
2195
+ conversations: { autoArchive: { enabled: null, afterDays: null } },
2196
+ notifications: { newMessageEnabled: null },
2197
+ };
2198
+ seedRawConfig();
2199
+ invalidateConfigCache();
2200
+
2201
+ await patchRoute.handler({
2202
+ body: { conversations: { autoArchive: { enabled: null } } },
2203
+ });
2204
+
2205
+ expect(loadRawConfig()).toEqual({
2206
+ conversations: { autoArchive: { afterDays: null } },
2207
+ notifications: { newMessageEnabled: null },
2208
+ });
2209
+ });
2210
+
2211
+ test("sparse GET exposes effective defaults without materializing unrelated fields", async () => {
2212
+ rawConfigFixture = {};
2213
+ seedRawConfig();
2214
+ invalidateConfigCache();
2215
+ const result = await getRoute.handler({});
2216
+ expect(result).toMatchObject({
2217
+ conversations: { autoArchive: { enabled: false, afterDays: 7 } },
2218
+ notifications: { newMessageEnabled: true },
2219
+ });
2220
+ expect(result).not.toHaveProperty("conversations.skipAutoRetitling");
2221
+ expect(loadRawConfig()).not.toHaveProperty("conversations");
2222
+ expect(loadRawConfig()).not.toHaveProperty("notifications");
2223
+ });
2224
+
2225
+ test("partial PATCH persists, reloads, and publishes config invalidation", async () => {
2226
+ const received: AssistantEventEnvelope[] = [];
2227
+ const subscription = assistantEventHub.subscribe({
2228
+ type: "process",
2229
+ callback: (event) => {
2230
+ received.push(event);
2231
+ },
2232
+ });
2233
+ try {
2234
+ const result = await patchRoute.handler({
2235
+ body: { conversations: { autoArchive: { afterDays: 30 } } },
2236
+ });
2237
+ expect(result).toMatchObject({
2238
+ conversations: {
2239
+ skipAutoRetitling: true,
2240
+ autoArchive: { enabled: true, afterDays: 30, futureOption: "keep" },
2241
+ },
2242
+ notifications: { newMessageEnabled: false, futureOption: "keep" },
2243
+ maxStepsPerSession: 75,
2244
+ });
2245
+ expect(loadRawConfig()).toEqual({
2246
+ ...rawConfigFixture,
2247
+ conversations: {
2248
+ skipAutoRetitling: true,
2249
+ autoArchive: { enabled: true, afterDays: 30, futureOption: "keep" },
2250
+ },
2251
+ });
2252
+ invalidateConfigCache();
2253
+ expect(loadConfig().conversations.autoArchive).toEqual({
2254
+ enabled: true,
2255
+ afterDays: 30,
2256
+ });
2257
+ expect(loadConfig().notifications.newMessageEnabled).toBe(false);
2258
+ expect(await getRoute.handler({})).toEqual(result);
2259
+ expect(initializeProvidersCalls).toBe(1);
2260
+ expect(clearEmbeddingBackendCacheCalls).toBe(1);
2261
+ await waitFor(() =>
2262
+ received.some((event) => event.message.type === "sync_changed"),
2263
+ );
2264
+ expect(received.map((event) => event.message)).toContainEqual({
2265
+ type: "sync_changed",
2266
+ tags: ["assistant:self:config"],
2267
+ });
2268
+ } finally {
2269
+ subscription.dispose();
2270
+ }
2271
+ });
2272
+
2273
+ test("PATCH null leaves restore defaults and preserve sibling values", async () => {
2274
+ const result = await patchRoute.handler({
2275
+ body: {
2276
+ conversations: { autoArchive: { enabled: null, afterDays: null } },
2277
+ notifications: { newMessageEnabled: null },
2278
+ },
2279
+ });
2280
+ expect(result).toMatchObject({
2281
+ conversations: {
2282
+ skipAutoRetitling: true,
2283
+ autoArchive: { enabled: false, afterDays: 7, futureOption: "keep" },
2284
+ },
2285
+ notifications: { newMessageEnabled: true, futureOption: "keep" },
2286
+ });
2287
+ expect(loadRawConfig()).toMatchObject({
2288
+ conversations: { autoArchive: { futureOption: "keep" } },
2289
+ notifications: { futureOption: "keep" },
2290
+ });
2291
+ expect(loadRawConfig()).not.toHaveProperty(
2292
+ "conversations.autoArchive.enabled",
2293
+ );
2294
+ expect(loadRawConfig()).not.toHaveProperty(
2295
+ "notifications.newMessageEnabled",
2296
+ );
2297
+ });
2298
+
2299
+ test.each([
2300
+ { conversations: { autoArchive: null }, notifications: null },
2301
+ { conversations: null, notifications: null },
2302
+ ])("PATCH null subtrees restore defaults: %j", async (body) => {
2303
+ const result = await patchRoute.handler({ body });
2304
+ expect(result).toMatchObject({
2305
+ conversations: { autoArchive: { enabled: false, afterDays: 7 } },
2306
+ notifications: { newMessageEnabled: true },
2307
+ });
2308
+ expect(loadRawConfig()).not.toHaveProperty("conversations.autoArchive");
2309
+ expect(loadRawConfig()).not.toHaveProperty("notifications");
2310
+ });
2311
+
2312
+ test.each([
2313
+ { conversations: false },
2314
+ { conversations: { autoArchive: [] } },
2315
+ { conversations: { autoArchive: { enabled: "true" } } },
2316
+ { conversations: { autoArchive: { afterDays: 5 } } },
2317
+ { notifications: "off" },
2318
+ { notifications: { newMessageEnabled: 0 } },
2319
+ ])(
2320
+ "invalid PATCH leaves saved config and runtime unchanged: %j",
2321
+ async (body) => {
2322
+ const original = loadRawConfig();
2323
+ await expect(patchRoute.handler({ body })).rejects.toMatchObject({
2324
+ statusCode: 400,
2325
+ });
2326
+ expect(loadRawConfig()).toEqual(original);
2327
+ expect(initializeProvidersCalls).toBe(0);
2328
+ expect(clearEmbeddingBackendCacheCalls).toBe(0);
2329
+ },
2330
+ );
2331
+
2332
+ test.each([false, "legacy", [], null])(
2333
+ "PATCH null resets a malformed stored autoArchive subtree: %j",
2334
+ async (autoArchive) => {
2335
+ rawConfigFixture = {
2336
+ conversations: { skipAutoRetitling: true, autoArchive },
2337
+ notifications: { newMessageEnabled: false },
2338
+ maxStepsPerSession: 75,
2339
+ };
2340
+ seedRawConfig();
2341
+ invalidateConfigCache();
2342
+
2343
+ const result = await patchRoute.handler({
2344
+ body: { conversations: { autoArchive: null } },
2345
+ });
2346
+
2347
+ expect(result).toMatchObject({
2348
+ conversations: {
2349
+ skipAutoRetitling: true,
2350
+ autoArchive: { enabled: false, afterDays: 7 },
2351
+ },
2352
+ notifications: { newMessageEnabled: false },
2353
+ });
2354
+ expect(loadRawConfig()).toEqual({
2355
+ conversations: { skipAutoRetitling: true },
2356
+ notifications: { newMessageEnabled: false },
2357
+ maxStepsPerSession: 75,
2358
+ });
2359
+ },
2360
+ );
2361
+
2362
+ test.each([false, "legacy", [], null])(
2363
+ "PATCH null resets malformed stored settings sections: %j",
2364
+ async (value) => {
2365
+ rawConfigFixture = {
2366
+ conversations: value,
2367
+ notifications: value,
2368
+ maxStepsPerSession: 75,
2369
+ };
2370
+ seedRawConfig();
2371
+ invalidateConfigCache();
2372
+
2373
+ const result = await patchRoute.handler({
2374
+ body: { conversations: null, notifications: null },
2375
+ });
2376
+
2377
+ expect(result).toMatchObject({
2378
+ conversations: { autoArchive: { enabled: false, afterDays: 7 } },
2379
+ notifications: { newMessageEnabled: true },
2380
+ });
2381
+ expect(loadRawConfig()).toEqual({ maxStepsPerSession: 75 });
2382
+ },
2383
+ );
2384
+
2385
+ test("SET persists valid preferences through the same writer", async () => {
2386
+ await setRoute.handler({
2387
+ body: { path: "notifications.newMessageEnabled", value: true },
2388
+ });
2389
+ expect(loadConfig().notifications.newMessageEnabled).toBe(true);
2390
+ expect(loadRawConfig()).toHaveProperty(
2391
+ "notifications.futureOption",
2392
+ "keep",
2393
+ );
2394
+ });
2395
+
2396
+ test.each([
2397
+ ["conversations.autoArchive.afterDays", 2],
2398
+ ["conversations.autoArchive.enabled", null],
2399
+ ["conversations.autoArchive", []],
2400
+ ["notifications.newMessageEnabled", "false"],
2401
+ ["notifications", null],
2402
+ ])("invalid SET %s is rejected before saving", async (path, value) => {
2403
+ const original = loadRawConfig();
2404
+ await expect(
2405
+ setRoute.handler({ body: { path, value } }),
2406
+ ).rejects.toMatchObject({ statusCode: 400 });
2407
+ expect(loadRawConfig()).toEqual(original);
2408
+ expect(initializeProvidersCalls).toBe(0);
2409
+ });
2410
+
2411
+ test("canonical config writes reject invalid settings", async () => {
2412
+ const original = loadRawConfig();
2413
+ await expect(
2414
+ commitConfigWrite(
2415
+ { ...original, notifications: { newMessageEnabled: null } },
2416
+ "test",
2417
+ ),
2418
+ ).rejects.toMatchObject({ statusCode: 400 });
2419
+ expect(loadRawConfig()).toEqual(original);
2420
+ expect(initializeProvidersCalls).toBe(0);
2421
+ });
2422
+ });
@@ -252,7 +252,7 @@ describe("inference_send profile usability", () => {
252
252
  const err = await rejection({ ...hermes, status: "disabled" });
253
253
  expect(err).toBeInstanceOf(BadRequestError);
254
254
  expect(err.message).toContain("disabled");
255
- expect(err.message).toContain("deepseek-v4-flash");
255
+ expect(err.message).toContain("deepseek-v4p1-flash");
256
256
  });
257
257
 
258
258
  test("rejects a profile that lost its provider", async () => {
@@ -106,6 +106,18 @@ describe("path traversal", () => {
106
106
  );
107
107
  expect(res.status).toBe(400);
108
108
  });
109
+
110
+ test("rejects backslashes in the route path", async () => {
111
+ // Some URL normalizers treat a backslash as a separator, so a
112
+ // backslash-carrying routePath is refused outright regardless of dots.
113
+ const dispatcher = makeDispatcher();
114
+ for (const routePath of ["..\\config", "foo\\bar", "x\\..\\config"]) {
115
+ const res = await dispatcher.dispatch(routePath, makeRequest("GET"));
116
+ expect(res.status).toBe(400);
117
+ const body = await readErrorBody(res);
118
+ expect(body.error.message).toContain("Path traversal");
119
+ }
120
+ });
109
121
  });
110
122
 
111
123
  // ---------------------------------------------------------------------------
@@ -393,8 +393,8 @@ export async function handleListContacts(queryParams: Record<string, string>) {
393
393
  export async function handleGetContact(contactId: string) {
394
394
  try {
395
395
  const result = await ipcCallPersistent("contacts_get_rich", { contactId });
396
- // The gateway returns null (no `contact`) on a clean not-found.
397
- if (!result || (result as { contact?: unknown }).contact === undefined) {
396
+ // The gateway returns null on a clean not-found.
397
+ if (result == null) {
398
398
  throw new NotFoundError(`Contact "${contactId}" not found`);
399
399
  }
400
400
  const { contact, assistantMetadata } =
@@ -150,6 +150,13 @@ const listConversationsResponseSchema = z.object({
150
150
  nextOffset: z.number(),
151
151
  hasMore: z.boolean(),
152
152
  groups: z.array(groupSummarySchema).optional(),
153
+ /**
154
+ * The key the rows are ordered on: last activity, the later of a row's
155
+ * last message and the time it was marked done. Stated so a client that
156
+ * pages a list including done rows can tell this order from message
157
+ * recency, which an assistant that omits the field pages by.
158
+ */
159
+ orderedBy: z.literal("lastActivity"),
153
160
  });
154
161
 
155
162
  const conversationDetailResponseSchema = z.object({
@@ -376,6 +383,7 @@ function handleListConversations({
376
383
  ),
377
384
  nextOffset,
378
385
  hasMore: nextOffset < totalCount,
386
+ orderedBy: "lastActivity",
379
387
  };
380
388
 
381
389
  // Include groups array on first page only
@@ -632,7 +640,7 @@ export const ROUTES: RouteDefinition[] = [
632
640
  type: "string",
633
641
  required: false,
634
642
  description:
635
- 'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at) descending) like every other list read, and pinned rows are never appended to it.',
643
+ 'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are ordered by last activity (the later of the last message and the time the row was marked done, descending) like every other list read, and pinned rows are never appended to it.',
636
644
  schema: {
637
645
  type: "string",
638
646
  enum: ["background", "scheduled", "all"],
@@ -662,7 +670,7 @@ export const ROUTES: RouteDefinition[] = [
662
670
  type: "string",
663
671
  required: false,
664
672
  description:
665
- 'Filter to a single group, so each sidebar section can load independently of the paginated list. Pass "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A group-scoped request is recency-ordered like every list read (COALESCE(last_message_at, updated_at) descending) and never has pinned rows appended to it. Omit to span every group.',
673
+ 'Filter to a single group, so each sidebar section can load independently of the paginated list. Pass "system:all" for conversations in no group, "system:pinned" for the Pinned section, or a custom group id. A group-scoped request is ordered by last activity like every list read (the later of the last message and the time the row was marked done, descending) and never has pinned rows appended to it. Omit to span every group.',
666
674
  },
667
675
  {
668
676
  name: "needsAttention",
@@ -30,6 +30,11 @@ import {
30
30
  LLMRequestLogEntrySchema,
31
31
  } from "../../api/responses/llm-request-log-entry.js";
32
32
  import { scrubNulledAcpAgentLeaves } from "../../config/acp-agent-write.js";
33
+ import {
34
+ ChatSettingsPatchSchema,
35
+ scrubNulledChatSettings,
36
+ validateChatSettingsWrite,
37
+ } from "../../config/chat-settings.js";
33
38
  import {
34
39
  catalogEntryFor,
35
40
  type InputModalities,
@@ -54,6 +59,7 @@ import {
54
59
  } from "../../config/profile-materialization.js";
55
60
  import { AssistantConfigSchema } from "../../config/schema.js";
56
61
  import { getSchemaAtPath } from "../../config/schema-utils.js";
62
+ import { AutoArchiveConfigSchema } from "../../config/schemas/conversations.js";
57
63
  import {
58
64
  collectFallbackProfileIssues,
59
65
  DefaultProviderSchema,
@@ -64,6 +70,7 @@ import {
64
70
  unknownLlmProviderIssue,
65
71
  } from "../../config/schemas/llm.js";
66
72
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
73
+ import { NotificationsConfigSchema } from "../../config/schemas/notifications.js";
67
74
  import { ServiceModeSchema } from "../../config/schemas/services.js";
68
75
  import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
69
76
  import {
@@ -746,14 +753,19 @@ const MemoryWireConfigSchema = z
746
753
  * Response schema for `GET /v1/config`.
747
754
  *
748
755
  * Describes the wire shape of the raw `settings.json` response after
749
- * context-default filling and vision-flag enrichment. All top-level fields
750
- * are optional because the on-disk config may be sparse. Additional
751
- * top-level config sections beyond what's typed here are preserved via
752
- * passthrough — this schema types the fields that web/macOS clients consume
756
+ * context-default filling and vision-flag enrichment. Chat settings include
757
+ * effective defaults. Fields remain optional for older assistants and sparse
758
+ * on-disk config. Additional top-level sections are preserved via
759
+ * passthrough. This schema types the fields that web/macOS clients consume
753
760
  * without restricting the full config surface.
754
761
  */
755
762
  const ConfigGetResponseSchema = z
756
763
  .object({
764
+ conversations: z
765
+ .object({ autoArchive: AutoArchiveConfigSchema.passthrough() })
766
+ .passthrough()
767
+ .optional(),
768
+ notifications: NotificationsConfigSchema.passthrough().optional(),
757
769
  llm: z
758
770
  .object({
759
771
  default: LLMConfigFragment.extend({
@@ -859,6 +871,7 @@ const CallSiteOverrideDraftSchema = nullablePartial(
859
871
  */
860
872
  const ConfigPatchRequestSchema = z
861
873
  .object({
874
+ ...ChatSettingsPatchSchema.shape,
862
875
  llm: z
863
876
  .object({
864
877
  default: nullablePartial(
@@ -932,6 +945,7 @@ function handleGetConfig() {
932
945
  sanitizeMcpTransportHeadersForSettingsRead(config);
933
946
  overlayEffectiveProfilesForWire(config);
934
947
  enrichProfilesForWire(config);
948
+ overlayChatSettingsForWire(config);
935
949
  return config;
936
950
  } catch (err) {
937
951
  const message = err instanceof Error ? err.message : String(err);
@@ -939,6 +953,26 @@ function handleGetConfig() {
939
953
  }
940
954
  }
941
955
 
956
+ function overlayChatSettingsForWire(config: unknown): void {
957
+ const root = readPlainObject(config);
958
+ if (!root) {
959
+ return;
960
+ }
961
+ const effective = getConfig();
962
+ const conversations = readPlainObject(root.conversations);
963
+ root.conversations = {
964
+ ...conversations,
965
+ autoArchive: {
966
+ ...readPlainObject(conversations?.autoArchive),
967
+ ...effective.conversations.autoArchive,
968
+ },
969
+ };
970
+ root.notifications = {
971
+ ...readPlainObject(root.notifications),
972
+ newMessageEnabled: effective.notifications.newMessageEnabled,
973
+ };
974
+ }
975
+
942
976
  /**
943
977
  * Replace `llm.profiles` in an outgoing config response with the effective
944
978
  * profile view (code-catalog default bodies + workspace overlays). Default
@@ -1513,6 +1547,10 @@ export async function commitConfigWrite(
1513
1547
  // false diffs. Runs before the watcher-suppress/save sequence so a
1514
1548
  // rejection needs no suppress-flag or cache cleanup.
1515
1549
  const preWrite = loadRawConfig();
1550
+ const chatSettings = validateChatSettingsWrite(preWrite, raw);
1551
+ if (!chatSettings.success) {
1552
+ throw new BadRequestError(chatSettings.error.message);
1553
+ }
1516
1554
  completeChangedCustomProfiles(preWrite, raw);
1517
1555
  assertInvariantProfilesPreserved(preWrite, raw);
1518
1556
  assertRoutableIdentityEntries(preWrite, raw);
@@ -1626,6 +1664,10 @@ async function handlePatchConfig({ body }: RouteHandlerArgs) {
1626
1664
  ) {
1627
1665
  throw new BadRequestError("Body must be a non-empty JSON object");
1628
1666
  }
1667
+ const chatSettings = ChatSettingsPatchSchema.safeParse(body);
1668
+ if (!chatSettings.success) {
1669
+ throw new BadRequestError(chatSettings.error.message);
1670
+ }
1629
1671
  stripWireOnlyProfileKeys(body);
1630
1672
  normalizeManagedProfileWrites(body);
1631
1673
  rejectManagedProfileDeletion(body as Record<string, unknown>);
@@ -1641,16 +1683,12 @@ async function handlePatchConfig({ body }: RouteHandlerArgs) {
1641
1683
  deepMergeOverwrite(raw, patch);
1642
1684
  scrubRemovedServiceModes(raw);
1643
1685
  scrubNulledAcpAgentLeaves(raw);
1686
+ scrubNulledChatSettings(raw, patch);
1644
1687
  seedSttProviderForSparseBlock(raw);
1645
1688
 
1646
1689
  await commitConfigWrite(raw, "patch");
1647
1690
 
1648
- const merged = applyContextDefaultsToRawConfig(loadRawConfig());
1649
- overlayWorkspaceMcpForConfigRead(merged);
1650
- sanitizeMcpTransportHeadersForSettingsRead(merged);
1651
- overlayEffectiveProfilesForWire(merged);
1652
- enrichProfilesForWire(merged);
1653
- return merged;
1691
+ return handleGetConfig();
1654
1692
  }
1655
1693
 
1656
1694
  /**
@@ -101,6 +101,7 @@ import {
101
101
  writeOnboardingSidecar,
102
102
  writeRelationshipState,
103
103
  } from "../../home/relationship-state-writer.js";
104
+ import { localeFromAcceptLanguage } from "../../i18n/index.js";
104
105
  import { ipcCall } from "../../ipc/gateway-client.js";
105
106
  import { buildSlackMessageDeepLinks } from "../../messaging/providers/slack/deep-link.js";
106
107
  import {
@@ -915,7 +916,9 @@ function parseRequestedModeSessionIds(raw: unknown): string[] {
915
916
 
916
917
  export async function handleListMessages({
917
918
  queryParams,
919
+ headers,
918
920
  }: RouteHandlerArgs): Promise<Record<string, unknown>> {
921
+ const locale = localeFromAcceptLanguage(headers?.["accept-language"]);
919
922
  const conversationId = queryParams?.conversationId;
920
923
  const conversationKey = queryParams?.conversationKey;
921
924
 
@@ -1338,6 +1341,7 @@ export async function handleListMessages({
1338
1341
  attachmentBlocks,
1339
1342
  m.id ?? undefined,
1340
1343
  m.rowMetadata,
1344
+ m.role === "assistant" ? locale : undefined,
1341
1345
  );
1342
1346
 
1343
1347
  const toolCalls = enrichToolCallsWithQuestion(
@@ -1735,6 +1739,7 @@ export async function handleSendMessage(
1735
1739
  const principalType = headers?.["x-vellum-principal-type"];
1736
1740
  const originClientId = headers?.["x-vellum-client-id"]?.trim() || undefined;
1737
1741
  const clientMetadata = readClientMetadataHeaders(headers);
1742
+ const locale = localeFromAcceptLanguage(headers?.["accept-language"]);
1738
1743
 
1739
1744
  const { conversationKey, content, attachmentIds } = body;
1740
1745
  const inboundConversationId =
@@ -2475,6 +2480,7 @@ export async function handleSendMessage(
2475
2480
  clientMetadata,
2476
2481
  ),
2477
2482
  isInteractive,
2483
+ locale,
2478
2484
  sourceActorPrincipalId,
2479
2485
  transport,
2480
2486
  clientMessageId,
@@ -3159,6 +3165,7 @@ export async function handleSendMessage(
3159
3165
  onEvent: broadcastMessage,
3160
3166
  isInteractive,
3161
3167
  isUserMessage: true,
3168
+ locale,
3162
3169
  turnTrustContext,
3163
3170
  ...(body.hidden === true ? { isHiddenPrompt: true } : {}),
3164
3171
  })