@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
@@ -12,6 +12,7 @@ import {
12
12
  getTwilioMediaStreamUrl,
13
13
  getTwilioStatusCallbackUrl,
14
14
  getTwilioVoiceWebhookUrl,
15
+ tryGetPublicBaseUrl,
15
16
  } from "../inbound/public-ingress-urls.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -163,6 +164,41 @@ describe("getPublicBaseUrl", () => {
163
164
  });
164
165
  });
165
166
 
167
+ describe("tryGetPublicBaseUrl", () => {
168
+ beforeEach(() => {
169
+ setIngressPublicBaseUrl(undefined);
170
+ });
171
+
172
+ afterEach(() => {
173
+ setIngressPublicBaseUrl(undefined);
174
+ });
175
+
176
+ test("returns the configured public base URL", () => {
177
+ expect(
178
+ tryGetPublicBaseUrl({
179
+ ingress: { publicBaseUrl: "https://ingress.example.com/" },
180
+ }),
181
+ ).toBe("https://ingress.example.com");
182
+ });
183
+
184
+ test("returns the detected module-level ingress URL", () => {
185
+ setIngressPublicBaseUrl("https://detected.example.com/");
186
+ expect(tryGetPublicBaseUrl({})).toBe("https://detected.example.com");
187
+ });
188
+
189
+ test("returns undefined when no URL is configured", () => {
190
+ expect(tryGetPublicBaseUrl({})).toBeUndefined();
191
+ });
192
+
193
+ test("returns undefined when ingress is explicitly disabled", () => {
194
+ expect(
195
+ tryGetPublicBaseUrl({
196
+ ingress: { enabled: false, publicBaseUrl: "https://example.com" },
197
+ }),
198
+ ).toBeUndefined();
199
+ });
200
+ });
201
+
166
202
  // ---------------------------------------------------------------------------
167
203
  // Twilio-specific public base URL selection
168
204
  // ---------------------------------------------------------------------------
@@ -13,7 +13,6 @@ mock.module("../config/env.js", () => ({
13
13
 
14
14
  import {
15
15
  FALLBACK_TURN_TRUST,
16
- mapChatTypeToConversationType,
17
16
  resolveTrustClass,
18
17
  } from "../daemon/trust-context.js";
19
18
  import type { TrustContext } from "../daemon/trust-context-types.js";
@@ -73,50 +72,3 @@ describe("resolveTrustClass", () => {
73
72
  expect(resolveTrustClass(FALLBACK_TURN_TRUST)).toBe("unknown");
74
73
  });
75
74
  });
76
-
77
- describe("mapChatTypeToConversationType", () => {
78
- test("maps DM-shaped chat types to dm", () => {
79
- expect(mapChatTypeToConversationType("im")).toBe("dm"); // Slack DM
80
- expect(mapChatTypeToConversationType("private")).toBe("dm"); // Telegram DM
81
- expect(mapChatTypeToConversationType("dm")).toBe("dm"); // Discord DM
82
- });
83
-
84
- test("every DM-capable channel's own word for a DM lands on the axis", () => {
85
- // A missing arm is silent rather than loud: the permission cell stays
86
- // settable, because the adapter validates against the channel registry,
87
- // and simply never matches. The guardian sets a rule for DMs on that
88
- // channel and it does nothing. Stated as a sweep so adding a DM-capable
89
- // channel without its arm fails here.
90
- const dmChatTypeByChannel = {
91
- slack: "im",
92
- telegram: "private",
93
- whatsapp: "private",
94
- discord: "dm",
95
- };
96
- for (const [channel, chatType] of Object.entries(dmChatTypeByChannel)) {
97
- expect(`${channel}:${mapChatTypeToConversationType(chatType)}`).toBe(
98
- `${channel}:dm`,
99
- );
100
- }
101
- });
102
-
103
- test("maps closed-group chat types to private", () => {
104
- expect(mapChatTypeToConversationType("mpim")).toBe("private"); // Slack multi-party DM
105
- expect(mapChatTypeToConversationType("group")).toBe("private");
106
- expect(mapChatTypeToConversationType("supergroup")).toBe("private");
107
- });
108
-
109
- test("maps Slack 'channel' to undefined — public and private are indistinguishable", () => {
110
- // The gateway forwards every non-DM Slack conversation as "channel"
111
- // without a public/private distinction. Mapping it to "public" would let
112
- // a permissive public-channel matrix cell govern private channels, so
113
- // the channel-type tier must not match at all.
114
- expect(mapChatTypeToConversationType("channel")).toBeUndefined();
115
- });
116
-
117
- test("maps unknown or absent chat types to undefined", () => {
118
- expect(mapChatTypeToConversationType(undefined)).toBeUndefined();
119
- expect(mapChatTypeToConversationType("")).toBeUndefined();
120
- expect(mapChatTypeToConversationType("broadcast")).toBeUndefined();
121
- });
122
- });
@@ -155,6 +155,34 @@ describe("sanitizeConfigForTransfer", () => {
155
155
  });
156
156
  });
