@vellumai/assistant 0.8.3 → 0.8.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.
- package/docker-entrypoint.sh +0 -1
- package/node_modules/@vellumai/gateway-client/src/types.ts +2 -0
- package/openapi.yaml +610 -16
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +4 -5
- package/src/__tests__/agent-loop-override-profile.test.ts +1 -1
- package/src/__tests__/agent-loop.test.ts +88 -3
- package/src/__tests__/anthropic-provider.test.ts +272 -0
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
- package/src/__tests__/channel-delivery-store.test.ts +193 -0
- package/src/__tests__/channel-reply-delivery.test.ts +284 -5
- package/src/__tests__/channel-retry-sweep.test.ts +274 -1
- package/src/__tests__/compaction-events.test.ts +1 -1
- package/src/__tests__/compactor-preserved-tail-count.test.ts +110 -0
- package/src/__tests__/config-watcher.test.ts +1 -1
- package/src/__tests__/context-token-estimator.test.ts +91 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +54 -3
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +31 -6
- package/src/__tests__/conversation-agent-loop.test.ts +25 -7
- package/src/__tests__/conversation-app-control-lifecycle.test.ts +1 -1
- package/src/__tests__/conversation-clean-command.test.ts +137 -0
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-fork-crud.test.ts +161 -0
- package/src/__tests__/conversation-lifecycle.test.ts +1 -1
- package/src/__tests__/conversation-load-cleaned-at.test.ts +279 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +1 -1
- package/src/__tests__/conversation-pairing.test.ts +2 -2
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +264 -81
- package/src/__tests__/conversation-seed-composer.test.ts +66 -4
- package/src/__tests__/conversation-slash-commands.test.ts +36 -8
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +220 -0
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +5 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +5 -1
- package/src/__tests__/credential-security-invariants.test.ts +6 -0
- package/src/__tests__/cu-unified-flow.test.ts +10 -1
- package/src/__tests__/dm-backfill.test.ts +64 -0
- package/src/__tests__/dm-persistence.test.ts +33 -0
- package/src/__tests__/document-find-replace.test.ts +501 -0
- package/src/__tests__/first-greeting.test.ts +23 -2
- package/src/__tests__/headless-browser-navigate.test.ts +172 -0
- package/src/__tests__/host-bash-proxy.test.ts +6 -0
- package/src/__tests__/host-browser-proxy.test.ts +10 -0
- package/src/__tests__/host-cu-proxy.test.ts +8 -1
- package/src/__tests__/host-file-proxy.test.ts +8 -1
- package/src/__tests__/host-transfer-proxy.test.ts +8 -1
- package/src/__tests__/identity-routes.test.ts +57 -0
- package/src/__tests__/inbound-slack-persistence.test.ts +3 -0
- package/src/__tests__/injector-chain.test.ts +2 -0
- package/src/__tests__/injector-document-comments.test.ts +378 -0
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +4 -25
- package/src/__tests__/list-messages-attachments.test.ts +21 -17
- package/src/__tests__/list-messages-hidden-metadata.test.ts +217 -0
- package/src/__tests__/list-messages-page-latest.test.ts +130 -14
- package/src/__tests__/list-messages-tool-merge.test.ts +17 -16
- package/src/__tests__/llm-context-normalization.test.ts +0 -2
- package/src/__tests__/llm-resolver.test.ts +85 -1
- package/src/__tests__/log-export-routes.test.ts +99 -2
- package/src/__tests__/message-queue-steer.test.ts +114 -0
- package/src/__tests__/openai-provider.test.ts +105 -0
- package/src/__tests__/openai-responses-provider.test.ts +4 -4
- package/src/__tests__/outbound-slack-persistence.test.ts +187 -20
- package/src/__tests__/pending-interactions-resolved-event.test.ts +190 -0
- package/src/__tests__/platform.test.ts +0 -3
- package/src/__tests__/plugin-source-watcher.test.ts +302 -0
- package/src/__tests__/process-message-background-slack.test.ts +1 -51
- package/src/__tests__/process-message-display-content.test.ts +21 -16
- package/src/__tests__/server-history-render.test.ts +83 -4
- package/src/__tests__/steer-tool-repair.test.ts +249 -0
- package/src/__tests__/system-prompt.test.ts +51 -28
- package/src/__tests__/terminal-tools.test.ts +11 -1
- package/src/__tests__/thinking-block-replay.test.ts +113 -0
- package/src/__tests__/thread-backfill.test.ts +370 -22
- package/src/__tests__/tool-executor.test.ts +90 -1
- package/src/__tests__/tool-result-metadata-plumbing.test.ts +167 -0
- package/src/__tests__/twilio-routes.test.ts +1 -1
- package/src/__tests__/web-fetch.test.ts +2 -2
- package/src/__tests__/workspace-git-service.test.ts +88 -5
- package/src/__tests__/workspace-migration-088-deprecate-background-conversation-override.test.ts +158 -0
- package/src/agent/attachments.ts +1 -0
- package/src/agent/loop.ts +57 -20
- package/src/background-wake/next-wake.test.ts +289 -0
- package/src/background-wake/next-wake.ts +172 -0
- package/src/browser/operations.ts +15 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +572 -0
- package/src/cli/commands/__tests__/memory-v2.test.ts +9 -12
- package/src/cli/commands/conversations.ts +128 -1
- package/src/cli/commands/inference-providers.ts +147 -1
- package/src/cli/commands/memory-v2.ts +308 -0
- package/src/cli/commands/notifications.ts +24 -2
- package/src/cli/utils/conversation-id.ts +17 -5
- package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/SKILL.md +115 -0
- package/src/config/bundled-skills/document-editor/TOOLS.json +240 -0
- package/src/config/bundled-skills/document-editor/tools/comment-list.ts +12 -0
- package/src/config/bundled-skills/document-editor/tools/comment-reply.ts +12 -0
- package/src/config/bundled-skills/document-editor/tools/comment-resolve.ts +12 -0
- package/src/config/bundled-skills/document-editor/tools/document-find.ts +12 -0
- package/src/config/bundled-skills/document-editor/tools/document-replace-text.ts +12 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +8 -0
- package/src/config/bundled-skills/schedule/SKILL.md +8 -0
- package/src/config/bundled-tool-registry.ts +22 -12
- package/src/config/call-site-defaults.ts +19 -0
- package/src/config/feature-flag-registry.json +99 -3
- package/src/config/llm-resolver.ts +16 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +4 -0
- package/src/config/schemas/call-site-catalog.ts +21 -0
- package/src/config/schemas/llm.ts +3 -0
- package/src/config/schemas/memory-v2.ts +48 -1
- package/src/context/compactor.ts +8 -1
- package/src/context/token-estimator.ts +47 -4
- package/src/context/window-manager.ts +25 -0
- package/src/credential-health/credential-health-service.ts +34 -19
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +66 -6
- package/src/daemon/__tests__/native-web-search-metadata.test.ts +357 -0
- package/src/daemon/__tests__/web-search-status-text.test.ts +287 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +153 -23
- package/src/daemon/conversation-agent-loop.ts +223 -54
- package/src/daemon/conversation-lifecycle.ts +142 -116
- package/src/daemon/conversation-messaging.ts +3 -0
- package/src/daemon/conversation-process.ts +273 -0
- package/src/daemon/conversation-queue-manager.ts +14 -0
- package/src/daemon/conversation-runtime-assembly.ts +135 -75
- package/src/daemon/conversation-slash.ts +37 -5
- package/src/daemon/conversation-surfaces.ts +45 -2
- package/src/daemon/conversation-tool-setup.ts +7 -0
- package/src/daemon/conversation.ts +42 -5
- package/src/daemon/first-greeting.ts +10 -0
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +498 -0
- package/src/daemon/handlers/config-a2a.ts +160 -0
- package/src/daemon/handlers/config-model.test.ts +1 -0
- package/src/daemon/handlers/conversations.ts +79 -0
- package/src/daemon/handlers/shared.ts +92 -29
- package/src/daemon/host-bash-proxy.ts +1 -1
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/daemon/host-file-proxy.ts +1 -1
- package/src/daemon/host-transfer-proxy.ts +1 -1
- package/src/daemon/lifecycle.ts +18 -4
- package/src/daemon/message-protocol.ts +4 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/document-comments.ts +50 -0
- package/src/daemon/message-types/messages.ts +68 -1
- package/src/daemon/message-types/surfaces.ts +3 -1
- package/src/daemon/message-types/web-activity.ts +57 -0
- package/src/daemon/plugin-source-watcher.ts +135 -3
- package/src/daemon/process-message.ts +69 -12
- package/src/daemon/query-complexity-router.ts +75 -0
- package/src/daemon/trust-context.ts +6 -0
- package/src/documents/document-comments-store.test.ts +338 -0
- package/src/documents/document-comments-store.ts +237 -0
- package/src/documents/document-store.ts +202 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
- package/src/heartbeat/heartbeat-service.ts +1 -0
- package/src/home/__tests__/suggested-prompts.test.ts +33 -2
- package/src/home/feed-types.ts +6 -1
- package/src/home/home-content-refresh.ts +52 -0
- package/src/home/home-greeting-cache.ts +69 -0
- package/src/home/home-greeting.ts +94 -0
- package/src/home/suggested-prompts.ts +177 -9
- package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +135 -2
- package/src/memory/__tests__/memory-retrospective-job.test.ts +320 -6
- package/src/memory/conversation-crud.ts +133 -43
- package/src/memory/db-init.ts +16 -0
- package/src/memory/delivery-crud.ts +41 -0
- package/src/memory/delivery-status.ts +141 -15
- package/src/memory/external-conversation-store.ts +32 -1
- package/src/memory/jobs-worker.ts +21 -1
- package/src/memory/memory-retrospective-constants.ts +28 -0
- package/src/memory/memory-retrospective-enqueue.ts +3 -2
- package/src/memory/memory-retrospective-job.ts +408 -18
- package/src/memory/memory-retrospective-startup-cleanup.ts +3 -3
- package/src/memory/memory-v2-activation-log-store.ts +26 -8
- package/src/memory/migrations/100-core-tables.ts +1 -0
- package/src/memory/migrations/109-external-conversation-bindings.ts +1 -0
- package/src/memory/migrations/253-conversation-last-notified-profile.ts +15 -0
- package/src/memory/migrations/253-document-comments.ts +47 -0
- package/src/memory/migrations/254-external-conversation-binding-chat-name.ts +43 -0
- package/src/memory/migrations/255-channel-inbound-delivery-attempts.ts +24 -0
- package/src/memory/migrations/256-memory-v2-injection-events.ts +113 -0
- package/src/memory/migrations/257-strip-base-url-non-openai-compatible.ts +22 -0
- package/src/memory/migrations/258-onboarding-events-prior-assistants.ts +13 -0
- package/src/memory/migrations/259-conversation-cleaned-at.ts +33 -0
- package/src/memory/migrations/index.ts +17 -0
- package/src/memory/migrations/registry.ts +25 -0
- package/src/memory/onboarding-events-store.ts +7 -0
- package/src/memory/schema/calls.ts +1 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/v2/__tests__/injection-events.test.ts +318 -0
- package/src/memory/v2/__tests__/injection.test.ts +31 -14
- package/src/memory/v2/__tests__/page-index.test.ts +365 -1
- package/src/memory/v2/__tests__/router.test.ts +489 -1
- package/src/memory/v2/consolidation-job.ts +14 -0
- package/src/memory/v2/injection-events.ts +101 -0
- package/src/memory/v2/injection.ts +21 -10
- package/src/memory/v2/page-index.ts +209 -7
- package/src/memory/v2/page-store.ts +18 -0
- package/src/memory/v2/router.ts +209 -55
- package/src/messaging/providers/index.ts +7 -1
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +329 -3
- package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +34 -1
- package/src/messaging/providers/slack/adapter.ts +178 -25
- package/src/messaging/providers/slack/api.test.ts +54 -0
- package/src/messaging/providers/slack/api.ts +119 -3
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/deep-link.ts +20 -1
- package/src/messaging/providers/slack/message-metadata.test.ts +48 -0
- package/src/messaging/providers/slack/message-metadata.ts +156 -0
- package/src/messaging/providers/slack/render-transcript.test.ts +107 -75
- package/src/messaging/providers/slack/render-transcript.ts +176 -49
- package/src/messaging/providers/slack/send.test.ts +77 -0
- package/src/messaging/providers/slack/send.ts +8 -2
- package/src/messaging/providers/slack/types.ts +14 -0
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +4 -1
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +116 -54
- package/src/notifications/conversation-seed-composer.ts +14 -2
- package/src/notifications/deferred-emit.ts +135 -0
- package/src/notifications/emit-signal.ts +9 -1
- package/src/notifications/home-feed-side-effect.ts +60 -30
- package/src/oauth/connect-orchestrator.ts +3 -0
- package/src/oauth/credential-token-resolver.ts +2 -0
- package/src/oauth/manual-token-connection.ts +19 -0
- package/src/oauth/oauth-store.ts +12 -0
- package/src/oauth/seed-providers.ts +22 -0
- package/src/permissions/prompter.ts +5 -2
- package/src/permissions/secret-prompter.ts +4 -1
- package/src/plugins/defaults/injectors.ts +82 -9
- package/src/prompts/__tests__/system-prompt.test.ts +46 -2
- package/src/prompts/normalize-onboarding.ts +40 -0
- package/src/prompts/sections.ts +32 -14
- package/src/prompts/system-prompt.ts +105 -68
- package/src/prompts/template-detection.ts +37 -0
- package/src/prompts/templates/BOOTSTRAP-CONTENT-AUTOMATION.md +141 -0
- package/src/prompts/templates/BOOTSTRAP.md +8 -0
- package/src/prompts/templates/VOICE.md +3 -0
- package/src/prompts/templates/system-sections.ts +53 -3
- package/src/providers/anthropic/client.ts +132 -5
- package/src/providers/fireworks/client.ts +20 -2
- package/src/providers/inference/__tests__/base-url-route-validation.test.ts +342 -0
- package/src/providers/inference/__tests__/base-url-security.test.ts +189 -0
- package/src/providers/inference/__tests__/codex-token-refresh.test.ts +254 -0
- package/src/providers/inference/adapter-factory.ts +15 -1
- package/src/providers/inference/auth.ts +3 -3
- package/src/providers/inference/codex-token-refresh.ts +128 -0
- package/src/providers/inference/resolve-auth.ts +49 -6
- package/src/providers/model-catalog.ts +48 -1
- package/src/providers/openai/chat-completions-provider.ts +57 -20
- package/src/providers/openai/responses-provider.ts +9 -3
- package/src/providers/openrouter/client.ts +5 -1
- package/src/providers/types.ts +25 -0
- package/src/runtime/__tests__/agent-wake.test.ts +214 -0
- package/src/runtime/__tests__/background-job-runner.test.ts +128 -0
- package/src/runtime/agent-wake.ts +151 -56
- package/src/runtime/auth/route-policy.ts +7 -3
- package/src/runtime/background-job-runner.ts +26 -0
- package/src/runtime/channel-reply-delivery.ts +182 -47
- package/src/runtime/channel-retry-sweep.ts +141 -16
- package/src/runtime/http-types.ts +7 -4
- package/src/runtime/pending-interactions.ts +51 -8
- package/src/runtime/routes/__tests__/content-source-routes.test.ts +162 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +55 -1
- package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +14 -0
- package/src/runtime/routes/__tests__/memory-v2-simulate-route.test.ts +271 -0
- package/src/runtime/routes/__tests__/sanity-routes.test.ts +280 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +266 -0
- package/src/runtime/routes/approval-routes.ts +4 -1
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +246 -0
- package/src/runtime/routes/content-source-routes.ts +78 -0
- package/src/runtime/routes/conversation-cli-routes.ts +146 -1
- package/src/runtime/routes/conversation-query-routes.ts +60 -1
- package/src/runtime/routes/conversation-routes.ts +281 -76
- package/src/runtime/routes/document-comments-routes.ts +287 -0
- package/src/runtime/routes/documents-routes.ts +33 -0
- package/src/runtime/routes/home-feed-routes.ts +6 -3
- package/src/runtime/routes/host-app-control-routes.ts +1 -1
- package/src/runtime/routes/host-browser-routes.ts +8 -1
- package/src/runtime/routes/identity-routes.ts +21 -0
- package/src/runtime/routes/inbound-message-handler.ts +288 -58
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +365 -6
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +283 -82
- package/src/runtime/routes/index.ts +12 -4
- package/src/runtime/routes/inference-provider-connection-routes.ts +63 -7
- package/src/runtime/routes/integrations/a2a.ts +60 -1
- package/src/runtime/routes/log-export-routes.ts +39 -0
- package/src/runtime/routes/memory-v2-routes.ts +217 -0
- package/src/runtime/routes/notification-routes.ts +19 -2
- package/src/runtime/routes/question-routes.ts +4 -1
- package/src/runtime/routes/sanity-routes.ts +159 -0
- package/src/runtime/routes/slack-channel-routes.ts +187 -0
- package/src/runtime/services/conversation-serializer.ts +30 -4
- package/src/schedule/integration-status.ts +3 -1
- package/src/security/__tests__/oauth2-device-code.test.ts +479 -0
- package/src/security/oauth2-device-code.ts +307 -0
- package/src/security/oauth2.ts +26 -9
- package/src/security/secure-keys.ts +5 -0
- package/src/skills/catalog-install.ts +6 -2
- package/src/tools/browser/__tests__/pinned-tabs.test.ts +80 -0
- package/src/tools/browser/browser-execution.ts +93 -0
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +28 -0
- package/src/tools/browser/cdp-client/__tests__/types.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect-client.ts +10 -0
- package/src/tools/browser/cdp-client/extension-cdp-client.ts +15 -1
- package/src/tools/browser/cdp-client/factory.ts +87 -3
- package/src/tools/browser/cdp-client/local-cdp-client.ts +9 -0
- package/src/tools/browser/cdp-client/types.ts +36 -0
- package/src/tools/browser/pinned-tabs.ts +90 -0
- package/src/tools/document/document-comment-tool.test.ts +379 -0
- package/src/tools/document/document-comment-tool.ts +156 -0
- package/src/tools/document/document-tool.ts +128 -2
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +229 -0
- package/src/tools/network/__tests__/web-search-metadata.test.ts +346 -0
- package/src/tools/network/domain-normalize.ts +17 -0
- package/src/tools/network/web-fetch.ts +213 -64
- package/src/tools/network/web-search.ts +191 -66
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/tools/tool-approval-handler.ts +19 -12
- package/src/tools/types.ts +4 -0
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/types/onboarding-context.ts +4 -0
- package/src/util/__tests__/favicon.test.ts +84 -0
- package/src/util/favicon.ts +40 -0
- package/src/util/platform.ts +0 -5
- package/src/workspace/git-service.ts +75 -4
- package/src/workspace/migrations/088-deprecate-background-conversation-override.ts +103 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/config/bundled-skills/document/SKILL.md +0 -54
- package/src/config/bundled-skills/document/TOOLS.json +0 -106
- package/src/daemon/seed-files.ts +0 -18
- package/src/runtime/routes/interface-routes.ts +0 -43
- /package/src/config/bundled-skills/{document → document-editor}/tools/document-create.ts +0 -0
- /package/src/config/bundled-skills/{document → document-editor}/tools/document-delete.ts +0 -0
- /package/src/config/bundled-skills/{document → document-editor}/tools/document-list.ts +0 -0
- /package/src/config/bundled-skills/{document → document-editor}/tools/document-read.ts +0 -0
- /package/src/config/bundled-skills/{document → document-editor}/tools/document-update.ts +0 -0
|
@@ -102,6 +102,16 @@ function resolveTone(raw?: string): Tone {
|
|
|
102
102
|
return raw && VALID_TONES.has(raw) ? (raw as Tone) : "grounded";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
export function buildScanFirstMessage(
|
|
106
|
+
url: string,
|
|
107
|
+
variant: "website" | "content-source",
|
|
108
|
+
): string {
|
|
109
|
+
if (variant === "content-source") {
|
|
110
|
+
return `Here's a page with content I'd like you to look at: ${url}`;
|
|
111
|
+
}
|
|
112
|
+
return `Here's my website: ${url}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
function buildPersonalizedGreeting(ctx: OnboardingGreetingContext): string {
|
|
106
116
|
const name = ctx.userName?.trim();
|
|
107
117
|
const assistant = ctx.assistantName?.trim();
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the self-hosted A2A invite accept broker (acceptA2AInvite).
|
|
3
|
+
*
|
|
4
|
+
* Uses the real DB (via `initializeDb()`) and the test preload which sets
|
|
5
|
+
* `VELLUM_WORKSPACE_DIR` to a per-file temp directory. Global `fetch` is
|
|
6
|
+
* mocked to simulate the outbound call to the sender's gateway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
mock.module("../../../util/logger.js", () => ({
|
|
12
|
+
getLogger: () =>
|
|
13
|
+
new Proxy({} as Record<string, unknown>, {
|
|
14
|
+
get: () => () => {},
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
invalidateConfigCache,
|
|
20
|
+
loadRawConfig,
|
|
21
|
+
saveRawConfig,
|
|
22
|
+
setNestedValue,
|
|
23
|
+
} from "../../../config/loader.js";
|
|
24
|
+
import {
|
|
25
|
+
getAssistantContactMetadata,
|
|
26
|
+
getContact,
|
|
27
|
+
searchContacts,
|
|
28
|
+
} from "../../../contacts/contact-store.js";
|
|
29
|
+
import { getSqlite } from "../../../memory/db-connection.js";
|
|
30
|
+
import { initializeDb } from "../../../memory/db-init.js";
|
|
31
|
+
import { acceptA2AInvite, createA2AInvite } from "../config-a2a.js";
|
|
32
|
+
|
|
33
|
+
initializeDb();
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function resetTables(): void {
|
|
40
|
+
const sqlite = getSqlite();
|
|
41
|
+
sqlite.run("DELETE FROM assistant_ingress_invites");
|
|
42
|
+
sqlite.run("DELETE FROM assistant_contact_metadata");
|
|
43
|
+
sqlite.run("DELETE FROM contact_channels");
|
|
44
|
+
sqlite.run("DELETE FROM contacts");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setConfig(opts: {
|
|
48
|
+
a2aEnabled?: boolean;
|
|
49
|
+
publicBaseUrl?: string;
|
|
50
|
+
ingressEnabled?: boolean;
|
|
51
|
+
assistantName?: string;
|
|
52
|
+
}): void {
|
|
53
|
+
const raw = loadRawConfig();
|
|
54
|
+
if (opts.a2aEnabled !== undefined) {
|
|
55
|
+
setNestedValue(raw, "a2a.enabled", opts.a2aEnabled);
|
|
56
|
+
}
|
|
57
|
+
if (opts.publicBaseUrl !== undefined) {
|
|
58
|
+
setNestedValue(raw, "ingress.publicBaseUrl", opts.publicBaseUrl);
|
|
59
|
+
}
|
|
60
|
+
if (opts.ingressEnabled !== undefined) {
|
|
61
|
+
setNestedValue(raw, "ingress.enabled", opts.ingressEnabled);
|
|
62
|
+
}
|
|
63
|
+
saveRawConfig(raw);
|
|
64
|
+
invalidateConfigCache();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SENDER_GATEWAY_URL = "https://sender.example.com";
|
|
68
|
+
const SENDER_ASSISTANT_ID = "sender-assistant-abc";
|
|
69
|
+
const RECEIVER_GATEWAY_URL = "https://receiver.example.com";
|
|
70
|
+
|
|
71
|
+
interface MockFetchOptions {
|
|
72
|
+
status?: number;
|
|
73
|
+
body?: Record<string, unknown>;
|
|
74
|
+
networkError?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mockFetchOnce(opts: MockFetchOptions): void {
|
|
78
|
+
const originalFetch = globalThis.fetch;
|
|
79
|
+
const mockFn = mock(
|
|
80
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) => {
|
|
81
|
+
// Restore after first call
|
|
82
|
+
globalThis.fetch = originalFetch;
|
|
83
|
+
|
|
84
|
+
if (opts.networkError) {
|
|
85
|
+
throw new Error(opts.networkError);
|
|
86
|
+
}
|
|
87
|
+
return new Response(JSON.stringify(opts.body ?? {}), {
|
|
88
|
+
status: opts.status ?? 200,
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
globalThis.fetch = mockFn as unknown as typeof fetch;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Track the outbound fetch call and return a mock response. */
|
|
97
|
+
function mockFetchCapture(opts: MockFetchOptions): {
|
|
98
|
+
getCall: () => { url: string; body: Record<string, unknown> } | null;
|
|
99
|
+
} {
|
|
100
|
+
const originalFetch = globalThis.fetch;
|
|
101
|
+
let captured: { url: string; body: Record<string, unknown> } | null = null;
|
|
102
|
+
const mockFn = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
103
|
+
globalThis.fetch = originalFetch;
|
|
104
|
+
captured = {
|
|
105
|
+
url: String(input),
|
|
106
|
+
body: JSON.parse((init?.body as string) ?? "{}") as Record<
|
|
107
|
+
string,
|
|
108
|
+
unknown
|
|
109
|
+
>,
|
|
110
|
+
};
|
|
111
|
+
if (opts.networkError) {
|
|
112
|
+
throw new Error(opts.networkError);
|
|
113
|
+
}
|
|
114
|
+
return new Response(JSON.stringify(opts.body ?? {}), {
|
|
115
|
+
status: opts.status ?? 200,
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
globalThis.fetch = mockFn as unknown as typeof fetch;
|
|
120
|
+
return { getCall: () => captured };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Tests
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe("acceptA2AInvite", () => {
|
|
128
|
+
let savedFetch: typeof globalThis.fetch;
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
savedFetch = globalThis.fetch;
|
|
132
|
+
resetTables();
|
|
133
|
+
setConfig({
|
|
134
|
+
a2aEnabled: true,
|
|
135
|
+
publicBaseUrl: RECEIVER_GATEWAY_URL,
|
|
136
|
+
ingressEnabled: true,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
globalThis.fetch = savedFetch;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── Happy path ──────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
test("happy path: creates local contact from sender identity", async () => {
|
|
147
|
+
// Create an invite on the sender side so we have a valid token
|
|
148
|
+
const created = createA2AInvite({});
|
|
149
|
+
expect(created.success).toBe(true);
|
|
150
|
+
|
|
151
|
+
mockFetchOnce({
|
|
152
|
+
body: {
|
|
153
|
+
success: true,
|
|
154
|
+
sender: {
|
|
155
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
156
|
+
displayName: "Sender Bot",
|
|
157
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await acceptA2AInvite({
|
|
163
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
164
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
165
|
+
token: "any-token",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(true);
|
|
169
|
+
expect(result.contactId).toBeDefined();
|
|
170
|
+
expect(result.alreadyConnected).toBeFalsy();
|
|
171
|
+
|
|
172
|
+
// Verify the contact was created with correct identity
|
|
173
|
+
const contact = getContact(result.contactId!);
|
|
174
|
+
expect(contact).not.toBeNull();
|
|
175
|
+
expect(contact!.channels).toHaveLength(1);
|
|
176
|
+
expect(contact!.channels[0]!.type).toBe("a2a");
|
|
177
|
+
expect(contact!.channels[0]!.status).toBe("active");
|
|
178
|
+
|
|
179
|
+
// Verify assistant metadata
|
|
180
|
+
const metadata = getAssistantContactMetadata(result.contactId!);
|
|
181
|
+
expect(metadata).not.toBeNull();
|
|
182
|
+
expect(metadata!.metadata).toEqual({
|
|
183
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
184
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("uses invite-link values for sender identity, not daemon response", async () => {
|
|
189
|
+
const maliciousGateway = "https://evil.example.com";
|
|
190
|
+
const maliciousId = "evil-assistant";
|
|
191
|
+
|
|
192
|
+
mockFetchOnce({
|
|
193
|
+
body: {
|
|
194
|
+
success: true,
|
|
195
|
+
sender: {
|
|
196
|
+
// Sender daemon tries to misrepresent identity
|
|
197
|
+
assistantId: maliciousId,
|
|
198
|
+
displayName: "Legit Bot",
|
|
199
|
+
gatewayUrl: maliciousGateway,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result = await acceptA2AInvite({
|
|
205
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
206
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
207
|
+
token: "any-token",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.success).toBe(true);
|
|
211
|
+
|
|
212
|
+
// Verify contact uses invite-link values, NOT the daemon response
|
|
213
|
+
const metadata = getAssistantContactMetadata(result.contactId!);
|
|
214
|
+
expect(metadata!.metadata).toEqual({
|
|
215
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
216
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("uses sender displayName from complete response", async () => {
|
|
221
|
+
mockFetchOnce({
|
|
222
|
+
body: {
|
|
223
|
+
success: true,
|
|
224
|
+
sender: {
|
|
225
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
226
|
+
displayName: "My Cool Bot",
|
|
227
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = await acceptA2AInvite({
|
|
233
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
234
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
235
|
+
token: "any-token",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(true);
|
|
239
|
+
const contact = getContact(result.contactId!);
|
|
240
|
+
expect(contact!.displayName).toBe("My Cool Bot");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("falls back to senderAssistantId when displayName is missing", async () => {
|
|
244
|
+
mockFetchOnce({
|
|
245
|
+
body: {
|
|
246
|
+
success: true,
|
|
247
|
+
sender: {
|
|
248
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
249
|
+
// No displayName
|
|
250
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const result = await acceptA2AInvite({
|
|
256
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
257
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
258
|
+
token: "any-token",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(true);
|
|
262
|
+
const contact = getContact(result.contactId!);
|
|
263
|
+
expect(contact!.displayName).toBe(SENDER_ASSISTANT_ID);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("sends correct request to sender's invite/complete endpoint", async () => {
|
|
267
|
+
const capture = mockFetchCapture({
|
|
268
|
+
body: {
|
|
269
|
+
success: true,
|
|
270
|
+
sender: {
|
|
271
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
272
|
+
displayName: "Sender Bot",
|
|
273
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await acceptA2AInvite({
|
|
279
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
280
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
281
|
+
token: "test-token-123",
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const call = capture.getCall();
|
|
285
|
+
expect(call).not.toBeNull();
|
|
286
|
+
expect(call!.url).toBe(
|
|
287
|
+
`${SENDER_GATEWAY_URL}/v1/integrations/a2a/invite/complete`,
|
|
288
|
+
);
|
|
289
|
+
expect(call!.body).toEqual({
|
|
290
|
+
token: "test-token-123",
|
|
291
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
292
|
+
acceptor: {
|
|
293
|
+
assistantId: RECEIVER_GATEWAY_URL,
|
|
294
|
+
displayName: "Vellum Assistant",
|
|
295
|
+
gatewayUrl: RECEIVER_GATEWAY_URL,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("strips trailing slashes from senderGatewayUrl in fetch URL and stored metadata", async () => {
|
|
301
|
+
const capture = mockFetchCapture({
|
|
302
|
+
body: {
|
|
303
|
+
success: true,
|
|
304
|
+
sender: {
|
|
305
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
306
|
+
displayName: "Bot",
|
|
307
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const result = await acceptA2AInvite({
|
|
313
|
+
senderGatewayUrl: `${SENDER_GATEWAY_URL}///`,
|
|
314
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
315
|
+
token: "test-token",
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Fetch URL is normalized
|
|
319
|
+
const call = capture.getCall();
|
|
320
|
+
expect(call!.url).toBe(
|
|
321
|
+
`${SENDER_GATEWAY_URL}/v1/integrations/a2a/invite/complete`,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Stored contact metadata is also normalized (no trailing slashes)
|
|
325
|
+
expect(result.success).toBe(true);
|
|
326
|
+
const contact = getContact(result.contactId!);
|
|
327
|
+
expect(contact).toBeTruthy();
|
|
328
|
+
const meta = getAssistantContactMetadata(result.contactId!);
|
|
329
|
+
expect((meta?.metadata as { gatewayUrl?: string } | null)?.gatewayUrl).toBe(
|
|
330
|
+
SENDER_GATEWAY_URL,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ── Already connected ───────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
test("returns alreadyConnected without calling sender when already a contact", async () => {
|
|
337
|
+
// First accept — creates the contact
|
|
338
|
+
mockFetchOnce({
|
|
339
|
+
body: {
|
|
340
|
+
success: true,
|
|
341
|
+
sender: {
|
|
342
|
+
assistantId: SENDER_ASSISTANT_ID,
|
|
343
|
+
displayName: "Sender Bot",
|
|
344
|
+
gatewayUrl: SENDER_GATEWAY_URL,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
const first = await acceptA2AInvite({
|
|
349
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
350
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
351
|
+
token: "token-1",
|
|
352
|
+
});
|
|
353
|
+
expect(first.success).toBe(true);
|
|
354
|
+
|
|
355
|
+
// Second accept — should short-circuit before any outbound call.
|
|
356
|
+
// No mockFetchOnce here: if fetch is called, it hits the real
|
|
357
|
+
// (unmocked) fetch and the test would fail or hang.
|
|
358
|
+
const second = await acceptA2AInvite({
|
|
359
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
360
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
361
|
+
token: "token-2",
|
|
362
|
+
});
|
|
363
|
+
expect(second.success).toBe(true);
|
|
364
|
+
expect(second.alreadyConnected).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// ── Sender unreachable ──────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
test("returns sender_unreachable when fetch throws", async () => {
|
|
370
|
+
mockFetchOnce({ networkError: "Connection refused" });
|
|
371
|
+
|
|
372
|
+
const result = await acceptA2AInvite({
|
|
373
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
374
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
375
|
+
token: "any-token",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(result.success).toBe(false);
|
|
379
|
+
expect(result.errorCode).toBe("sender_unreachable");
|
|
380
|
+
expect(result.error).toContain("Connection refused");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── Sender returns error ────────────────────────────────────────────
|
|
384
|
+
// The daemon HTTP adapter always converts RouteError throws into the
|
|
385
|
+
// standard envelope: { error: { code, message } } (see http-errors.ts).
|
|
386
|
+
// These mocks match the real wire format.
|
|
387
|
+
|
|
388
|
+
test("returns complete_failed when sender returns 400 with token error", async () => {
|
|
389
|
+
mockFetchOnce({
|
|
390
|
+
status: 400,
|
|
391
|
+
body: {
|
|
392
|
+
error: {
|
|
393
|
+
code: "BAD_REQUEST",
|
|
394
|
+
message: "Invite token has expired or was already claimed",
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const result = await acceptA2AInvite({
|
|
400
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
401
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
402
|
+
token: "expired-token",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(result.success).toBe(false);
|
|
406
|
+
expect(result.error).toBe(
|
|
407
|
+
"Invite token has expired or was already claimed",
|
|
408
|
+
);
|
|
409
|
+
expect(result.errorCode).toBe("complete_failed");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("returns complete_failed when sender returns 400 for validation", async () => {
|
|
413
|
+
mockFetchOnce({
|
|
414
|
+
status: 400,
|
|
415
|
+
body: {
|
|
416
|
+
error: {
|
|
417
|
+
code: "BAD_REQUEST",
|
|
418
|
+
message:
|
|
419
|
+
"acceptor must include non-empty assistantId, displayName, and gatewayUrl",
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const result = await acceptA2AInvite({
|
|
425
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
426
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
427
|
+
token: "any-token",
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(result.success).toBe(false);
|
|
431
|
+
expect(result.error).toContain("acceptor must include");
|
|
432
|
+
expect(result.errorCode).toBe("complete_failed");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("falls back to generic message when error envelope is malformed", async () => {
|
|
436
|
+
mockFetchOnce({
|
|
437
|
+
status: 500,
|
|
438
|
+
body: { unexpected: "shape" },
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const result = await acceptA2AInvite({
|
|
442
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
443
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
444
|
+
token: "any-token",
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(result.success).toBe(false);
|
|
448
|
+
expect(result.error).toBe("Invite completion failed");
|
|
449
|
+
expect(result.errorCode).toBe("complete_failed");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ── No public base URL ──────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
test("returns no_public_url when publicBaseUrl is not configured", async () => {
|
|
455
|
+
setConfig({ publicBaseUrl: "", ingressEnabled: true });
|
|
456
|
+
|
|
457
|
+
const result = await acceptA2AInvite({
|
|
458
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
459
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
460
|
+
token: "any-token",
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(result.success).toBe(false);
|
|
464
|
+
expect(result.errorCode).toBe("no_public_url");
|
|
465
|
+
expect(result.error).toContain("public base URL");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("returns no_public_url when ingress is disabled", async () => {
|
|
469
|
+
setConfig({ ingressEnabled: false });
|
|
470
|
+
|
|
471
|
+
const result = await acceptA2AInvite({
|
|
472
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
473
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
474
|
+
token: "any-token",
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(result.success).toBe(false);
|
|
478
|
+
expect(result.errorCode).toBe("no_public_url");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ── No existing contacts leaked ─────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
test("does not create a contact when sender returns failure", async () => {
|
|
484
|
+
mockFetchOnce({
|
|
485
|
+
status: 400,
|
|
486
|
+
body: { error: { code: "BAD_REQUEST", message: "Invalid token" } },
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await acceptA2AInvite({
|
|
490
|
+
senderGatewayUrl: SENDER_GATEWAY_URL,
|
|
491
|
+
senderAssistantId: SENDER_ASSISTANT_ID,
|
|
492
|
+
token: "bad-token",
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const contacts = searchContacts({ channelType: "a2a" });
|
|
496
|
+
expect(contacts).toHaveLength(0);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* - setA2AConfig() — set a2a.enabled = true
|
|
6
6
|
* - clearA2AConfig() — set a2a.enabled = false
|
|
7
7
|
* - createA2AInvite() — create a shareable invite token for link-based contact creation
|
|
8
|
+
* - completeA2AInvite() — sender-side: claim token and return sender identity
|
|
8
9
|
* - redeemA2AInvite() — receiver-side: create trusted contact from sender identity
|
|
10
|
+
* - acceptA2AInvite() — self-hosted broker: orchestrate complete + redeem across daemons
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import {
|
|
@@ -29,7 +31,11 @@ import {
|
|
|
29
31
|
hashToken,
|
|
30
32
|
} from "../../memory/invite-store.js";
|
|
31
33
|
import { assistantContactMetadata } from "../../memory/schema.js";
|
|
34
|
+
import type { HttpErrorResponse } from "../../runtime/http-errors.js";
|
|
35
|
+
import { getLogger } from "../../util/logger.js";
|
|
32
36
|
import { getAssistantName } from "../identity-helpers.js";
|
|
37
|
+
const log = getLogger("config-a2a");
|
|
38
|
+
|
|
33
39
|
// ── Result types ────────────────────────────────────────────────────
|
|
34
40
|
|
|
35
41
|
export interface A2AConfigResult {
|
|
@@ -61,6 +67,14 @@ export interface RedeemA2AInviteResult {
|
|
|
61
67
|
error?: string;
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
export interface AcceptA2AInviteResult {
|
|
71
|
+
success: boolean;
|
|
72
|
+
contactId?: string;
|
|
73
|
+
alreadyConnected?: boolean;
|
|
74
|
+
error?: string;
|
|
75
|
+
errorCode?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
// ── Config operations ───────────────────────────────────────────────
|
|
65
79
|
|
|
66
80
|
export function getA2AConfig(): A2AConfigResult {
|
|
@@ -287,3 +301,149 @@ export function redeemA2AInvite(params: {
|
|
|
287
301
|
|
|
288
302
|
return { success: true, contactId: contact.id };
|
|
289
303
|
}
|
|
304
|
+
|
|
305
|
+
// ── Self-hosted broker ──────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
const ACCEPT_TIMEOUT_MS = 15_000;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Extract a human-readable error message from a daemon HTTP error
|
|
311
|
+
* response. The daemon always returns `{ error: { code, message } }`
|
|
312
|
+
* (see `HttpErrorResponse` in `runtime/http-errors.ts`).
|
|
313
|
+
*/
|
|
314
|
+
function extractDaemonErrorMessage(
|
|
315
|
+
body: Record<string, unknown>,
|
|
316
|
+
): string | undefined {
|
|
317
|
+
const envelope = body as Partial<HttpErrorResponse>;
|
|
318
|
+
if (
|
|
319
|
+
typeof envelope.error === "object" &&
|
|
320
|
+
envelope.error !== null &&
|
|
321
|
+
typeof envelope.error.message === "string"
|
|
322
|
+
) {
|
|
323
|
+
return envelope.error.message;
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Orchestrate cross-daemon A2A invite acceptance for self-hosted
|
|
330
|
+
* deployments. Calls the sender's `invite/complete` endpoint, then
|
|
331
|
+
* creates a local contact via `redeemA2AInvite`.
|
|
332
|
+
*
|
|
333
|
+
* Trust model: the user explicitly chose to connect to `senderGatewayUrl`
|
|
334
|
+
* and provided a token from the sender's invite link. We trust the
|
|
335
|
+
* invite-link values (`senderAssistantId`, `senderGatewayUrl`) as the
|
|
336
|
+
* canonical sender identity, and only use the `complete` response for the
|
|
337
|
+
* sender's display name (which has no other source in self-hosted mode).
|
|
338
|
+
*/
|
|
339
|
+
export async function acceptA2AInvite(params: {
|
|
340
|
+
senderGatewayUrl: string;
|
|
341
|
+
senderAssistantId: string;
|
|
342
|
+
token: string;
|
|
343
|
+
}): Promise<AcceptA2AInviteResult> {
|
|
344
|
+
const senderGatewayUrl = params.senderGatewayUrl.replace(/\/+$/, "");
|
|
345
|
+
|
|
346
|
+
// 1. Validate local config
|
|
347
|
+
const displayName = getAssistantName() ?? "Vellum Assistant";
|
|
348
|
+
let localGatewayUrl: string;
|
|
349
|
+
try {
|
|
350
|
+
localGatewayUrl = getPublicBaseUrl(getConfig());
|
|
351
|
+
} catch {
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
error:
|
|
355
|
+
"No public base URL configured. Set ingress.publicBaseUrl in config.",
|
|
356
|
+
errorCode: "no_public_url",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 2. Short-circuit if already connected — avoids a network round-trip
|
|
361
|
+
// and consuming a token on the sender side.
|
|
362
|
+
const existing = findContactByAddress("a2a", params.senderAssistantId);
|
|
363
|
+
if (
|
|
364
|
+
existing &&
|
|
365
|
+
existing.channels.some((ch) => ch.type === "a2a" && ch.status === "active")
|
|
366
|
+
) {
|
|
367
|
+
return { success: true, alreadyConnected: true, contactId: existing.id };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 3. Call the sender's invite/complete endpoint
|
|
371
|
+
const completeUrl = `${senderGatewayUrl}/v1/integrations/a2a/invite/complete`;
|
|
372
|
+
const completeBody = {
|
|
373
|
+
token: params.token,
|
|
374
|
+
senderAssistantId: params.senderAssistantId,
|
|
375
|
+
acceptor: {
|
|
376
|
+
assistantId: localGatewayUrl,
|
|
377
|
+
displayName,
|
|
378
|
+
gatewayUrl: localGatewayUrl,
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
let completeData: Record<string, unknown>;
|
|
383
|
+
try {
|
|
384
|
+
const response = await fetch(completeUrl, {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: { "Content-Type": "application/json" },
|
|
387
|
+
body: JSON.stringify(completeBody),
|
|
388
|
+
signal: AbortSignal.timeout(ACCEPT_TIMEOUT_MS),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
completeData = (await response.json()) as Record<string, unknown>;
|
|
392
|
+
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
const error =
|
|
395
|
+
extractDaemonErrorMessage(completeData) ?? "Invite completion failed";
|
|
396
|
+
log.warn(
|
|
397
|
+
{ senderGatewayUrl, status: response.status, error },
|
|
398
|
+
"Sender invite/complete returned error",
|
|
399
|
+
);
|
|
400
|
+
return { success: false, error, errorCode: "complete_failed" };
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
404
|
+
log.warn(
|
|
405
|
+
{ senderGatewayUrl, error: message },
|
|
406
|
+
"Failed to reach sender for invite/complete",
|
|
407
|
+
);
|
|
408
|
+
return {
|
|
409
|
+
success: false,
|
|
410
|
+
error: `Failed to reach sender: ${message}`,
|
|
411
|
+
errorCode: "sender_unreachable",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 4. Extract sender display name from the complete response; use
|
|
416
|
+
// invite-link values for assistantId and gatewayUrl (trusted source).
|
|
417
|
+
const senderFromResponse = completeData.sender as
|
|
418
|
+
| { displayName?: string }
|
|
419
|
+
| undefined;
|
|
420
|
+
|
|
421
|
+
const senderIdentity = {
|
|
422
|
+
assistantId: params.senderAssistantId,
|
|
423
|
+
displayName:
|
|
424
|
+
(typeof senderFromResponse?.displayName === "string" &&
|
|
425
|
+
senderFromResponse.displayName) ||
|
|
426
|
+
params.senderAssistantId,
|
|
427
|
+
gatewayUrl: senderGatewayUrl,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// 5. Create the sender as a local trusted contact
|
|
431
|
+
const redeemResult = redeemA2AInvite({ sender: senderIdentity });
|
|
432
|
+
if (!redeemResult.success) {
|
|
433
|
+
log.warn(
|
|
434
|
+
{ error: redeemResult.error },
|
|
435
|
+
"Local invite/redeem failed after successful complete",
|
|
436
|
+
);
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
error: redeemResult.error ?? "Failed to create sender contact",
|
|
440
|
+
errorCode: "redeem_failed",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
success: true,
|
|
446
|
+
contactId: redeemResult.contactId,
|
|
447
|
+
alreadyConnected: redeemResult.alreadyConnected,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
@@ -65,6 +65,7 @@ describe("projectProviderForWire", () => {
|
|
|
65
65
|
const wire = projectProviderForWire(gemini!);
|
|
66
66
|
const modelIds = wire.models.map((model) => model.id);
|
|
67
67
|
const expectedGemini3ModelIds = [
|
|
68
|
+
"gemini-3.5-flash",
|
|
68
69
|
"gemini-3.1-pro-preview",
|
|
69
70
|
"gemini-3.1-pro-preview-customtools",
|
|
70
71
|
"gemini-3-flash-preview",
|