@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
@@ -58,18 +58,85 @@ export type LiveVoiceStepName =
58
58
  export type LiveVoiceSessionOutcome = "completed" | "failed";
59
59
 
60
60
  /**
61
- * The `screen` dimension for an ended session: how it closed, plus the
62
- * protocol error code when a failure is what closed it.
61
+ * How far a session that produced NO turn actually got.
63
62
  *
64
- * Both halves are the daemon's own vocabulary verbatim
65
- * (`LiveVoiceSessionCloseReason`, `LiveVoiceProtocolErrorCode`) rather than a
66
- * mapping, so a rename on either side surfaces here as a compile error instead
67
- * of a silently-empty dashboard facet. The longest pair this can produce is
68
- * well inside the wire field's 64-char bound.
63
+ * A quarter of live-voice sessions end without a single turn, at a median of a
64
+ * few seconds. That is the closest thing the telemetry has to a "voice didn't
65
+ * work for me" rate, and a bare connect/close row cannot separate a denied
66
+ * microphone from a muted one from someone deliberately backing out. Those have
67
+ * completely different fixes, so the reason is what makes the rate actionable.
68
+ *
69
+ * The four values are ordered by how far the session got, and each one narrows
70
+ * the cause to a different layer:
71
+ *
72
+ * - `no_ready` never reached `active`: died in the credential preflight or
73
+ * the transport, before the user could have said anything.
74
+ * - `no_audio` reached `active` but not one audio chunk ever arrived, so the
75
+ * microphone never opened. Permission denial looks like this.
76
+ * - `no_speech` audio arrived but the detector never classified any of it as
77
+ * speech: a muted or dead mic, or a genuinely silent user.
78
+ * - `no_turn` speech was detected but no turn was ever dispatched, so the
79
+ * utterance was abandoned, aborted, or failed to arm.
80
+ */
81
+ export type LiveVoiceSilenceReason =
82
+ | "no_ready"
83
+ | "no_audio"
84
+ | "no_speech"
85
+ | "no_turn";
86
+
87
+ /**
88
+ * Classify a zero-turn session from what the session observed. Call only when
89
+ * the session really produced no turn: every branch here asserts silence, so a
90
+ * session that did dispatch a turn would be mislabelled by the last one.
91
+ */
92
+ export function liveVoiceSilenceReason(signals: {
93
+ reachedActive: boolean;
94
+ receivedAudio: boolean;
95
+ detectedSpeech: boolean;
96
+ }): LiveVoiceSilenceReason {
97
+ if (!signals.reachedActive) {
98
+ return "no_ready";
99
+ }
100
+ if (!signals.receivedAudio) {
101
+ return "no_audio";
102
+ }
103
+ if (!signals.detectedSpeech) {
104
+ return "no_speech";
105
+ }
106
+ return "no_turn";
107
+ }
108
+
109
+ /**
110
+ * The `screen` dimension for an ended session: how it closed, plus a detail
111
+ * half. That detail is the protocol error code when a failure closed it, or the
112
+ * silence classification when the session produced no turn at all.
113
+ *
114
+ * Every part is the daemon's own vocabulary verbatim
115
+ * (`LiveVoiceSessionCloseReason`, `LiveVoiceProtocolErrorCode`,
116
+ * {@link LiveVoiceSilenceReason}) rather than a mapping, so a rename on any of
117
+ * them surfaces here as a compile error instead of a silently-empty dashboard
118
+ * facet. The longest combination this can produce is well inside the wire
119
+ * field's 64-char bound.
120
+ *
121
+ * A failure code wins over a silence reason when both apply: a session that
122
+ * died on an error is explained by the error, and the silence is a consequence
123
+ * of it rather than a separate finding.
124
+ *
125
+ * The silence value carries a `silent_` prefix on purpose. It shares the detail
126
+ * slot with failure codes, and the admin dashboard currently renders that slot
127
+ * in a column labelled "failure code", so the value has to read correctly even
128
+ * where the column header is wrong, until that panel learns the difference.
69
129
  */
