@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { credentialTestRule, describeCredentialTestMismatch, } from "./credentia
2
2
  import { Registry } from "./registry.js";
3
3
  import { parseConnectorAccess, POOL_NAME_RE } from "./connector-access.js";
4
4
  import { createFetchHandler } from "./server.js";
5
- import { droppedBrandingUrls, droppedUiAuthUrls } from "./branding.js";
5
+ import { droppedBrandingUrls, droppedThemeTokens, droppedUiAuthUrls, } from "./branding.js";
6
6
  import { memoryStorage } from "./storage/memory.js";
7
7
  import { CONNECTA_VERSION } from "./version.js";
8
8
  import { AdmissionController, executorName, isAdmittingExecutor, withExecutorAdmission, } from "./executor-admission.js";
@@ -60,6 +60,7 @@ const CONFIG_SCHEMA = {
60
60
  pools: null,
61
61
  storage: null,
62
62
  publicUrl: null,
63
+ allowedOrigins: null,
63
64
  activity: null,
64
65
  vault: null,
65
66
  ui: null,
@@ -70,6 +71,10 @@ const CONFIG_SCHEMA = {
70
71
  staleCatalogSeconds: null,
71
72
  probeTimeoutMs: null,
72
73
  },
74
+ results: {
75
+ maxStashBytes: null,
76
+ maxStashEntries: null,
77
+ },
73
78
  calls: {
74
79
  defaultTimeoutMs: null,
75
80
  maxResultBytes: null,
@@ -135,6 +140,17 @@ function rejectUnknownOptions(paths) {
135
140
  /** Reject JavaScript typos and removed options before construction does work. */
136
141
  function assertKnownConfig(config) {
137
142
  rejectUnknownOptions(unknownOptionPaths(config, "ConnectaConfig", CONFIG_SCHEMA));
143
+ if (config.results !== undefined) {
144
+ if (!config.results || typeof config.results !== "object" || Array.isArray(config.results)) {
145
+ throw new Error("ConnectaConfig.results must be an object");
146
+ }
147
+ for (const key of ["maxStashBytes", "maxStashEntries"]) {
148
+ const value = config.results[key];
149
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
150
+ throw new Error(`ConnectaConfig.results.${key} must be a non-negative safe integer`);
151
+ }
152
+ }
153
+ }
138
154
  if (config.vault && ["get", "getAll", "set", "setAll", "metadata", "delete"].some(key => typeof config.vault[key] !== "function"))
139
155
  throw new Error("ConnectaConfig.vault must implement CredentialVault");
140
156
  if (config.ui && (typeof config.ui.handle !== "function" || typeof config.ui.credentialHandoffUrl !== "function" || !Array.isArray(config.ui.reservedPaths)))
@@ -217,12 +233,15 @@ function resolvePools(pools, registry) {
217
233
  function warnInsecureConfig(config, inboundAuth, logger) {
218
234
  const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
219
235
  const hasCredentialConnector = config.connectors.some((c) => c.credential);
220
- // Open mode (no inbound auth) with connectors that expose credentials or
221
- // downstream OAuth: any caller reaches everything, including the vault.
236
+ // Static API headers can carry secrets without declaring credential hooks.
237
+ // Any configured connector warrants the open-deployment warning.
222
238
  if (inboundAuth.length === 0 &&
223
- (hasCredentialConnector || oauthConnectors.length > 0)) {
239
+ config.connectors.length > 0) {
224
240
  logger.warn("[connecta] running with no inbound authentication: any caller can " +
225
241
  "invoke every shared connector. " +
242
+ (hasCredentialConnector || oauthConnectors.length > 0
243
+ ? "Configured credentials and downstream OAuth grants are exposed to those calls. "
244
+ : "") +
226
245
  "Configure `auth` (for example bearerToken(...) or Clerk) to gate access.");
227
246
  }
228
247
  // Unset publicUrl with OAuth connectors: the downstream redirect_uri is
@@ -243,6 +262,17 @@ function warnInsecureConfig(config, inboundAuth, logger) {
243
262
  "used as an href, so it must be an absolute http(s) URL (favicon.href " +
244
263
  "may also be a root-relative path). The default is rendered instead.");
245
264
  }
265
+ // Theme tokens are written into a `:root` block, so each is gated
266
+ // syntactically and a rejected value takes the stylesheet's default. Same
267
+ // reason as the branding URLs above: the page still renders, so without this
268
+ // line the only evidence is that the operator's color never showed up.
269
+ const droppedTheme = droppedThemeTokens(config.ui?.branding?.theme);
270
+ if (droppedTheme.length > 0) {
271
+ logger.warn(`[connecta] branding ${droppedTheme.join(", ")} dropped: accent must be ` +
272
+ "a hex color, radius a CSS length, the font families a plain " +
273
+ "font-family list, and colorScheme one of system/light/dark. The " +
274
+ "default is rendered instead.");
275
+ }
246
276
  // Operator shells render exactly one provider's browser sign-in config — the
247
277
  // first that offers one, matching the server route's `find` — and that
248
278
  // provider's URLs reach the browser: frontendApiUrl as the loader's
@@ -330,6 +360,7 @@ export function createConnecta(config) {
330
360
  persistToolCatalog: config.discovery?.persistCatalog,
331
361
  toolCatalogStaleSeconds: config.discovery?.staleCatalogSeconds,
332
362
  maxResultBytes: config.calls?.maxResultBytes,
363
+ results: config.results,
333
364
  });
334
365
  const inboundAuth = configuredAuth;
335
366
  const pools = resolvePools(config.pools, registry);
@@ -356,6 +387,7 @@ export function createConnecta(config) {
356
387
  identity: config.identity,
357
388
  pools,
358
389
  publicUrl: config.publicUrl,
390
+ allowedOrigins: config.allowedOrigins,
359
391
  serverInfo,
360
392
  logger,
361
393
  activity: config.activity?.store,
@@ -1,5 +1,5 @@
1
1
  import { isCallAdmissionError } from "./call-admission.js";
2
- import { classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
2
+ import { boundedEchoText, classifyCallError, ConnectorCallError, echoedCallArgs, framingError, } from "./errors.js";
3
3
  import { unwrapMcpResult } from "./mcp-result.js";
4
4
  import { splitAddress } from "./registry.js";
5
5
  import { isExplicitlyReadOnly } from "./tool-safety.js";
@@ -43,10 +43,10 @@ function assertRawMcpSuccess(kind, result) {
43
43
  const mcpResult = result;
44
44
  if (!mcpResult.isError)
45
45
  return;
46
- throw new Error(mcpResult.content
46
+ throw new Error(boundedEchoText(mcpResult.content
47
47
  ?.filter((block) => block.type === "text")
48
48
  .map((block) => block.text ?? "")
49
- .join("") || "Downstream tool call failed");
49
+ .join("") || "Downstream tool call failed"));
50
50
  }
51
51
  export class InvocationFailure extends Error {
52
52
  details;
@@ -196,6 +196,8 @@ export class InvocationService {
196
196
  tool: target.toolName,
197
197
  source: context.source,
198
198
  code: details.code,
199
+ // Sanitized transport diagnostics (origin and errno only, #539).
200
+ ...(details.details ? { details: details.details } : {}),
199
201
  attempts,
200
202
  durationMs: Date.now() - started,
201
203
  message: String(details.message ?? "").slice(0, 300),
@@ -215,111 +217,150 @@ export class InvocationService {
215
217
  error: details,
216
218
  };
217
219
  };
218
- const resolution = await this.catalog.resolveTool(address, defined({ signal: context.requestSignal }));
219
- catalogMs += resolution.catalogMs;
220
- if (!resolution.ok) {
221
- if (resolution.connector && resolution.toolName) {
222
- activityTarget = {
223
- connector: resolution.connector,
224
- toolName: resolution.toolName,
225
- };
226
- }
227
- if (resolution.cause && context.requestSignal?.aborted) {
228
- return failed(callerCancelledDetails());
229
- }
230
- return failed(resolution.error);
231
- }
232
- resolved = resolution.resolved;
233
- activityTarget = resolved;
234
- if (!isExplicitlyReadOnly(resolved.definition) && !context.allowDestructive) {
235
- const canonicalAddress = `${resolved.connector.id}.${resolved.toolName}`;
236
- return failed(framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`));
237
- }
238
- // Remote MCP tools advertise their input schema in the catalog. Validate
239
- // against that same request-local definition before admission or provider
240
- // dispatch, so a predictable mismatch stays structured instead of being
241
- // flattened into provider-specific error prose. Unsupported schemas retain
242
- // validateToolInput's fail-open behavior and reach the downstream normally.
243
- if (resolved.connector.kind === "mcp" &&
244
- resolved.definition.inputSchema) {
245
- const invalid = validateToolInput(resolved.definition.inputSchema, args ?? {}, {
246
- address: `${resolved.connector.id}.${resolved.toolName}`,
247
- logger: this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
248
- });
249
- if (invalid)
250
- return failed(classifyCallError(invalid));
251
- }
252
- try {
253
- context.beforeDispatch?.();
254
- }
255
- catch (error) {
256
- return failed(error instanceof InvocationFailure
257
- ? error.details
258
- : classifyCallError(error));
220
+ if (context.requestSignal?.aborted) {
221
+ // Preserve the cancelled admission-attempt count without starting discovery.
222
+ attempts = 1;
223
+ return failed(callerCancelledDetails());
259
224
  }
260
225
  let result;
261
226
  let observedResult;
262
- attempts = 1;
263
- let permit;
264
- let attemptError;
265
- let attemptFailed = false;
227
+ // One deadline owns discovery, queue admission, and provider dispatch.
228
+ // Only the caller of this function records the final outcome, so a late
229
+ // cancellation cannot append a second activity event after the deadline.
230
+ const dispatch = async (callSignal) => {
231
+ const resolution = await this.catalog.resolveTool(address, defined({ signal: callSignal }));
232
+ catalogMs += resolution.catalogMs;
233
+ if (callSignal?.aborted)
234
+ throw callSignal.reason;
235
+ if (!resolution.ok) {
236
+ if (resolution.connector && resolution.toolName) {
237
+ activityTarget = {
238
+ connector: resolution.connector,
239
+ toolName: resolution.toolName,
240
+ };
241
+ }
242
+ if (resolution.cause && context.requestSignal?.aborted) {
243
+ return callerCancelledDetails();
244
+ }
245
+ return resolution.error;
246
+ }
247
+ const target = resolution.resolved;
248
+ resolved = target;
249
+ activityTarget = target;
250
+ if (!isExplicitlyReadOnly(target.definition) && !context.allowDestructive) {
251
+ const canonicalAddress = `${target.connector.id}.${target.toolName}`;
252
+ return framingError("destructive_tool_requires_approval", `Tool "${canonicalAddress}" is not explicitly read-only. Invoke it through call_destructive_tool so the MCP host can request explicit approval.`);
253
+ }
254
+ // Remote MCP tools advertise their input schema in the catalog. Validate
255
+ // against that same request-local definition before admission or provider
256
+ // dispatch, so a predictable mismatch stays structured instead of being
257
+ // flattened into provider-specific error prose. Unsupported schemas retain
258
+ // validateToolInput's fail-open behavior and reach the downstream normally.
259
+ if (target.connector.kind === "mcp" &&
260
+ target.definition.inputSchema) {
261
+ const invalid = validateToolInput(target.definition.inputSchema, args ?? {}, {
262
+ address: `${target.connector.id}.${target.toolName}`,
263
+ logger: this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope).logger,
264
+ });
265
+ if (invalid)
266
+ return classifyCallError(invalid);
267
+ }
268
+ try {
269
+ context.beforeDispatch?.();
270
+ }
271
+ catch (error) {
272
+ return error instanceof InvocationFailure
273
+ ? error.details
274
+ : classifyCallError(error);
275
+ }
276
+ if (callSignal?.aborted)
277
+ throw callSignal.reason;
278
+ attempts = 1;
279
+ let permit;
280
+ let attemptError;
281
+ let attemptFailed = false;
282
+ try {
283
+ permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(target.connector.id, {
284
+ toolName: target.toolName,
285
+ args: args ?? {},
286
+ ...defined({ signal: callSignal }),
287
+ }));
288
+ const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
289
+ const call = () => {
290
+ const connectorContext = this.registry.contextFor(target.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
291
+ if (target.connector.credential &&
292
+ !connectorContext.credential) {
293
+ throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
294
+ `authorize_connector({ connector: "${target.connector.id}" }).`);
295
+ }
296
+ // Cancellation can arrive during admission or context construction.
297
+ if (callSignal?.aborted)
298
+ throw callSignal.reason;
299
+ return target.connector.callTool(target.toolName, args ?? {}, connectorContext);
300
+ };
301
+ // Race cancellation here too, so an uncooperative connector cannot
302
+ // retain its admission permit after the enclosing deadline expires.
303
+ return callSignal
304
+ ? withDeadline(call, {
305
+ signal: callSignal,
306
+ timeoutError: new ConnectorCallError("timeout", "Tool call timed out"),
307
+ })
308
+ : call();
309
+ });
310
+ // isError is checked here for BOTH result shapes so every adapter
311
+ // reports the same downstream-failure wording, and the throw lands
312
+ // inside the attempt where it feeds health.
313
+ assertRawMcpSuccess(target.connector.kind, raw);
314
+ observedResult = unwrapMcpResult(target.connector.kind, raw);
315
+ result = context.unwrapResult ? observedResult : raw;
316
+ }
317
+ catch (error) {
318
+ attemptFailed = true;
319
+ attemptError = error;
320
+ }
321
+ finally {
322
+ permit?.release();
323
+ }
324
+ if (attemptFailed) {
325
+ if (callSignal?.aborted)
326
+ throw callSignal.reason;
327
+ const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
328
+ const details = callerCancelled
329
+ ? callerCancelledDetails()
330
+ : classifyCallError(attemptError);
331
+ return details;
332
+ }
333
+ return undefined;
334
+ };
266
335
  try {
267
- permit = await timed((elapsed) => { admissionMs += elapsed; }, () => this.registry.admitCall(resolved.connector.id, {
268
- toolName: resolved.toolName,
269
- args: args ?? {},
270
- ...defined({ signal: context.requestSignal }),
271
- }));
272
- const raw = await timed((elapsed) => { connectorMs += elapsed; }, () => {
273
- const call = (callSignal) => {
274
- const connectorContext = this.registry.contextFor(resolved.connector.id, this.catalog.baseUrl, this.catalog.requestScope, defined({ signal: callSignal, timeoutMs: context.timeoutMs }));
275
- if (resolved.connector.credential &&
276
- !connectorContext.credential) {
277
- throw new ConnectorCallError("auth_required", "Operator-managed credential storage is not configured. Call " +
278
- `authorize_connector({ connector: "${resolved.connector.id}" }).`);
279
- }
280
- // Cancellation can arrive during admission or context construction.
281
- if (callSignal?.aborted)
282
- throw callSignal.reason;
283
- return resolved.connector.callTool(resolved.toolName, args ?? {}, connectorContext);
284
- };
285
- if (!context.timeoutMs && !context.requestSignal)
286
- return call();
287
- return withDeadline(call, {
336
+ const error = context.timeoutMs || context.requestSignal
337
+ ? await withDeadline(dispatch, {
288
338
  ...defined({
289
339
  timeoutMs: context.timeoutMs,
290
340
  signal: context.requestSignal,
291
341
  }),
292
342
  timeoutError: new ConnectorCallError("timeout", `Tool call timed out after ${context.timeoutMs}ms`),
293
- });
294
- });
295
- // isError is checked here for BOTH result shapes so every adapter
296
- // reports the same downstream-failure wording, and the throw lands
297
- // inside the attempt where it feeds health.
298
- assertRawMcpSuccess(resolved.connector.kind, raw);
299
- observedResult = unwrapMcpResult(resolved.connector.kind, raw);
300
- result = context.unwrapResult ? observedResult : raw;
343
+ })
344
+ : await dispatch();
345
+ if (error)
346
+ return failed(error);
301
347
  }
302
348
  catch (error) {
303
- attemptFailed = true;
304
- attemptError = error;
305
- }
306
- finally {
307
- permit?.release();
308
- }
309
- if (attemptFailed) {
310
- const callerCancelled = isCallerCancellation(attemptError, context.requestSignal);
311
- const details = callerCancelled
349
+ return failed(context.requestSignal?.aborted
312
350
  ? callerCancelledDetails()
313
- : classifyCallError(attemptError);
314
- return failed(details);
351
+ : classifyCallError(error));
315
352
  }
353
+ // A dispatch that returned no refusal resolved a concrete tool.
354
+ const completed = resolved;
355
+ if (!completed)
356
+ throw new Error("Invocation completed without a resolved tool");
316
357
  try {
317
358
  const value = await timed((elapsed) => { resultProcessingMs += elapsed; }, async () => {
318
359
  const processed = context.processResult
319
- ? await context.processResult(result, resolved)
360
+ ? await context.processResult(result, completed)
320
361
  : result;
321
362
  try {
322
- this.registry.observeOutputShape(resolved.connector.id, resolved.definition, observedResult);
363
+ this.registry.observeOutputShape(completed.connector.id, completed.definition, observedResult);
323
364
  }
324
365
  catch {
325
366
  // Shape learning is advisory. It cannot change a completed call.
@@ -332,14 +373,14 @@ export class InvocationService {
332
373
  return {
333
374
  ok: true,
334
375
  value,
335
- resolved,
376
+ resolved: completed,
336
377
  durationMs: Date.now() - started,
337
378
  attempts,
338
379
  timing: diagnostics,
339
380
  };
340
381
  }
341
- catch (error) {
342
- return failed(framingError("result_processing_failed", error instanceof Error ? error.message : String(error)));
382
+ catch {
383
+ return failed(framingError("result_processing_failed", "The downstream call completed, but its result could not be processed. Do not repeat the call to recover its result."));
343
384
  }
344
385
  }
345
386
  }
@@ -1,3 +1,4 @@
1
+ import { boundedEchoText } from "./errors.js";
1
2
  /**
2
3
  * Unwrap an MCP CallToolResult into an ordinary JavaScript value:
3
4
  * `toolResult` wins when present, then `structuredContent`; all-text content is
@@ -17,9 +18,9 @@ export function unwrapMcpResult(kind, result) {
17
18
  .filter((c) => c.type === "text")
18
19
  .map((c) => c.text ?? "")
19
20
  .join("\n");
20
- throw new Error(text || "Tool call failed");
21
+ throw new Error(boundedEchoText(text || "Tool call failed"));
21
22
  }
22
- if (r.structuredContent != null)
23
+ if (r.structuredContent !== undefined)
23
24
  return r.structuredContent;
24
25
  if (content.length > 0 && content.every((c) => c.type === "text")) {
25
26
  const text = content.map((c) => c.text ?? "").join("\n");