@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -773,6 +773,144 @@ describe("GeminiProvider", () => {
773
773
  });
774
774
  });
775
775
 
776
+ test("splits functionResponse from following text in a user Content", async () => {
777
+ /** Gemini serializes function responses separately from following text. */
778
+
779
+ // GIVEN streamed output and a history containing a tool result followed by text
780
+ fakeChunks = [textChunk("Done"), finishChunk("STOP", 20, 10)];
781
+
782
+ const messages: Message[] = [
783
+ { role: "user", content: [{ type: "text", text: "Read /tmp/test" }] },
784
+ {
785
+ role: "assistant",
786
+ content: [
787
+ {
788
+ type: "tool_use",
789
+ id: "call_split",
790
+ name: "file_read",
791
+ input: { path: "/tmp/test" },
792
+ },
793
+ ],
794
+ },
795
+ {
796
+ role: "user",
797
+ content: [
798
+ {
799
+ type: "tool_result",
800
+ tool_use_id: "call_split",
801
+ content: "file content here",
802
+ },
803
+ { type: "text", text: "Summarize that." },
804
+ ],
805
+ },
806
+ ];
807
+
808
+ // WHEN the provider sends the message
809
+ await provider.sendMessage(messages);
810
+
811
+ // THEN the function response is isolated in its own user Content
812
+ const contents = lastStreamParams!.contents as Array<{
813
+ role: string;
814
+ parts: Array<Record<string, unknown>>;
815
+ }>;
816
+ expect(contents).toHaveLength(4);
817
+
818
+ // AND the function response Content appears before the text Content
819
+ expect(contents[2]).toMatchObject({
820
+ role: "user",
821
+ parts: [
822
+ {
823
+ functionResponse: {
824
+ name: "file_read",
825
+ response: { output: "file content here" },
826
+ },
827
+ },
828
+ ],
829
+ });
830
+
831
+ // AND the text remains in a separate user Content
832
+ expect(contents[3]).toEqual({
833
+ role: "user",
834
+ parts: [{ text: "Summarize that." }],
835
+ });
836
+ });
837
+
838
+ test("orders tool result media before following text", async () => {
839
+ /** Gemini receives tool result attachments before text that refers to them. */
840
+
841
+ // GIVEN streamed output and a history with an image tool result followed by text
842
+ fakeChunks = [textChunk("Done"), finishChunk("STOP", 20, 10)];
843
+
844
+ const messages: Message[] = [
845
+ { role: "user", content: [{ type: "text", text: "Read /tmp/test" }] },
846
+ {
847
+ role: "assistant",
848
+ content: [
849
+ {
850
+ type: "tool_use",
851
+ id: "call_image",
852
+ name: "file_read",
853
+ input: { path: "/tmp/test" },
854
+ },
855
+ ],
856
+ },
857
+ {
858
+ role: "user",
859
+ content: [
860
+ {
861
+ type: "tool_result",
862
+ tool_use_id: "call_image",
863
+ content: "image result",
864
+ contentBlocks: [
865
+ {
866
+ type: "image",
867
+ source: {
868
+ type: "base64",
869
+ media_type: "image/png",
870
+ data: "iVBORw0KGgo=",
871
+ },
872
+ },
873
+ ],
874
+ },
875
+ { type: "text", text: "Describe that image." },
876
+ ],
877
+ },
878
+ ];
879
+
880
+ // WHEN the provider sends the message
881
+ await provider.sendMessage(messages);
882
+
883
+ const contents = lastStreamParams!.contents as Array<{
884
+ role: string;
885
+ parts: Array<Record<string, unknown>>;
886
+ }>;
887
+
888
+ // THEN the tool result Contents are ordered by function response, media, and text
889
+ expect(contents.slice(2)).toEqual([
890
+ {
891
+ role: "user",
892
+ parts: [
893
+ {
894
+ functionResponse: {
895
+ name: "file_read",
896
+ response: { output: "image result" },
897
+ },
898
+ },
899
+ ],
900
+ },
901
+ {
902
+ role: "user",
903
+ parts: [
904
+ { inlineData: { mimeType: "image/png", data: "iVBORw0KGgo=" } },
905
+ ],
906
+ },
907
+ {
908
+ role: "user",
909
+ parts: [{ text: "Describe that image." }],
910
+ },
911
+ ]);
912
+ });
913
+
776
914
  function toolResultWithAudio(mediaType: string, data: string): Message[] {
777
915
  return [
778
916
  { role: "user", content: [{ type: "text", text: "Read clip" }] },
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { deepRepairHistory } from "../agent/history-repair/history-repair.js";
4
- import { isRepairableOrderingError } from "../agent/history-repair/history-repair.js";
5
- import { repairHistory } from "../agent/history-repair/history-repair.js";
3
+ import {
4
+ deepRepairHistory,
5
+ isRepairableOrderingError,
6
+ isUserTerminalHistoryError,
7
+ repairHistory,
8
+ } from "../agent/history-repair/history-repair.js";
6
9
  import type { Message } from "../providers/types.js";
7
10
 
8
11
  describe("repairHistory", () => {
@@ -1172,6 +1175,88 @@ describe("deepRepairHistory", () => {
1172
1175
  expect(repaired[1].content[0]).toEqual({ type: "text", text: "Hi" });
1173
1176
  });
1174
1177
 
1178
+ test("removes blank text messages so blank assistant tails become user-terminal", () => {
1179
+ /** Blank-text assistant turns carry nothing a provider can serialize. */
1180
+
1181
+ // GIVEN a history whose assistant turns hold only whitespace text
1182
+ const messages: Message[] = [
1183
+ { role: "user", content: [{ type: "text", text: "Hello" }] },
1184
+ { role: "assistant", content: [{ type: "text", text: " \n" }] },
1185
+ { role: "user", content: [{ type: "text", text: "Continue" }] },
1186
+ { role: "assistant", content: [{ type: "text", text: "" }] },
1187
+ ];
1188
+
1189
+ // WHEN it is deep-repaired
1190
+ const { messages: repaired } = deepRepairHistory(messages);
1191
+
1192
+ // THEN only the merged user turn survives
1193
+ expect(repaired).toHaveLength(1);
1194
+ expect(repaired[0].role).toBe("user");
1195
+ expect(repaired[0].content).toEqual([
1196
+ { type: "text", text: "Hello" },
1197
+ { type: "text", text: "Continue" },
1198
+ ]);
1199
+ });
1200
+
1201
+ test("preserves a non-empty assistant tail by default", () => {
1202
+ /** Providers that accept an assistant-terminal history keep the reply. */
1203
+
1204
+ // GIVEN a history ending with a real assistant reply
1205
+ const messages: Message[] = [
1206
+ { role: "user", content: [{ type: "text", text: "Hello" }] },
1207
+ { role: "assistant", content: [{ type: "text", text: "Hi" }] },
1208
+ ];
1209
+
1210
+ // WHEN it is deep-repaired without a user-terminal requirement
1211
+ const { messages: repaired } = deepRepairHistory(messages);
1212
+
1213
+ // THEN the reply is kept
1214
+ expect(repaired).toEqual(messages);
1215
+ });
1216
+
1217
+ test("drops a non-empty assistant tail when user-terminal history is required", () => {
1218
+ /** Providers rejecting a model-terminal request get a user-terminal history. */
1219
+
1220
+ // GIVEN a history ending with a real assistant reply
1221
+ const messages: Message[] = [
1222
+ { role: "user", content: [{ type: "text", text: "Hello" }] },
1223
+ { role: "assistant", content: [{ type: "text", text: "Hi" }] },
1224
+ ];
1225
+
1226
+ // WHEN it is deep-repaired for a provider requiring a user-terminal history
1227
+ const { messages: repaired } = deepRepairHistory(messages, {
1228
+ requireUserTerminal: true,
1229
+ });
1230
+
1231
+ // THEN the trailing reply is dropped
1232
+ expect(repaired).toEqual([messages[0]]);
1233
+ });
1234
+
1235
+ test("drops multiple trailing assistant messages and allows an assistant-only history to empty", () => {
1236
+ /** The whole assistant tail is dropped, even when nothing is left. */
1237
+
1238
+ // GIVEN a history ending with several assistant turns
1239
+ const messages: Message[] = [
1240
+ { role: "user", content: [{ type: "text", text: "Hello" }] },
1241
+ { role: "assistant", content: [{ type: "text", text: "One" }] },
1242
+ { role: "assistant", content: [{ type: "text", text: "Two" }] },
1243
+ ];
1244
+
1245
+ // WHEN it is deep-repaired for a provider requiring a user-terminal history
1246
+ const { messages: repaired } = deepRepairHistory(messages, {
1247
+ requireUserTerminal: true,
1248
+ });
1249
+
1250
+ // THEN every trailing assistant turn is gone
1251
+ expect(repaired).toEqual([messages[0]]);
1252
+
1253
+ // AND an assistant-only history repairs to an empty history
1254
+ expect(
1255
+ deepRepairHistory(messages.slice(1), { requireUserTerminal: true })
1256
+ .messages,
1257
+ ).toEqual([]);
1258
+ });
1259
+
1175
1260
  test("applies standard repair after deep pass", () => {
1176
1261
  // Consecutive assistant messages with tool_use but missing tool_result
1177
1262
  const messages: Message[] = [
@@ -1242,6 +1327,23 @@ describe("isRepairableOrderingError", () => {
1242
1327
  ).toBe(true);
1243
1328
  });
1244
1329
 
1330
+ test("matches user-terminal history rejections", () => {
1331
+ /** A model-terminal rejection is recognized as repairable. */
1332
+
1333
+ // GIVEN Gemini's rejection of a request ending with a model turn
1334
+ const message = "Requests ending with a model turn are not supported.";
1335
+
1336
+ // WHEN the message is classified
1337
+ // THEN it is repairable and identified as a user-terminal requirement
1338
+ expect(isRepairableOrderingError(message)).toBe(true);
1339
+ expect(isUserTerminalHistoryError(message)).toBe(true);
1340
+
1341
+ // AND an unrelated provider error is not
1342
+ expect(
1343
+ isUserTerminalHistoryError("The provider returned an unrelated error"),
1344
+ ).toBe(false);
1345
+ });
1346
+
1245
1347
  test("does not match unrelated provider errors", () => {
1246
1348
  expect(isRepairableOrderingError("Rate limit exceeded")).toBe(false);
1247
1349
  expect(
@@ -165,6 +165,7 @@ describe("identity routes — health endpoint", () => {
165
165
  expect(body.capabilities).toEqual({
166
166
  memoryOptOut: true,
167
167
  retryLastTurn: true,
168
+ appPins: true,
168
169
  });
169
170
 
170
171
  // Profiler should either be absent or show enabled: false
@@ -244,6 +244,51 @@ describe("LLM catalog parity: daemon vs client", () => {
244
244
  }
245
245
  });
246
246
 
247
+ test("OpenRouter supportsCaching requires cache-read pricing", () => {
248
+ const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
249
+ expect(openrouter).toBeDefined();
250
+
251
+ for (const model of openrouter!.models) {
252
+ if (model.supportsCaching) {
253
+ expect(
254
+ model.pricing?.cacheReadPer1mTokens,
255
+ `openrouter/${model.id} supportsCaching requires cacheReadPer1mTokens`,
256
+ ).toBeGreaterThan(0);
257
+ }
258
+ }
259
+ });
260
+
261
+ test("OpenRouter cache-read pricing implies supportsCaching except xAI", () => {
262
+ const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
263
+ expect(openrouter).toBeDefined();
264
+
265
+ for (const model of openrouter!.models) {
266
+ if (model.pricing?.cacheReadPer1mTokens === undefined) {
267
+ continue;
268
+ }
269
+ if (model.id.startsWith("x-ai/")) {
270
+ expect(
271
+ model.supportsCaching,
272
+ `openrouter/${model.id} keeps supportsCaching false: OpenRouter xAI routes do not report cached tokens`,
273
+ ).toBe(false);
274
+ continue;
275
+ }
276
+ expect(
277
+ model.supportsCaching,
278
+ `openrouter/${model.id} publishes cacheReadPer1mTokens so supportsCaching must be true`,
279
+ ).toBe(true);
280
+ }
281
+ });
282
+
283
+ test("OpenRouter catalog drops ids OpenRouter no longer serves", () => {
284
+ const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
285
+ expect(openrouter).toBeDefined();
286
+ const ids = new Set(openrouter!.models.map((model) => model.id));
287
+ expect(ids.has("deepseek/deepseek-v3.2-speciale")).toBe(false);
288
+ expect(ids.has("mistralai/devstral-2512")).toBe(false);
289
+ expect(ids.has("openrouter/owl-alpha")).toBe(false);
290
+ });
291
+
247
292
  test("every model default context is capped by its context window", () => {
248
293
  const json = loadClientCatalog();
249
294
 
@@ -0,0 +1,349 @@
1
+ /**
2
+ * HTTP tests for JSON `{ path }` on POST /v1/migrations/import and
3
+ * POST /v1/migrations/import-preflight.
4
+ *
5
+ * The daemon only opens a regular `.vbundle` that realpath-resolves
6
+ * under `${workspace}/.restore-staging/`.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ realpathSync,
14
+ rmSync,
15
+ symlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import {
21
+ afterAll,
22
+ afterEach,
23
+ beforeEach,
24
+ describe,
25
+ expect,
26
+ mock,
27
+ test,
28
+ } from "bun:test";
29
+
30
+ mock.module("../permissions/trust-store.js", () => ({
31
+ getAllRules: () => [],
32
+ isStarterBundleAccepted: () => false,
33
+ clearCache: () => {},
34
+ }));
35
+
36
+ mock.module("../config/env.js", () => ({
37
+ isHttpAuthDisabled: () => true,
38
+ hasUngatedHttpAuthDisabled: () => false,
39
+ getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
40
+ getGatewayPort: () => 7830,
41
+ getRuntimeHttpPort: () => 7821,
42
+ getRuntimeHttpHost: () => "127.0.0.1",
43
+ getRuntimeGatewayOriginSecret: () => undefined,
44
+ getIngressPublicBaseUrl: () => undefined,
45
+ setIngressPublicBaseUrl: () => {},
46
+ }));
47
+
48
+ import { defaultV1Options } from "../runtime/migrations/__tests__/v1-test-helpers.js";
49
+ import { RESTORE_STAGING_DIRNAME } from "../runtime/migrations/staged-import-path.js";
50
+ import { buildVBundle } from "../runtime/migrations/vbundle-builder.js";
51
+ import {
52
+ handleMigrationImport,
53
+ handleMigrationImportPreflight,
54
+ } from "../runtime/routes/migration-routes.js";
55
+ import { callHandler } from "./helpers/call-route-handler.js";
56
+
57
+ const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
58
+
59
+ function freshWorkspaceRoot(): string {
60
+ const parent = realpathSync(
61
+ mkdtempSync(join(tmpdir(), "migration-import-from-path-")),
62
+ );
63
+ const workspaceDir = join(parent, "workspace");
64
+ mkdirSync(workspaceDir, { recursive: true });
65
+ return workspaceDir;
66
+ }
67
+
68
+ function setWorkspaceDir(dir: string): void {
69
+ process.env.VELLUM_WORKSPACE_DIR = dir;
70
+ }
71
+
72
+ function makeSmallValidBundle(): Uint8Array {
73
+ const { archive } = buildVBundle({
74
+ files: [
75
+ {
76
+ path: "workspace/data/db/assistant.db",
77
+ data: new TextEncoder().encode("SQLite format 3\0"),
78
+ },
79
+ {
80
+ path: "workspace/config.json",
81
+ data: new TextEncoder().encode(
82
+ JSON.stringify({ provider: "anthropic", model: "test-model" }),
83
+ ),
84
+ },
85
+ ],
86
+ ...defaultV1Options(),
87
+ });
88
+ return archive;
89
+ }
90
+
91
+ function stageBundle(workspaceDir: string, archive: Uint8Array): string {
92
+ const staging = join(workspaceDir, RESTORE_STAGING_DIRNAME);
93
+ mkdirSync(staging, { recursive: true });
94
+ const filename = "backup.vbundle";
95
+ writeFileSync(join(staging, filename), archive);
96
+ return `${RESTORE_STAGING_DIRNAME}/${filename}`;
97
+ }
98
+
99
+ interface ImportCommitResponse {
100
+ success: boolean;
101
+ summary: {
102
+ total_files: number;
103
+ files_created: number;
104
+ files_overwritten: number;
105
+ files_skipped: number;
106
+ backups_created: number;
107
+ };
108
+ files: Array<{ path: string }>;
109
+ manifest: Record<string, unknown>;
110
+ }
111
+
112
+ interface PreflightResponse {
113
+ can_import: boolean;
114
+ summary?: {
115
+ files_to_create: number;
116
+ files_to_overwrite: number;
117
+ files_unchanged: number;
118
+ total_files: number;
119
+ };
120
+ validation?: { is_valid: false; errors: Array<{ code: string }> };
121
+ }
122
+
123
+ interface BadRequestResponse {
124
+ error: { code: string; message: string };
125
+ }
126
+
127
+ let testWorkspaceRoot: string;
128
+ let testParent: string;
129
+
130
+ beforeEach(() => {
131
+ testWorkspaceRoot = freshWorkspaceRoot();
132
+ testParent = join(testWorkspaceRoot, "..");
133
+ setWorkspaceDir(testWorkspaceRoot);
134
+ });
135
+
136
+ afterEach(() => {
137
+ try {
138
+ rmSync(testParent, { recursive: true, force: true });
139
+ } catch {
140
+ /* best effort */
141
+ }
142
+ });
143
+
144
+ afterAll(() => {
145
+ if (originalWorkspaceDir !== undefined) {
146
+ process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
147
+ } else {
148
+ delete process.env.VELLUM_WORKSPACE_DIR;
149
+ }
150
+ });
151
+
152
+ describe("handleMigrationImport — JSON {path} body", () => {
153
+ test("happy path: streams a staged .vbundle and imports it", async () => {
154
+ const relativePath = stageBundle(
155
+ testWorkspaceRoot,
156
+ makeSmallValidBundle(),
157
+ );
158
+
159
+ const req = new Request("http://localhost/v1/migrations/import", {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ path: relativePath }),
163
+ });
164
+
165
+ const res = await callHandler(handleMigrationImport, req);
166
+ const body = (await res.json()) as ImportCommitResponse;
167
+
168
+ expect(res.status).toBe(200);
169
+ expect(body.success).toBe(true);
170
+ expect(body.summary.total_files).toBeGreaterThan(0);
171
+ expect(body.files.length).toBeGreaterThan(0);
172
+ expect(existsSync(join(testWorkspaceRoot, "data", "db", "assistant.db"))).toBe(
173
+ true,
174
+ );
175
+ expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(true);
176
+ });
177
+
178
+ test("absolute path inside staging is accepted", async () => {
179
+ const relativePath = stageBundle(
180
+ testWorkspaceRoot,
181
+ makeSmallValidBundle(),
182
+ );
183
+ const absolutePath = join(testWorkspaceRoot, relativePath);
184
+
185
+ const req = new Request("http://localhost/v1/migrations/import", {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({ path: absolutePath }),
189
+ });
190
+
191
+ const res = await callHandler(handleMigrationImport, req);
192
+ expect(res.status).toBe(200);
193
+ const body = (await res.json()) as ImportCommitResponse;
194
+ expect(body.success).toBe(true);
195
+ });
196
+
197
+ test("both url and path returns 400", async () => {
198
+ const req = new Request("http://localhost/v1/migrations/import", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ url: "https://storage.googleapis.com/b/o?X-Goog-Signature=x",
203
+ path: `${RESTORE_STAGING_DIRNAME}/backup.vbundle`,
204
+ }),
205
+ });
206
+
207
+ const res = await callHandler(handleMigrationImport, req);
208
+ const body = (await res.json()) as BadRequestResponse;
209
+
210
+ expect(res.status).toBe(400);
211
+ expect(body.error.code).toBe("BAD_REQUEST");
212
+ expect(body.error.message).toContain("exactly one");
213
+ });
214
+
215
+ test("missing staged file returns 400", async () => {
216
+ const req = new Request("http://localhost/v1/migrations/import", {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify({
220
+ path: `${RESTORE_STAGING_DIRNAME}/missing.vbundle`,
221
+ }),
222
+ });
223
+
224
+ const res = await callHandler(handleMigrationImport, req);
225
+ const body = (await res.json()) as BadRequestResponse;
226
+
227
+ expect(res.status).toBe(400);
228
+ expect(body.error.code).toBe("BAD_REQUEST");
229
+ expect(body.error.message.toLowerCase()).toContain("not found");
230
+ });
231
+
232
+ test("traversal out of staging returns 400", async () => {
233
+ writeFileSync(join(testWorkspaceRoot, "secret.vbundle"), "nope");
234
+
235
+ const req = new Request("http://localhost/v1/migrations/import", {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ path: `${RESTORE_STAGING_DIRNAME}/../secret.vbundle`,
240
+ }),
241
+ });
242
+
243
+ const res = await callHandler(handleMigrationImport, req);
244
+ const body = (await res.json()) as BadRequestResponse;
245
+
246
+ expect(res.status).toBe(400);
247
+ expect(body.error.code).toBe("BAD_REQUEST");
248
+ expect(body.error.message.toLowerCase()).toContain("staging");
249
+ });
250
+
251
+ test("path outside the workspace returns 400", async () => {
252
+ const outside = join(testParent, "outside.vbundle");
253
+ writeFileSync(outside, "nope");
254
+
255
+ const req = new Request("http://localhost/v1/migrations/import", {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify({ path: outside }),
259
+ });
260
+
261
+ const res = await callHandler(handleMigrationImport, req);
262
+ const body = (await res.json()) as BadRequestResponse;
263
+
264
+ expect(res.status).toBe(400);
265
+ expect(body.error.code).toBe("BAD_REQUEST");
266
+ expect(body.error.message.toLowerCase()).toContain("staging");
267
+ });
268
+
269
+ test("symlink in staging returns 400", async () => {
270
+ const relativePath = stageBundle(
271
+ testWorkspaceRoot,
272
+ makeSmallValidBundle(),
273
+ );
274
+ const target = join(testWorkspaceRoot, relativePath);
275
+ const link = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME, "alias.vbundle");
276
+ symlinkSync(target, link);
277
+
278
+ const req = new Request("http://localhost/v1/migrations/import", {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({
282
+ path: `${RESTORE_STAGING_DIRNAME}/alias.vbundle`,
283
+ }),
284
+ });
285
+
286
+ const res = await callHandler(handleMigrationImport, req);
287
+ const body = (await res.json()) as BadRequestResponse;
288
+
289
+ expect(res.status).toBe(400);
290
+ expect(body.error.code).toBe("BAD_REQUEST");
291
+ expect(body.error.message.toLowerCase()).toContain("symlink");
292
+ });
293
+ });
294
+
295
+ describe("handleMigrationImportPreflight — JSON {path} body", () => {
296
+ test("happy path: analyzes a staged .vbundle without writing files", async () => {
297
+ const relativePath = stageBundle(
298
+ testWorkspaceRoot,
299
+ makeSmallValidBundle(),
300
+ );
301
+
302
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify({ path: relativePath }),
306
+ });
307
+
308
+ const res = await callHandler(handleMigrationImportPreflight, req);
309
+ const body = (await res.json()) as PreflightResponse;
310
+
311
+ expect(res.status).toBe(200);
312
+ expect(body.can_import).toBe(true);
313
+ expect(body.summary?.total_files).toBeGreaterThan(0);
314
+ expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(false);
315
+ });
316
+
317
+ test("empty path returns 400", async () => {
318
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
319
+ method: "POST",
320
+ headers: { "Content-Type": "application/json" },
321
+ body: JSON.stringify({ path: "" }),
322
+ });
323
+
324
+ const res = await callHandler(handleMigrationImportPreflight, req);
325
+ const body = (await res.json()) as BadRequestResponse;
326
+
327
+ expect(res.status).toBe(400);
328
+ expect(body.error.code).toBe("BAD_REQUEST");
329
+ });
330
+
331
+ test("non-.vbundle staging file returns 400", async () => {
332
+ const staging = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME);
333
+ mkdirSync(staging, { recursive: true });
334
+ writeFileSync(join(staging, "notes.txt"), "nope");
335
+
336
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
337
+ method: "POST",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ path: `${RESTORE_STAGING_DIRNAME}/notes.txt` }),
340
+ });
341
+
342
+ const res = await callHandler(handleMigrationImportPreflight, req);
343
+ const body = (await res.json()) as BadRequestResponse;
344
+
345
+ expect(res.status).toBe(400);
346
+ expect(body.error.code).toBe("BAD_REQUEST");
347
+ expect(body.error.message.toLowerCase()).toContain(".vbundle");
348
+ });
349
+ });