70
130
  export function liveVoiceEndScreen(
71
131
  reason: LiveVoiceSessionCloseReason,
72
132
  failureCode?: LiveVoiceProtocolErrorCode | null,
133
+ silenceReason?: LiveVoiceSilenceReason | null,
73
134
  ): string {
74
- return failureCode ? `ended_${reason}:${failureCode}` : `ended_${reason}`;
135
+ if (failureCode) {
136
+ return `ended_${reason}:${failureCode}`;
137
+ }
138
+ if (silenceReason) {
139
+ return `ended_${reason}:silent_${silenceReason}`;
140
+ }
141
+ return `ended_${reason}`;
75
142
  }
@@ -10,12 +10,13 @@
10
10
  * the plugin-facing {@link ../../plugin-api/store-credential.storeCredential}
11
11
  * both compose it.
12
12
  *
13
- * The transcript scrub and the connection sync are imported lazily, at the
14
- * point of use. Both pull heavy graphs behind them (the scrub reaches the
15
- * conversation database and registry; the sync reaches the OAuth store), and
16
- * this module sits under `@vellumai/plugin-api`, so a static edge would drag
17
- * that machinery into every plugin-api consumer for a write most of them never
18
- * perform.
13
+ * The transcript scrub, connection sync, and provider rotation refresh are
14
+ * imported lazily, at the point of use. They pull heavy graphs behind them
15
+ * (the scrub reaches the conversation database and registry, the sync reaches
16
+ * the OAuth store, and the refresh reaches the provider and conversation
17
+ * graphs), and this module sits under `@vellumai/plugin-api`, so static edges
18
+ * would drag that machinery into every plugin-api consumer for a write most of
19
+ * them never perform.
19
20
  *
20
21
  * Callers own their own transport-level argument validation and error mapping;
21
22
  * this module throws {@link InvalidCredentialInputError} for a value it will
@@ -164,5 +165,16 @@ export async function storeCredentialValue(
164
165
  await import("../../oauth/manual-token-connection.js");
165
166
  await syncManualTokenConnection(service);
166
167
 
168
+ try {
169
+ const { refreshProvidersForRotatedCredential } =
170
+ await import("../../providers/inference/credential-rotation.js");
171
+ await refreshProvidersForRotatedCredential(service, field);
172
+ } catch (err) {
173
+ log.warn(
174
+ { err, service, field },
175
+ "Credential stored, but provider rotation refresh failed",
176
+ );
177
+ }
178
+
167
179
  return { credentialId: metadata.credentialId, service, field };
168
180
  }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ extractFirecrawlCompatSearchResults,