157
157
 
158
+ test("strips the remembered tunnels and the assistant they fronted", () => {
159
+ // Every record describes the source host's tunnels; a destination that
160
+ // inherited them would report the old deployment's tunnel as its own, and
161
+ // advertise an address on the source's tailnet for pairing.
162
+ const input = {
163
+ ingress: {
164
+ publicBaseUrl: "https://source.ngrok.app",
165
+ lastTunnel: {
166
+ provider: "ngrok",
167
+ publicBaseUrl: "https://source.ngrok.app",
168
+ },
169
+ pairingTunnel: {
170
+ provider: "tailscale",
171
+ publicBaseUrl: "https://source-host.example.ts.net",
172
+ },
173
+ assistantId: "source-assistant",
174
+ webhook: { path: "/webhook" },
175
+ },
176
+ };
177
+
178
+ const result = JSON.parse(sanitizeConfigForTransfer(JSON.stringify(input)));
179
+
180
+ expect(result.ingress).toEqual({
181
+ publicBaseUrl: "",
182
+ webhook: { path: "/webhook" },
183
+ });
184
+ });
185
+
158
186
  test("preserves nested ingress fields other than environment-specific URL state", () => {
159
187
  const input = {
160
188
  ingress: {
@@ -1,6 +1,10 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { eq } from "drizzle-orm";
4
+
3
5
  import { getConfig } from "../config/loader.js";
6
+ import { getDb } from "../persistence/db-connection.js";
7
+ import { providerConnections } from "../persistence/schema/inference.js";
4
8
  import { credentialKey } from "../security/credential-key.js";
5
9
 
6
10
  let lastGeminiConstructorOpts: Record<string, unknown> | null = null;
@@ -160,6 +164,10 @@ describe("secret routes managed proxy registry sync", () => {
160
164
  lastGeminiConstructorOpts = null;
161
165
  platformBaseUrlOverride = undefined;
162
166
  providerRefreshCalls = 0;
167
+ getDb()
168
+ .delete(providerConnections)
169
+ .where(eq(providerConnections.name, "openrouter-connection"))
170
+ .run();
163
171
  await initializeProviders(getConfig());
164
172
  });
165
173
 
@@ -252,6 +260,47 @@ describe("secret routes managed proxy registry sync", () => {
252
260
  expect(providerRefreshCalls).toBe(2);
253
261
  });
254
262
 
263
+ /**
264
+ * Verifies that rotating a credential refreshes providers for dependent connections.
265
+ */
266
+ test("non-managed credentials refresh providers when a connection uses them", async () => {
267
+ // GIVEN a non-managed credential.
268
+ // AND a provider connection references its canonical credential key.
269
+ const now = Date.now();
270
+ getDb()
271
+ .insert(providerConnections)
272
+ .values({
273
+ name: "openrouter-connection",
274
+ provider: "openai",
275
+ auth: JSON.stringify({
276
+ type: "api_key",
277
+ credential: credentialKey("openrouter", "api_key"),
278
+ }),
279
+ createdAt: now,
280
+ updatedAt: now,
281
+ })
282
+ .run();
283
+
284
+ // WHEN the credential is rotated.
285
+ await addCredential("openrouter:api_key", "openrouter-key");
286
+
287
+ // THEN the provider refresh runs once.
288
+ expect(providerRefreshCalls).toBe(1);
289
+ });
290
+
291
+ /**
292
+ * Verifies that rotating an unused credential does not refresh providers.
293
+ */
294
+ test("non-managed credentials do not refresh providers when no connection uses them", async () => {
295
+ // GIVEN a non-managed credential.
296
+ // AND no provider connection references its credential key.
297
+ // WHEN the credential is rotated.
298
+ await addCredential("openrouter:api_key", "openrouter-key");
299
+
300
+ // THEN the provider refresh does not run.
301
+ expect(providerRefreshCalls).toBe(0);
302
+ });
303
+
255
304
  test("storing vellum:platform_base_url sets override and triggers initializeProviders", async () => {
256
305
  await addCredential(
257
306
  "vellum:platform_base_url",
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Tests for the `workspace-files` list/read endpoints in settings-routes.
2
+ * Tests for the `workspace-files` list/read and ingress-config endpoints in
3
+ * settings-routes.
3
4
  *
4
5
  * Focus: the list includes the guardian's per-user persona file
5
- * (`users/<slug>.md`) whenever a guardian exists, and the read endpoint
6
- * accepts paths under `users/`.
6
+ * (`users/<slug>.md`) whenever a guardian exists, the read endpoint accepts
7
+ * paths under `users/`, and writing a different ingress URL drops the tunnel
8
+ * records that described the old one.
7
9
  */
8
10
 
9
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -194,3 +196,83 @@ describe("GET /workspace-files/read", () => {
194
196
  );
195
197
  });
196
198
  });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // PUT /integrations/ingress/config
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe("PUT integrations/ingress/config", () => {
205
+ const handler = getHandler("integrations/ingress/config", "PUT");
206
+ const configPath = join(testWorkspaceDir, "config.json");
207
+
208
+ const TUNNEL_URL = "https://assistant-1.example.ts.net";
209
+ const LAST_TUNNEL = { provider: "tailscale", publicBaseUrl: TUNNEL_URL };
210
+
211
+ function readConfig(): Record<string, unknown> {
212
+ return existsSync(configPath)
213
+ ? JSON.parse(readFileSync(configPath, "utf-8"))
214
+ : {};
215
+ }
216
+
217
+ function seedIngress(ingress: Record<string, unknown>): void {
218
+ writeFileSync(
219
+ configPath,
220
+ JSON.stringify({ ...readConfig(), ingress }, null, 2),
221
+ "utf-8",
222
+ );
223
+ }
224
+
225
+ function readIngress(): Record<string, unknown> {
226
+ return readConfig().ingress as Record<string, unknown>;
227
+ }
228
+
229
+ beforeEach(() => {
230
+ seedIngress({
231
+ publicBaseUrl: TUNNEL_URL,
232
+ enabled: true,
233
+ assistantId: "assistant-1",
234
+ lastTunnel: LAST_TUNNEL,
235
+ });
236
+ });
237
+
238
+ test("keeps the records when the URL is unchanged", async () => {
239
+ await handler(makeArgs({}, { publicBaseUrl: TUNNEL_URL, enabled: false }));
240
+
241
+ const ingress = readIngress();
242
+ expect(ingress.assistantId).toBe("assistant-1");
243
+ expect(ingress.lastTunnel).toEqual(LAST_TUNNEL);
244
+ });
245
+
246
+ test("ignores trailing slashes and whitespace when comparing", async () => {
247
+ await handler(
248
+ makeArgs({}, { publicBaseUrl: ` ${TUNNEL_URL}/ `, enabled: true }),
249
+ );
250
+
251
+ const ingress = readIngress();
252
+ expect(ingress.assistantId).toBe("assistant-1");
253
+ expect(ingress.lastTunnel).toEqual(LAST_TUNNEL);
254
+ });
255
+
256
+ test("drops the records when the URL is retargeted", async () => {
257
+ await handler(
258
+ makeArgs(
259
+ {},
260
+ { publicBaseUrl: "https://other.example.ts.net", enabled: true },
261
+ ),
262
+ );
263
+
264
+ const ingress = readIngress();
265
+ expect(ingress.publicBaseUrl).toBe("https://other.example.ts.net");
266
+ expect(ingress.assistantId).toBeUndefined();
267
+ expect(ingress.lastTunnel).toBeUndefined();
268
+ });
269
+
270
+ test("drops the records when the URL is cleared", async () => {
271
+ await handler(makeArgs({}, { publicBaseUrl: "", enabled: false }));
272
+
273
+ const ingress = readIngress();
274
+ expect(ingress.publicBaseUrl).toBeUndefined();
275
+ expect(ingress.assistantId).toBeUndefined();
276
+ expect(ingress.lastTunnel).toBeUndefined();
277
+ });
278
+ });
@@ -29,6 +29,8 @@ interface JsonCatalogEntry {
29
29
  secretKey?: string;
30
30
  fallbackOrder?: number;
31
31
  privacyPolicyUrl?: string;
32
+ supportsApiBase?: boolean;
33
+ defaultApiBase?: string;
32
34
  }
33
35
 
34
36
  interface JsonCatalog {
@@ -75,6 +77,12 @@ function entryToPlain(
75
77
  if (entry.privacyPolicyUrl !== undefined) {
76
78
  out.privacyPolicyUrl = entry.privacyPolicyUrl;
77
79
  }
80
+ if (entry.supportsApiBase !== undefined) {
81
+ out.supportsApiBase = entry.supportsApiBase;
82
+ }
83
+ if (entry.defaultApiBase !== undefined) {
84
+ out.defaultApiBase = entry.defaultApiBase;
85
+ }
78
86
  return out;
79
87
  }
80
88
 
@@ -15,6 +15,7 @@
15
15
  * recover — so detection and repair of ordering drift live together as one unit.
16
16
  */
17
17
 
18
+ import { isEffectivelyEmptyContent } from "../../providers/content-blocks.js";
18
19
  import { analyzeServerToolPairing } from "../../providers/server-tool-pairing.js";
19
20
  import type {
20
21
  ContentBlock,
@@ -391,7 +392,7 @@ function formatWebSearchContent(content: unknown): string {
391
392
 
392
393
  /**
393
394
  * Provider rejection messages indicating a tool_use/tool_result pairing or
394
- * message-ordering violation — the structural drift `deepRepairHistory`
395
+ * message-ordering violation. The structural drift `deepRepairHistory`
395
396
  * re-normalizes.
396
397
  */
397
398
  export const ORDERING_ERROR_PATTERNS: readonly RegExp[] = [
@@ -420,6 +421,19 @@ export const WEB_SEARCH_ORDERING_PATTERNS: readonly RegExp[] = [
420
421
  /web_search.*tool_result/i,
421
422
  ];
422
423
 
424
+ /** Provider rejections for APIs that require the last history turn to be a user or function turn. */
425
+ export const USER_TERMINAL_HISTORY_PATTERNS: readonly RegExp[] = [
426
+ /ending with a model turn/i,
427
+ /must end with a user (turn|message)/i,
428
+ ];
429
+
430
+ /** Whether a provider error message requires history to end with a user turn. */
431
+ export function isUserTerminalHistoryError(message: string): boolean {
432
+ return USER_TERMINAL_HISTORY_PATTERNS.some((pattern) =>
433
+ pattern.test(message),
434
+ );
435
+ }
436
+
423
437
  /**
424
438
  * Whether a provider error message denotes an ordering/pairing violation that
425
439
  * `deepRepairHistory` can recover by re-normalizing the history. Detection and
@@ -429,21 +443,22 @@ export const WEB_SEARCH_ORDERING_PATTERNS: readonly RegExp[] = [
429
443
  export function isRepairableOrderingError(message: string): boolean {
430
444
  return (
431
445
  ORDERING_ERROR_PATTERNS.some((pattern) => pattern.test(message)) ||
432
- WEB_SEARCH_ORDERING_PATTERNS.some((pattern) => pattern.test(message))
446
+ WEB_SEARCH_ORDERING_PATTERNS.some((pattern) => pattern.test(message)) ||
447
+ isUserTerminalHistoryError(message)
433
448
  );
434
449
  }
435
450
 
436
451
  /**
437
- * Aggressive repair pass that handles edge cases beyond repairHistory:
438
- * - Removes empty messages
439
- * - Ensures the first message is from the user
440
- * - Merges consecutive same-role messages (before tool-use/result repair)
441
- * Then applies the standard repairHistory on top (which also merges any
442
- * consecutive same-role messages introduced by tool-use/result repair).
452
+ * Removes effectively empty messages, ensures the first message is from the
453
+ * user, merges consecutive same-role messages, optionally ensures the history
454
+ * ends with a user message, and applies standard tool-use/result repair.
443
455
  */
444
- export function deepRepairHistory(messages: Message[]): RepairResult {
445
- // 1. Remove messages with no content blocks
446
- let cleaned = messages.filter((m) => m.content.length > 0);
456
+ export function deepRepairHistory(
457
+ messages: Message[],
458
+ options: { requireUserTerminal?: boolean } = {},
459
+ ): RepairResult {
460
+ // 1. Remove messages with no serializable content
461
+ let cleaned = messages.filter((m) => !isEffectivelyEmptyContent(m.content));
447
462
 
448
463
  // 2. Strip leading assistant messages (provider requires user-first)
449
464
  while (cleaned.length > 0 && cleaned[0].role === "assistant") {
@@ -461,9 +476,25 @@ export function deepRepairHistory(messages: Message[]): RepairResult {
461
476
  }
462
477
  }
463
478
 
464
- // 4. Apply standard tool-use/tool-result repair on top. The inner mapping
465
- // is keyed to `merged`'s indices, not the caller's input — steps 1–3 drop
466
- // and merge messages untracked — so it is not returned.
479
+ if (options.requireUserTerminal) {
480
+ let droppedCount = 0;
481
+ while (
482
+ merged.length > 0 &&
483
+ merged[merged.length - 1]?.role === "assistant"
484
+ ) {
485
+ merged.pop();
486
+ droppedCount++;
487
+ }
488
+ if (droppedCount > 0) {
489
+ log.warn(
490
+ { droppedCount },
491
+ "Dropped trailing assistant turns because the provider requires history to end with a user turn",
492
+ );
493
+ }
494
+ }
495
+
496
+ // Apply standard tool-use/tool-result repair on top. The inner mapping is
497
+ // keyed to `merged`'s indices, not the caller's input, so it is not returned.
467
498
  const { messages: repaired, stats } = repairHistory(merged);
468
499
  return { messages: repaired, stats };
469
500
  }
package/src/agent/loop.ts CHANGED
@@ -56,6 +56,7 @@ import { CompactionCircuit } from "./compaction-circuit.js";
56
56
  import {
57
57
  deepRepairHistory,
58
58
  isRepairableOrderingError,
59
+ isUserTerminalHistoryError,
59
60
  } from "./history-repair/history-repair.js";
60
61
 
61
62
  const log = getLogger("agent-loop");
@@ -2538,7 +2539,9 @@ export class AgentLoop {
2538
2539
  ) {
2539
2540
  orderingRepairAttempted = true;
2540
2541
  postModelCallContinues++;
2541
- history = deepRepairHistory(errorOutcome.messages).messages;
2542
+ history = deepRepairHistory(errorOutcome.messages, {
2543
+ requireUserTerminal: isUserTerminalHistoryError(err.message),
2544
+ }).messages;
2542
2545
  // Deep repair merges and drops messages, so the prior input
2543
2546
  // boundary no longer maps onto the new array; the repaired history
2544
2547
  // is the base the retry's output appends after.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Static sanity check for a profile's explicit token budget against the
3
+ * model's catalog limits. Catches configurations that can never dispatch
4
+ * (the output budget consumes the model's entire context window, or exceeds
5
+ * its output cap) at save time instead of on the first chat message.
6
+ *
7
+ * Shared surface: the daemon's profile write routes enforce it as a blocking
8
+ * error, and the web profile editor mirrors the same judgment before its
9
+ * generic config PATCH, so the two paths cannot drift. Only fires when both
10
+ * sides of a judgment are known; unlisted models are covered by the live
11
+ * save-time probe instead.
12
+ */
13
+
14
+ /**
15
+ * Input room a profile must leave when its output budget is judged against
16
+ * the model's total context window.
17
+ */
18
+ export const MIN_INPUT_RESERVE_TOKENS = 4096;
19
+
20
+ export interface ProfileConfigIssue {
21
+ field: "maxTokens";
22
+ /** Which judgment fired, so clients can compose surface-specific copy. */
23
+ code: "over_output_cap" | "no_input_room";
24
+ message: string;
25
+ }
26
+
27
+ export function validateInferenceProfileConfig(args: {
28
+ maxTokens?: number;
29
+ modelMaxOutputTokens?: number;
30
+ modelContextWindowTokens?: number;
31
+ }): ProfileConfigIssue | null {
32
+ const { maxTokens, modelMaxOutputTokens, modelContextWindowTokens } = args;
33
+ if (maxTokens === undefined) {
34
+ return null;
35
+ }
36
+ if (modelMaxOutputTokens !== undefined && maxTokens > modelMaxOutputTokens) {
37
+ return {
38
+ field: "maxTokens",
39
+ code: "over_output_cap",
40
+ message:
41
+ `maxTokens (${maxTokens}) exceeds the model's maximum output of ` +
42
+ `${modelMaxOutputTokens} tokens. Requests would be rejected upstream; ` +
43
+ `reduce maxTokens to ${modelMaxOutputTokens} or less.`,
44
+ };
45
+ }
46
+ if (
47
+ modelContextWindowTokens !== undefined &&
48
+ maxTokens >= modelContextWindowTokens - MIN_INPUT_RESERVE_TOKENS
49
+ ) {
50
+ return {
51
+ field: "maxTokens",
52
+ code: "no_input_room",
53
+ message:
54
+ `maxTokens (${maxTokens}) reserves the entire ${modelContextWindowTokens}-token ` +
55
+ `context window for output, leaving no room for your messages. ` +
56
+ `Reduce maxTokens (e.g. 8000) so input fits.`,
57
+ };
58
+ }
59
+ return null;
60
+ }
@@ -50,11 +50,16 @@ export const WebSearchProviderIdSchema = z.enum([
50
50
  "tavily",
51
51
  "keenable",
52
52
  "firecrawl",
53
+ "fastcrw",
53
54
  ]);
54
55
 
55
56
  export type WebSearchProviderId = z.infer<typeof WebSearchProviderIdSchema>;
56
57
 
57
- export const WebFetchProviderIdSchema = z.enum(["default", "firecrawl"]);
58
+ export const WebFetchProviderIdSchema = z.enum([
59
+ "default",
60
+ "firecrawl",
61
+ "fastcrw",
62
+ ]);
58
63
 
59
64
  export type WebFetchProviderId = z.infer<typeof WebFetchProviderIdSchema>;
60
65
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `watch_retro_completed` SSE event.
3
+ *
4
+ * Server → client broadcast marking the end of a watch session's
5
+ * retrospective, the turn `runWatchRetro` dispatches once the recording socket
6
+ * is gone (`assistant/src/watch/watch-retro.ts`).
7
+ *
8
+ * **The watch stream cannot carry this.** A session's socket sends `closed`
9
+ * and is torn down before the retro is dispatched, so by the time there is
10
+ * anything to report the transport the user pressed stop on no longer exists.
11
+ * Every client that draws a watch session is already subscribed to `/v1/events`
12
+ * for the assistant, which is the one channel still open at that point.
13
+ *
14
+ * **Global, not conversation-scoped.** `conversationId` names the thread the
15
+ * report was written into, which is a background conversation the user is
16
+ * almost never sitting in: a session is narrated while working in another app
17
+ * entirely. Clients route this by `sessionId` rather than against whichever
18
+ * conversation they have open, the same way `notification_conversation_created`
19
+ * announces a conversation other than the active one.
20
+ *
21
+ * **Emitted on every outcome, including the empty ones.** A client that told
22
+ * the user their session is being summarized has to be told when it is not
23
+ * coming, or it draws a progress affordance over nothing until it gives up on
24
+ * its own. `reportReady` is the whole of that distinction: true when the turn
25
+ * left an account of the session behind and the conversation has been surfaced
26
+ * for the user to open, false when the session recorded nothing or the turn
27
+ * failed to produce a report.
28
+ *
29
+ * Canonical wire-contract source. Daemon code imports the type directly from
30
+ * this file; external consumers import via `@vellumai/assistant-api`.
31
+ */
32
+
33
+ import { z } from "zod";
34
+
35
+ export const WatchRetroCompletedEventSchema = z.object({
36
+ type: z.literal("watch_retro_completed"),
37
+ /**
38
+ * The watch session the retrospective was run for, as the `ready` frame on
39
+ * the watch stream named it. Clients match on this rather than on the
40
+ * conversation, because a session can be started against a conversation it
41
+ * did not mint and two sessions can therefore share one.
42
+ */
43
+ sessionId: z.string(),
44
+ /** The conversation holding the report, and the one to open on a yes. */
45
+ conversationId: z.string(),
46
+ /** Whether there is a report in that conversation to read. */
47
+ reportReady: z.boolean(),
48
+ });
49
+
50
+ export type WatchRetroCompletedEvent = z.infer<
51
+ typeof WatchRetroCompletedEventSchema
52
+ >;
package/src/api/index.ts CHANGED
@@ -129,6 +129,7 @@ import { UISurfaceUpdateEventSchema } from "./events/ui-surface-update.js";
129
129
  import { UsageProgressEventSchema } from "./events/usage-progress.js";
130
130
  import { UsageUpdateEventSchema } from "./events/usage-update.js";
131
131
  import { UserMessageEchoEventSchema } from "./events/user-message-echo.js";
132
+ import { WatchRetroCompletedEventSchema } from "./events/watch-retro-completed.js";
132
133
  import { WorkflowCompletedEventSchema } from "./events/workflow-completed.js";
133
134
  import { WorkflowLeafFinishedEventSchema } from "./events/workflow-leaf-finished.js";
134
135
  import { WorkflowLeafStartedEventSchema } from "./events/workflow-leaf-started.js";
@@ -151,6 +152,11 @@ export {
151
152
  type DocumentMutationTool,
152
153
  REOPENABLE_DOCUMENT_MUTATION_TOOL_NAMES,
153
154
  } from "./constants/document-tools.js";
155
+ export {
156
+ MIN_INPUT_RESERVE_TOKENS,
157
+ type ProfileConfigIssue,
158
+ validateInferenceProfileConfig,
159
+ } from "./constants/profile-config-validation.js";
154
160
  export {
155
161
  SSE_REPLAY_RING_AGE_LIMIT_MS,
156
162
  SSE_REPLAY_RING_COUNT_LIMIT,
@@ -676,6 +682,10 @@ export {
676
682
  type UserMessageEchoEvent,
677
683
  UserMessageEchoEventSchema,
678
684
  } from "./events/user-message-echo.js";
685
+ export {
686
+ type WatchRetroCompletedEvent,
687
+ WatchRetroCompletedEventSchema,
688
+ } from "./events/watch-retro-completed.js";
679
689
  export {
680
690
  type WorkflowCompletedEvent,
681
691
  WorkflowCompletedEventSchema,
@@ -1032,6 +1042,7 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
1032
1042
  UsageProgressEventSchema,
1033
1043
  UsageUpdateEventSchema,
1034
1044
  UserMessageEchoEventSchema,
1045
+ WatchRetroCompletedEventSchema,
1035
1046
  WorkflowCompletedEventSchema,
1036
1047
  WorkflowLeafFinishedEventSchema,
1037
1048
  WorkflowLeafStartedEventSchema,