5
+ resolveProviderApiUrl,
6
+ searchResultSnippet,
7
+ } from "../firecrawl-compat.js";
8
+
9
+ describe("resolveProviderApiUrl", () => {
10
+ test("uses the default base when apiBase is empty", () => {
11
+ expect(
12
+ resolveProviderApiUrl("", "/v1/search", "https://api.fastcrw.com"),
13
+ ).toBe("https://api.fastcrw.com/v1/search");
14
+ expect(
15
+ resolveProviderApiUrl(undefined, "/v1/scrape", "https://api.fastcrw.com"),
16
+ ).toBe("https://api.fastcrw.com/v1/scrape");
17
+ });
18
+
19
+ test("strips trailing slashes from a custom base", () => {
20
+ expect(
21
+ resolveProviderApiUrl(
22
+ "http://localhost:3000///",
23
+ "/v1/search",
24
+ "https://api.fastcrw.com",
25
+ ),
26
+ ).toBe("http://localhost:3000/v1/search");
27
+ });
28
+ });
29
+
30
+ describe("extractFirecrawlCompatSearchResults", () => {
31
+ test("reads grouped data.web", () => {
32
+ expect(
33
+ extractFirecrawlCompatSearchResults({
34
+ data: { web: [{ title: "A", url: "https://a.test" }] },
35
+ }),
36
+ ).toEqual([{ title: "A", url: "https://a.test" }]);
37
+ });
38
+
39
+ test("reads data.results as a flat array", () => {
40
+ expect(
41
+ extractFirecrawlCompatSearchResults({
42
+ data: { results: [{ title: "B", url: "https://b.test" }] },
43
+ }),
44
+ ).toEqual([{ title: "B", url: "https://b.test" }]);
45
+ });
46
+
47
+ test("reads self-hosted data.results.web when sources group by type", () => {
48
+ expect(
49
+ extractFirecrawlCompatSearchResults({
50
+ data: {
51
+ results: {
52
+ web: [{ title: "Self-host", url: "https://local.test" }],
53
+ news: [{ title: "News", url: "https://news.test" }],
54
+ },
55
+ },
56
+ }),
57
+ ).toEqual([{ title: "Self-host", url: "https://local.test" }]);
58
+ });
59
+
60
+ test("reads a flat data array", () => {
61
+ expect(
62
+ extractFirecrawlCompatSearchResults({
63
+ data: [{ title: "C", url: "https://c.test" }],
64
+ }),
65
+ ).toEqual([{ title: "C", url: "https://c.test" }]);
66
+ });
67
+ });
68
+
69
+ describe("searchResultSnippet", () => {
70
+ test("prefers description then snippet", () => {
71
+ expect(
72
+ searchResultSnippet({ description: "desc", snippet: "snip" }),
73
+ ).toBe("desc");
74
+ expect(searchResultSnippet({ snippet: "snip" })).toBe("snip");
75
+ expect(searchResultSnippet({})).toBeUndefined();
76
+ });
77
+ });
@@ -0,0 +1,169 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import { setConfig } from "../../../__tests__/helpers/set-config.js";
4
+
5
+ let mockFastcrwSecureKey: string | undefined;
6
+
7
+ function seedWebFetch(provider: string, apiBase?: string): void {
8
+ const entry: Record<string, string> = { provider };
9
+ if (apiBase !== undefined) {
10
+ entry.apiBase = apiBase;
11
+ }
12
+ setConfig("services", { "web-fetch": entry });
13
+ }
14
+
15
+ mock.module("../../../security/secure-keys.js", () => ({
16
+ getProviderKeyAsync: async (provider: string) =>
17
+ provider === "fastcrw" ? mockFastcrwSecureKey : undefined,
18
+ }));
19
+
20
+ const realLogger = await import("../../../util/logger.js");
21
+ mock.module("../../../util/logger.js", () => ({
22
+ ...realLogger,
23
+ getLogger: () =>
24
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
25
+ }));
26
+
27
+ mock.module("../../../permissions/types.js", () => ({
28
+ RiskLevel: { Low: "low", Medium: "medium", High: "high" },
29
+ }));
30
+
31
+ let mockResolveAddresses: string[] = [];
32
+ const realUrlSafety = await import("../url-safety.js");
33
+ mock.module("../url-safety.js", () => ({
34
+ ...realUrlSafety,
35
+ resolveHostAddresses: async () => mockResolveAddresses,
36
+ }));
37
+
38
+ const { executeFastcrwScrape, webFetchTool } = await import("../web-fetch.js");
39
+
40
+ function scrapeResponse(body: unknown, status = 200): Response {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: { "content-type": "application/json" },
44
+ });
45
+ }
46
+
47
+ describe("executeFastcrwScrape", () => {
48
+ let originalFetch: typeof globalThis.fetch;
49
+
50
+ beforeEach(() => {
51
+ originalFetch = globalThis.fetch;
52
+ mockFastcrwSecureKey = undefined;
53
+ mockResolveAddresses = ["93.184.216.34"];
54
+ });
55
+ afterEach(() => {
56
+ globalThis.fetch = originalFetch;
57
+ });
58
+
59
+ test("hits the cloud default /v1/scrape endpoint", async () => {
60
+ let capturedUrl = "";
61
+ let capturedHeaders: any = null;
62
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
63
+ capturedUrl = url;
64
+ capturedHeaders = new Headers(init?.headers);
65
+ return scrapeResponse({
66
+ success: true,
67
+ data: {
68
+ markdown: "# Hello\n\nFrom fastCRW.",
69
+ metadata: {
70
+ title: "Example",
71
+ url: "https://example.com/",
72
+ statusCode: 200,
73
+ },
74
+ },
75
+ });
76
+ }) as typeof fetch;
77
+
78
+ const result = await executeFastcrwScrape(
79
+ { url: "https://example.com" },
80
+ { apiKey: "crw_live_test" },
81
+ );
82
+
83
+ expect(result.isError).toBe(false);
84
+ expect(capturedUrl).toBe("https://api.fastcrw.com/v1/scrape");
85
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer crw_live_test");
86
+ expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
87
+ expect(result.content).toContain("From fastCRW.");
88
+ });
89
+
90
+ test("uses a custom API base from config", async () => {
91
+ seedWebFetch("fastcrw", "http://localhost:3000/");
92
+ let capturedUrl = "";
93
+ globalThis.fetch = (async (url: string) => {
94
+ capturedUrl = url;
95
+ return scrapeResponse({
96
+ success: true,
97
+ data: { markdown: "local", metadata: { statusCode: 200 } },
98
+ });
99
+ }) as typeof fetch;
100
+
101
+ const result = await executeFastcrwScrape(
102
+ { url: "https://example.com" },
103
+ { apiKey: "" },
104
+ );
105
+
106
+ expect(result.isError).toBe(false);
107
+ expect(capturedUrl).toBe("http://localhost:3000/v1/scrape");
108
+ });
109
+ });
110
+
111
+ describe("webFetchTool fastCRW dispatch", () => {
112
+ let originalFetch: typeof globalThis.fetch;
113
+
114
+ const execute = (input: Record<string, unknown>, ctx: any = {}) =>
115
+ webFetchTool.execute(input, ctx);
116
+
117
+ beforeEach(() => {
118
+ originalFetch = globalThis.fetch;
119
+ mockFastcrwSecureKey = "crw_live_test";
120
+ mockResolveAddresses = ["93.184.216.34"];
121
+ });
122
+ afterEach(() => {
123
+ globalThis.fetch = originalFetch;
124
+ });
125
+
126
+ test("routes to fastCRW when provider=fastcrw and a key is set", async () => {
127
+ seedWebFetch("fastcrw");
128
+ let fastcrwHit = false;
129
+ globalThis.fetch = (async (url: string) => {
130
+ if (String(url).includes("api.fastcrw.com")) {
131
+ fastcrwHit = true;
132
+ }
133
+ return scrapeResponse({
134
+ success: true,
135
+ data: {
136
+ markdown: "ok",
137
+ metadata: { statusCode: 200, url: "https://example.com/" },
138
+ },
139
+ });
140
+ }) as typeof fetch;
141
+
142
+ const result = await execute({ url: "https://example.com" });
143
+ expect(fastcrwHit).toBe(true);
144
+ expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
145
+ });
146
+
147
+ test("routes to fastCRW without a key when a custom API base is set", async () => {
148
+ seedWebFetch("fastcrw", "http://127.0.0.1:3000");
149
+ mockFastcrwSecureKey = undefined;
150
+ let capturedUrl = "";
151
+ let capturedHeaders: any = null;
152
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
153
+ capturedUrl = String(url);
154
+ capturedHeaders = new Headers(init?.headers);
155
+ return scrapeResponse({
156
+ success: true,
157
+ data: {
158
+ markdown: "self-host",
159
+ metadata: { statusCode: 200, url: "https://example.com/" },
160
+ },
161
+ });
162
+ }) as typeof fetch;
163
+
164
+ const result = await execute({ url: "https://example.com" });
165
+ expect(capturedUrl).toBe("http://127.0.0.1:3000/v1/scrape");
166
+ expect(capturedHeaders?.get("authorization")).toBeNull();
167
+ expect(result.activityMetadata?.webFetch?.provider).toBe("fastcrw");
168
+ });
169
+ });
@@ -9,6 +9,7 @@ let mockPerplexitySecureKey: string | undefined;
9
9
  let mockTavilySecureKey: string | undefined;
10
10
  let mockFirecrawlSecureKey: string | undefined;
11
11
  let mockKeenableSecureKey: string | undefined;
12
+ let mockFastcrwSecureKey: string | undefined;
12
13
  let mockManagedSearchProxyResult: any;
13
14
  let mockManagedSearchAvailable = true;
14
15
  let mockManagedSearchProxyCalls: Array<{
@@ -21,9 +22,18 @@ let mockManagedSearchProxyCalls: Array<{
21
22
  * Seed the web-search service into the workspace config. Pass `undefined`
22
23
  * for mode to write a post-migration-132 config carrying only `provider`.
23
24
  */
24
- function seedWebSearch(mode: string | undefined, provider: string): void {
25
+ function seedWebSearch(
26
+ mode: string | undefined,
27
+ provider: string,
28
+ apiBase?: string,
29
+ ): void {
30
+ const entry: Record<string, string> =
31
+ mode === undefined ? { provider } : { mode, provider };
32
+ if (apiBase !== undefined) {
33
+ entry.apiBase = apiBase;
34
+ }
25
35
  setConfig("services", {
26
- "web-search": mode === undefined ? { provider } : { mode, provider },
36
+ "web-search": entry,
27
37
  });
28
38
  }
29
39
 
@@ -44,6 +54,9 @@ mock.module("../../../security/secure-keys.js", () => ({
44
54
  if (provider === "keenable") {
45
55
  return mockKeenableSecureKey;
46
56
  }
57
+ if (provider === "fastcrw") {
58
+ return mockFastcrwSecureKey;
59
+ }
47
60
  return undefined;
48
61
  },
49
62
  }));
@@ -87,6 +100,7 @@ describe("web_search tool", () => {
87
100
  mockTavilySecureKey = undefined;
88
101
  mockFirecrawlSecureKey = undefined;
89
102
  mockKeenableSecureKey = undefined;
103
+ mockFastcrwSecureKey = undefined;
90
104
  mockManagedSearchProxyCalls = [];
91
105
  mockManagedSearchAvailable = true;
92
106
  mockManagedSearchProxyResult = {
@@ -1047,6 +1061,87 @@ describe("web_search tool", () => {
1047
1061
  expect(callCount).toBe(4);
1048
1062
  });
1049
1063
 
1064
+ // ---- fastCRW provider ---------------------------------------------------
1065
+
1066
+ test("executes fastCRW search against the cloud default base", async () => {
1067
+ seedWebSearch("your-own", "fastcrw");
1068
+ mockFastcrwSecureKey = "crw_live_test";
1069
+ let capturedUrl = "";
1070
+ globalThis.fetch = (async (url: string) => {
1071
+ capturedUrl = url;
1072
+ return new Response(
1073
+ JSON.stringify({
1074
+ success: true,
1075
+ data: {
1076
+ web: [
1077
+ {
1078
+ title: "fastCRW Result",
1079
+ url: "https://example.com/crw",
1080
+ description: "From fastCRW",
1081
+ },
1082
+ ],
1083
+ },
1084
+ }),
1085
+ { status: 200, headers: { "content-type": "application/json" } },
1086
+ );
1087
+ }) as any;
1088
+
1089
+ const result = await execute({ query: "rust scraper" });
1090
+ expect(result.isError).toBe(false);
1091
+ expect(capturedUrl).toBe("https://api.fastcrw.com/v1/search");
1092
+ expect(result.content).toContain("fastCRW Result");
1093
+ expect(result.activityMetadata?.webSearch?.provider).toBe("fastcrw");
1094
+ });
1095
+
1096
+ test("fastCRW uses a custom API base and accepts flat data arrays", async () => {
1097
+ seedWebSearch("your-own", "fastcrw", "http://localhost:3000/");
1098
+ mockFastcrwSecureKey = "crw_live_test";
1099
+ let capturedUrl = "";
1100
+ globalThis.fetch = (async (url: string) => {
1101
+ capturedUrl = url;
1102
+ return new Response(
1103
+ JSON.stringify({
1104
+ success: true,
1105
+ data: [
1106
+ {
1107
+ title: "Flat Result",
1108
+ url: "https://example.com/flat",
1109
+ snippet: "Portable flat shape",
1110
+ },
1111
+ ],
1112
+ }),
1113
+ { status: 200, headers: { "content-type": "application/json" } },
1114
+ );
1115
+ }) as any;
1116
+
1117
+ const result = await execute({ query: "flat" });
1118
+ expect(result.isError).toBe(false);
1119
+ expect(capturedUrl).toBe("http://localhost:3000/v1/search");
1120
+ expect(result.content).toContain("Flat Result");
1121
+ expect(result.content).toContain("Portable flat shape");
1122
+ });
1123
+
1124
+ test("fastCRW self-host with custom base runs without an API key", async () => {
1125
+ seedWebSearch("your-own", "fastcrw", "http://127.0.0.1:3000");
1126
+ mockFastcrwSecureKey = undefined;
1127
+ let capturedHeaders: any = null;
1128
+ globalThis.fetch = (async (_url: string, init?: RequestInit) => {
1129
+ capturedHeaders = new Headers(init?.headers);
1130
+ return new Response(
1131
+ JSON.stringify({
1132
+ success: true,
1133
+ data: { results: [{ title: "Local", url: "https://local.test" }] },
1134
+ }),
1135
+ { status: 200, headers: { "content-type": "application/json" } },
1136
+ );
1137
+ }) as any;
1138
+
1139
+ const result = await execute({ query: "local" });
1140
+ expect(result.isError).toBe(false);
1141
+ expect(result.content).toContain("Local");
1142
+ expect(capturedHeaders?.get("authorization")).toBeNull();
1143
+ });
1144
+
1050
1145
  // ---- Provider fallback --------------------------------------------------
1051
1146
 
1052
1147
  test("falls back from perplexity to brave when perplexity has no key", async () => {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared helpers for Firecrawl-compatible scrape/search HTTP APIs
3
+ * (Firecrawl hosted `/v2/*` and fastCRW `/v1/*`).
4
+ */
5
+
6
+ export const FASTCRW_DEFAULT_API_BASE = "https://api.fastcrw.com";
7
+
8
+ /**
9
+ * Join an API origin with a path. Empty / whitespace `apiBase` uses
10
+ * `defaultBase`. Trailing slashes on the base are stripped.
11
+ */
12
+ export function resolveProviderApiUrl(
13
+ apiBase: string | undefined,
14
+ path: string,
15
+ defaultBase: string,
16
+ ): string {
17
+ const trimmed = apiBase?.trim() ?? "";
18
+ const origin = (trimmed.length > 0 ? trimmed : defaultBase).replace(
19
+ /\/+$/,
20
+ "",
21
+ );
22
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
23
+ return `${origin}${normalizedPath}`;
24
+ }
25
+
26
+ export interface FirecrawlCompatSearchResult {
27
+ title?: string;
28
+ description?: string;
29
+ snippet?: string;
30
+ url?: string;
31
+ category?: string;
32
+ }
33
+
34
+ /**
35
+ * Portable read of Firecrawl / fastCRW search payloads:
36
+ * - grouped: `data.web` (Firecrawl `/v2/search`, hosted CRW with sources)
37
+ * - self-hosted with sources: `data.results.web` (object keyed by source)
38
+ * - self-hosted flat: `data.results` (array)
39
+ * - hosted flat: `data` is the array itself
40
+ */
41
+ export function extractFirecrawlCompatSearchResults(
42
+ data: unknown,
43
+ ): FirecrawlCompatSearchResult[] {
44
+ if (!data || typeof data !== "object") {
45
+ return [];
46
+ }
47
+ const root = data as Record<string, unknown>;
48
+ const payload = root.data;
49
+
50
+ if (Array.isArray(payload)) {
51
+ return payload as FirecrawlCompatSearchResult[];
52
+ }
53
+
54
+ if (payload && typeof payload === "object") {
55
+ const nested = payload as Record<string, unknown>;
56
+ if (Array.isArray(nested.web)) {
57
+ return nested.web as FirecrawlCompatSearchResult[];
58
+ }
59
+ // Self-hosted CRW with sources:["web"] nests rows under data.results.web
60
+ // (an object keyed by source), not data.results as a flat array.
61
+ if (
62
+ nested.results &&
63
+ typeof nested.results === "object" &&
64
+ !Array.isArray(nested.results)
65
+ ) {
66
+ const resultsBySource = nested.results as Record<string, unknown>;
67
+ if (Array.isArray(resultsBySource.web)) {
68
+ return resultsBySource.web as FirecrawlCompatSearchResult[];
69
+ }
70
+ const firstSourceKey = Object.keys(resultsBySource).find((key) =>
71
+ Array.isArray(resultsBySource[key]),
72
+ );
73
+ if (firstSourceKey) {
74
+ return resultsBySource[firstSourceKey] as FirecrawlCompatSearchResult[];
75
+ }
76
+ }
77
+ if (Array.isArray(nested.results)) {
78
+ return nested.results as FirecrawlCompatSearchResult[];
79
+ }
80
+ }
81
+
82
+ return [];
83
+ }
84
+
85
+ export function searchResultSnippet(
86
+ result: FirecrawlCompatSearchResult,
87
+ ): string | undefined {
88
+ const text = result.description || result.snippet;
89
+ return text?.trim() || undefined;
90
+ }