@vellumai/assistant 0.8.2 → 0.8.3
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/ARCHITECTURE.md +11 -12
- package/docker-entrypoint.sh +13 -1
- package/docker-init-apt-root.sh +79 -6
- package/openapi.yaml +336 -21
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
- package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
- package/src/__tests__/config-get-vision-flag.test.ts +136 -0
- package/src/__tests__/config-loader-backfill.test.ts +115 -18
- package/src/__tests__/context-token-estimator.test.ts +30 -65
- package/src/__tests__/conversation-agent-loop.test.ts +57 -1
- package/src/__tests__/conversation-media-retry.test.ts +19 -8
- package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
- package/src/__tests__/date-context.test.ts +45 -0
- package/src/__tests__/external-plugin-loader.test.ts +91 -19
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
- package/src/__tests__/guardian-dispatch.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +24 -164
- package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
- package/src/__tests__/host-app-control-proxy.test.ts +241 -0
- package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
- package/src/__tests__/injector-background-turn.test.ts +153 -0
- package/src/__tests__/injector-chain.test.ts +5 -0
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
- package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
- package/src/__tests__/llm-catalog-parity.test.ts +3 -0
- package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
- package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
- package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
- package/src/__tests__/llm-resolver.test.ts +255 -2
- package/src/__tests__/managed-profile-guard.test.ts +10 -0
- package/src/__tests__/notification-decision-fallback.test.ts +0 -91
- package/src/__tests__/notification-decision-strategy.test.ts +14 -31
- package/src/__tests__/notification-deep-link.test.ts +15 -0
- package/src/__tests__/notification-guardian-path.test.ts +1 -2
- package/src/__tests__/notification-platform-adapter.test.ts +5 -4
- package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
- package/src/__tests__/openai-provider.test.ts +218 -3
- package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
- package/src/__tests__/openrouter-provider-only.test.ts +51 -3
- package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
- package/src/__tests__/platform-proxy-context.test.ts +6 -1
- package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
- package/src/__tests__/plugin-types.test.ts +2 -2
- package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +6 -73
- package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
- package/src/a2a/__tests__/agent-card.test.ts +98 -0
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
- package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
- package/src/a2a/__tests__/task-store.test.ts +246 -0
- package/src/a2a/agent-card.ts +58 -0
- package/src/a2a/feature-gate.ts +8 -0
- package/src/a2a/protocol-constants.ts +21 -0
- package/src/a2a/protocol-errors.ts +50 -0
- package/src/a2a/protocol-types.ts +162 -0
- package/src/a2a/task-store.ts +168 -0
- package/src/agent/loop.ts +167 -18
- package/src/channels/config.ts +9 -0
- package/src/channels/types.ts +14 -0
- package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
- package/src/cli/commands/__tests__/schedules.test.ts +469 -0
- package/src/cli/commands/notifications.ts +65 -35
- package/src/cli/commands/plugins.ts +67 -0
- package/src/cli/commands/schedules.ts +297 -5
- package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
- package/src/cli/lib/install-from-github.ts +8 -9
- package/src/cli/lib/search-plugins.ts +163 -0
- package/src/cli/program.ts +14 -0
- package/src/config/assistant-feature-flags.ts +24 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/call-site-defaults.ts +105 -0
- package/src/config/feature-flag-registry.json +21 -29
- package/src/config/llm-resolver.ts +52 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
- package/src/config/schemas/channels.ts +9 -0
- package/src/config/schemas/conversations.ts +10 -0
- package/src/config/schemas/heartbeat.ts +14 -0
- package/src/config/schemas/llm.ts +1 -3
- package/src/config/schemas/memory-retrospective.ts +1 -1
- package/src/config/schemas/memory-v2.ts +4 -4
- package/src/config/schemas/memory.ts +3 -1
- package/src/config/seed-inference-profiles.ts +99 -29
- package/src/context/compactor.ts +72 -12
- package/src/context/token-estimator.ts +32 -34
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
- package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
- package/src/daemon/conversation-agent-loop.ts +29 -2
- package/src/daemon/conversation-runtime-assembly.ts +9 -0
- package/src/daemon/conversation.ts +0 -7
- package/src/daemon/date-context.ts +40 -0
- package/src/daemon/guardian-action-generators.ts +1 -125
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
- package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
- package/src/daemon/handlers/config-a2a.ts +289 -0
- package/src/daemon/handlers/conversations.ts +1 -0
- package/src/daemon/host-app-control-proxy.ts +69 -18
- package/src/daemon/host-proxy-preactivation.ts +85 -18
- package/src/daemon/lifecycle.ts +49 -61
- package/src/daemon/memory-v2-startup.ts +49 -13
- package/src/daemon/message-types/notifications.ts +21 -0
- package/src/daemon/pkb-reminder-builder.test.ts +10 -53
- package/src/daemon/pkb-reminder-builder.ts +4 -19
- package/src/daemon/process-message.ts +3 -0
- package/src/daemon/skill-memory-refresh.ts +5 -1
- package/src/daemon/wake-target-adapter.ts +2 -0
- package/src/export/__tests__/transcript-formatter.test.ts +121 -0
- package/src/export/transcript-formatter.ts +54 -20
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
- package/src/heartbeat/heartbeat-service.ts +34 -191
- package/src/home/__tests__/feed-types.test.ts +40 -0
- package/src/home/feed-types.ts +14 -2
- package/src/ipc/cli-client.ts +147 -45
- package/src/memory/__tests__/conversation-queries.test.ts +220 -0
- package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
- package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
- package/src/memory/conversation-queries.ts +87 -1
- package/src/memory/conversation-title-service.ts +26 -4
- package/src/memory/db-init.ts +6 -0
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
- package/src/memory/graph/conversation-graph-memory.ts +18 -6
- package/src/memory/graph/tools.ts +6 -37
- package/src/memory/invite-store.ts +53 -0
- package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
- package/src/memory/llm-request-log-store.ts +92 -1
- package/src/memory/memory-retrospective-enqueue.ts +1 -20
- package/src/memory/memory-retrospective-job.ts +33 -6
- package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
- package/src/memory/migrations/251-a2a-tasks.ts +49 -0
- package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/a2a.ts +15 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/inference.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
- package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
- package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
- package/src/memory/v2/__tests__/injection.test.ts +190 -3
- package/src/memory/v2/__tests__/static-context.test.ts +12 -1
- package/src/memory/v2/activation-store.ts +14 -16
- package/src/memory/v2/cli-command-content.ts +19 -0
- package/src/memory/v2/cli-command-store.ts +304 -0
- package/src/memory/v2/frontmatter-sweep.ts +7 -1
- package/src/memory/v2/injection.ts +49 -20
- package/src/memory/v2/page-index.ts +38 -13
- package/src/memory/v2/static-context.ts +4 -4
- package/src/memory/v2/types.ts +23 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
- package/src/messaging/providers/a2a/deliver.ts +156 -0
- package/src/messaging/providers/gmail/client.ts +9 -2
- package/src/messaging/providers/index.ts +11 -2
- package/src/notifications/__tests__/broadcaster.test.ts +203 -0
- package/src/notifications/__tests__/decision-engine.test.ts +283 -0
- package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
- package/src/notifications/adapters/macos.ts +12 -2
- package/src/notifications/broadcaster.ts +29 -4
- package/src/notifications/copy-composer.ts +17 -64
- package/src/notifications/decision-engine.ts +111 -44
- package/src/notifications/deterministic-checks.ts +96 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/home-feed-side-effect.ts +85 -6
- package/src/notifications/signal.ts +0 -4
- package/src/notifications/types.ts +8 -0
- package/src/oauth/platform-connection.test.ts +43 -3
- package/src/oauth/platform-connection.ts +13 -4
- package/src/plugins/defaults/injectors.ts +38 -19
- package/src/plugins/external-plugin-loader.ts +82 -10
- package/src/plugins/types.ts +16 -7
- package/src/prompts/__tests__/system-prompt.test.ts +6 -51
- package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
- package/src/prompts/system-prompt.ts +0 -8
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/system-sections.ts +0 -9
- package/src/providers/__tests__/inference.test.ts +2 -0
- package/src/providers/call-site-routing.ts +24 -6
- package/src/providers/connection-resolution.ts +63 -13
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
- package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
- package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
- package/src/providers/inference/adapter-factory.ts +9 -20
- package/src/providers/inference/auth.ts +12 -0
- package/src/providers/inference/backfill.ts +14 -1
- package/src/providers/inference/connections.ts +85 -5
- package/src/providers/inference/resolve-auth.ts +2 -0
- package/src/providers/model-catalog.ts +199 -244
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
- package/src/providers/openai/chat-completions-provider.ts +159 -6
- package/src/providers/openrouter/client.ts +42 -4
- package/src/providers/platform-proxy/constants.ts +3 -4
- package/src/providers/provider-catalog-visibility.ts +3 -1
- package/src/providers/provider-send-message.ts +27 -12
- package/src/providers/registry.ts +30 -1
- package/src/runtime/agent-wake.ts +61 -1
- package/src/runtime/auth/route-policy.ts +13 -0
- package/src/runtime/http-server.ts +7 -16
- package/src/runtime/http-types.ts +0 -47
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
- package/src/runtime/routes/channel-availability-routes.ts +5 -0
- package/src/runtime/routes/consolidation-routes.ts +100 -0
- package/src/runtime/routes/conversation-query-routes.ts +70 -11
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
- package/src/runtime/routes/integrations/a2a.ts +235 -0
- package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
- package/src/runtime/routes/subagents-routes.ts +41 -0
- package/src/subagent/manager.ts +2 -0
- package/src/tools/memory/register.ts +1 -9
- package/src/tools/registry.ts +2 -2
- package/src/tools/types.ts +37 -2
- package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
- package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
- package/src/runtime/guardian-action-conversation-turn.ts +0 -99
|
@@ -9,12 +9,7 @@ import {
|
|
|
9
9
|
GUARDIAN_ACTION_COPY_TIMEOUT_MS,
|
|
10
10
|
includesRequiredKeywords,
|
|
11
11
|
} from "../runtime/guardian-action-message-composer.js";
|
|
12
|
-
import type {
|
|
13
|
-
GuardianActionCopyGenerator,
|
|
14
|
-
GuardianFollowUpConversationGenerator,
|
|
15
|
-
GuardianFollowUpDisposition,
|
|
16
|
-
GuardianFollowUpTurnResult,
|
|
17
|
-
} from "../runtime/http-types.js";
|
|
12
|
+
import type { GuardianActionCopyGenerator } from "../runtime/http-types.js";
|
|
18
13
|
|
|
19
14
|
/**
|
|
20
15
|
* Create the daemon-owned guardian action copy generator that resolves
|
|
@@ -74,122 +69,3 @@ export function createGuardianActionCopyGenerator(): GuardianActionCopyGenerator
|
|
|
74
69
|
return cleaned;
|
|
75
70
|
};
|
|
76
71
|
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Guardian follow-up conversation generator
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
const FOLLOWUP_CONVERSATION_TIMEOUT_MS = 8_000;
|
|
83
|
-
const FOLLOWUP_CONVERSATION_MAX_TOKENS = 300;
|
|
84
|
-
|
|
85
|
-
const FOLLOWUP_CONVERSATION_SYSTEM_PROMPT =
|
|
86
|
-
"You are an assistant helping route a guardian's reply to a post-timeout follow-up message. " +
|
|
87
|
-
"A voice caller asked a question, but the call timed out before the guardian could answer. " +
|
|
88
|
-
"The guardian has now replied late, and was asked whether they want to call the caller back " +
|
|
89
|
-
"or skip it. " +
|
|
90
|
-
"Analyze the guardian's latest reply to determine their intent. " +
|
|
91
|
-
"When uncertain, default to keep_pending and ask a clarifying question. " +
|
|
92
|
-
"Always provide a natural, helpful reply along with your decision.";
|
|
93
|
-
|
|
94
|
-
const FOLLOWUP_CONVERSATION_TOOL_NAME = "followup_decision";
|
|
95
|
-
|
|
96
|
-
const FOLLOWUP_CONVERSATION_TOOL_SCHEMA = {
|
|
97
|
-
name: FOLLOWUP_CONVERSATION_TOOL_NAME,
|
|
98
|
-
description:
|
|
99
|
-
"Record the guardian's follow-up decision and a natural reply. " +
|
|
100
|
-
"Call this tool with the determined disposition and a reply to the guardian.",
|
|
101
|
-
input_schema: {
|
|
102
|
-
type: "object" as const,
|
|
103
|
-
properties: {
|
|
104
|
-
disposition: {
|
|
105
|
-
type: "string",
|
|
106
|
-
enum: ["call_back", "decline", "keep_pending"],
|
|
107
|
-
description:
|
|
108
|
-
"The guardian's intent: call_back to call the original caller, " +
|
|
109
|
-
"decline to skip the follow-up, " +
|
|
110
|
-
"keep_pending if the intent is unclear (ask for clarification).",
|
|
111
|
-
},
|
|
112
|
-
replyText: {
|
|
113
|
-
type: "string",
|
|
114
|
-
description: "A natural language reply to send back to the guardian.",
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
required: ["disposition", "replyText"],
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const VALID_FOLLOWUP_DISPOSITIONS: ReadonlySet<string> = new Set([
|
|
122
|
-
"call_back",
|
|
123
|
-
"decline",
|
|
124
|
-
"keep_pending",
|
|
125
|
-
]);
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Create the daemon-owned guardian follow-up conversation generator.
|
|
129
|
-
* Uses tool/function calling to produce structured dispositions alongside
|
|
130
|
-
* natural reply text. Follows the same pattern as
|
|
131
|
-
* createApprovalConversationGenerator().
|
|
132
|
-
*/
|
|
133
|
-
export function createGuardianFollowUpConversationGenerator(): GuardianFollowUpConversationGenerator {
|
|
134
|
-
return async (context) => {
|
|
135
|
-
const baseProvider = await getConfiguredProvider("guardianQuestionCopy");
|
|
136
|
-
if (!baseProvider) {
|
|
137
|
-
throw new Error("No configured provider available for follow-up conversation");
|
|
138
|
-
}
|
|
139
|
-
const provider = wrapWithCallSiteRouting(baseProvider, loadConfig());
|
|
140
|
-
|
|
141
|
-
const userPrompt = [
|
|
142
|
-
`Original question from the voice call: "${context.questionText}"`,
|
|
143
|
-
`Guardian's late answer: "${context.lateAnswerText}"`,
|
|
144
|
-
`\nGuardian's latest reply: ${context.guardianReply}`,
|
|
145
|
-
].join("\n");
|
|
146
|
-
|
|
147
|
-
const response = await provider.sendMessage(
|
|
148
|
-
[{ role: "user", content: [{ type: "text", text: userPrompt }] }],
|
|
149
|
-
[FOLLOWUP_CONVERSATION_TOOL_SCHEMA],
|
|
150
|
-
FOLLOWUP_CONVERSATION_SYSTEM_PROMPT,
|
|
151
|
-
{
|
|
152
|
-
config: {
|
|
153
|
-
max_tokens: FOLLOWUP_CONVERSATION_MAX_TOKENS,
|
|
154
|
-
callSite: "guardianQuestionCopy",
|
|
155
|
-
},
|
|
156
|
-
signal: AbortSignal.timeout(FOLLOWUP_CONVERSATION_TIMEOUT_MS),
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Extract the tool_use block from the response
|
|
161
|
-
const toolUseBlock = response.content.find(
|
|
162
|
-
(block) =>
|
|
163
|
-
block.type === "tool_use" &&
|
|
164
|
-
block.name === FOLLOWUP_CONVERSATION_TOOL_NAME,
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
|
|
168
|
-
throw new Error(
|
|
169
|
-
"Provider did not return a tool_use block for follow-up decision",
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const input = toolUseBlock.input as Record<string, unknown>;
|
|
174
|
-
|
|
175
|
-
// Strict validation of the structured output
|
|
176
|
-
const disposition = input.disposition;
|
|
177
|
-
if (
|
|
178
|
-
typeof disposition !== "string" ||
|
|
179
|
-
!VALID_FOLLOWUP_DISPOSITIONS.has(disposition)
|
|
180
|
-
) {
|
|
181
|
-
throw new Error(`Invalid disposition: ${String(disposition)}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const replyText = input.replyText;
|
|
185
|
-
if (typeof replyText !== "string" || replyText.trim().length === 0) {
|
|
186
|
-
throw new Error("Missing or empty replyText in tool_use response");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const result: GuardianFollowUpTurnResult = {
|
|
190
|
-
disposition: disposition as GuardianFollowUpDisposition,
|
|
191
|
-
replyText: replyText.trim(),
|
|
192
|
-
};
|
|
193
|
-
return result;
|
|
194
|
-
};
|
|
195
|
-
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for A2A invite completion handler (sender side).
|
|
3
|
+
*
|
|
4
|
+
* Uses the real DB (via `initializeDb()`) and the test preload which sets
|
|
5
|
+
* `VELLUM_WORKSPACE_DIR` to a per-file temp directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
mock.module("../../../util/logger.js", () => ({
|
|
11
|
+
getLogger: () =>
|
|
12
|
+
new Proxy({} as Record<string, unknown>, {
|
|
13
|
+
get: () => () => {},
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
invalidateConfigCache,
|
|
19
|
+
loadRawConfig,
|
|
20
|
+
saveRawConfig,
|
|
21
|
+
setNestedValue,
|
|
22
|
+
} from "../../../config/loader.js";
|
|
23
|
+
import {
|
|
24
|
+
getAssistantContactMetadata,
|
|
25
|
+
getContact,
|
|
26
|
+
} from "../../../contacts/contact-store.js";
|
|
27
|
+
import { getSqlite } from "../../../memory/db-connection.js";
|
|
28
|
+
import { initializeDb } from "../../../memory/db-init.js";
|
|
29
|
+
import { findById } from "../../../memory/invite-store.js";
|
|
30
|
+
import { completeA2AInvite, createA2AInvite } from "../config-a2a.js";
|
|
31
|
+
|
|
32
|
+
initializeDb();
|
|
33
|
+
|
|
34
|
+
function resetTables(): void {
|
|
35
|
+
const sqlite = getSqlite();
|
|
36
|
+
sqlite.run("DELETE FROM assistant_ingress_invites");
|
|
37
|
+
sqlite.run("DELETE FROM assistant_contact_metadata");
|
|
38
|
+
sqlite.run("DELETE FROM contact_channels");
|
|
39
|
+
sqlite.run("DELETE FROM contacts");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setConfig(opts: {
|
|
43
|
+
a2aEnabled?: boolean;
|
|
44
|
+
publicBaseUrl?: string;
|
|
45
|
+
ingressEnabled?: boolean;
|
|
46
|
+
}): void {
|
|
47
|
+
const raw = loadRawConfig();
|
|
48
|
+
if (opts.a2aEnabled !== undefined) {
|
|
49
|
+
setNestedValue(raw, "a2a.enabled", opts.a2aEnabled);
|
|
50
|
+
}
|
|
51
|
+
if (opts.publicBaseUrl !== undefined) {
|
|
52
|
+
setNestedValue(raw, "ingress.publicBaseUrl", opts.publicBaseUrl);
|
|
53
|
+
}
|
|
54
|
+
if (opts.ingressEnabled !== undefined) {
|
|
55
|
+
setNestedValue(raw, "ingress.enabled", opts.ingressEnabled);
|
|
56
|
+
}
|
|
57
|
+
saveRawConfig(raw);
|
|
58
|
+
invalidateConfigCache();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ACCEPTOR = {
|
|
62
|
+
assistantId: "acceptor-assistant-123",
|
|
63
|
+
displayName: "Acceptor Bot",
|
|
64
|
+
gatewayUrl: "https://acceptor.example.com",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("completeA2AInvite", () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
resetTables();
|
|
70
|
+
setConfig({
|
|
71
|
+
a2aEnabled: false,
|
|
72
|
+
publicBaseUrl: "https://sender.example.com",
|
|
73
|
+
ingressEnabled: true,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("happy path: promotes placeholder contact and returns sender identity", () => {
|
|
78
|
+
const created = createA2AInvite({});
|
|
79
|
+
expect(created.success).toBe(true);
|
|
80
|
+
|
|
81
|
+
const result = completeA2AInvite({
|
|
82
|
+
token: created.token!,
|
|
83
|
+
senderAssistantId: "sender-platform-id-789",
|
|
84
|
+
acceptor: ACCEPTOR,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.success).toBe(true);
|
|
88
|
+
expect(result.sender).toBeDefined();
|
|
89
|
+
expect(result.sender!.assistantId).toBe("sender-platform-id-789");
|
|
90
|
+
expect(result.sender!.gatewayUrl).toBe("https://sender.example.com");
|
|
91
|
+
expect(result.sender!.displayName).toBeDefined();
|
|
92
|
+
expect(result.error).toBeUndefined();
|
|
93
|
+
|
|
94
|
+
// Verify the placeholder contact was promoted
|
|
95
|
+
const invite = findById(created.inviteId!);
|
|
96
|
+
expect(invite).not.toBeNull();
|
|
97
|
+
|
|
98
|
+
const contact = getContact(invite!.contactId);
|
|
99
|
+
expect(contact).not.toBeNull();
|
|
100
|
+
expect(contact!.displayName).toBe("Acceptor Bot");
|
|
101
|
+
expect(contact!.channels).toHaveLength(1);
|
|
102
|
+
expect(contact!.channels[0]!.type).toBe("a2a");
|
|
103
|
+
expect(contact!.channels[0]!.status).toBe("active");
|
|
104
|
+
expect(contact!.channels[0]!.policy).toBe("allow");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("contact channel address is acceptor.assistantId.toLowerCase()", () => {
|
|
108
|
+
const created = createA2AInvite({});
|
|
109
|
+
const acceptorWithUppercase = {
|
|
110
|
+
...ACCEPTOR,
|
|
111
|
+
assistantId: "UPPER-Case-ID-123",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = completeA2AInvite({
|
|
115
|
+
token: created.token!,
|
|
116
|
+
senderAssistantId: "sender-platform-id-789",
|
|
117
|
+
acceptor: acceptorWithUppercase,
|
|
118
|
+
});
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
|
|
121
|
+
const invite = findById(created.inviteId!);
|
|
122
|
+
const contact = getContact(invite!.contactId);
|
|
123
|
+
expect(contact!.channels[0]!.address).toBe("upper-case-id-123");
|
|
124
|
+
expect(contact!.channels[0]!.externalUserId).toBe("UPPER-Case-ID-123");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("assistantContactMetadata has correct assistantId and gatewayUrl", () => {
|
|
128
|
+
const created = createA2AInvite({});
|
|
129
|
+
const result = completeA2AInvite({
|
|
130
|
+
token: created.token!,
|
|
131
|
+
senderAssistantId: "sender-platform-id-789",
|
|
132
|
+
acceptor: ACCEPTOR,
|
|
133
|
+
});
|
|
134
|
+
expect(result.success).toBe(true);
|
|
135
|
+
|
|
136
|
+
const invite = findById(created.inviteId!);
|
|
137
|
+
const metadata = getAssistantContactMetadata(invite!.contactId);
|
|
138
|
+
expect(metadata).not.toBeNull();
|
|
139
|
+
expect(metadata!.species).toBe("vellum");
|
|
140
|
+
expect(metadata!.metadata).toEqual({
|
|
141
|
+
assistantId: "acceptor-assistant-123",
|
|
142
|
+
gatewayUrl: "https://acceptor.example.com",
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("invalid token returns not_found error", () => {
|
|
147
|
+
const result = completeA2AInvite({
|
|
148
|
+
token: "invalid-token-that-does-not-exist",
|
|
149
|
+
senderAssistantId: "sender-platform-id-789",
|
|
150
|
+
acceptor: ACCEPTOR,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.success).toBe(false);
|
|
154
|
+
expect(result.error).toBe("not_found");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("expired invite returns expired error", () => {
|
|
158
|
+
// Create an invite that expires immediately
|
|
159
|
+
const created = createA2AInvite({ expiresInHours: 0 });
|
|
160
|
+
expect(created.success).toBe(true);
|
|
161
|
+
|
|
162
|
+
// The invite was just created with 0 hours expiry, so expiresAt ~= now
|
|
163
|
+
// Manually expire it by updating the DB
|
|
164
|
+
const sqlite = getSqlite();
|
|
165
|
+
sqlite.run(
|
|
166
|
+
"UPDATE assistant_ingress_invites SET expires_at = ? WHERE id = ?",
|
|
167
|
+
[Date.now() - 1000, created.inviteId!],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const result = completeA2AInvite({
|
|
171
|
+
token: created.token!,
|
|
172
|
+
senderAssistantId: "sender-platform-id-789",
|
|
173
|
+
acceptor: ACCEPTOR,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(result.success).toBe(false);
|
|
177
|
+
expect(result.error).toBe("expired");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("already-redeemed by same acceptor returns idempotent success", () => {
|
|
181
|
+
const created = createA2AInvite({});
|
|
182
|
+
expect(created.success).toBe(true);
|
|
183
|
+
|
|
184
|
+
// First completion
|
|
185
|
+
const first = completeA2AInvite({
|
|
186
|
+
token: created.token!,
|
|
187
|
+
senderAssistantId: "sender-platform-id-789",
|
|
188
|
+
acceptor: ACCEPTOR,
|
|
189
|
+
});
|
|
190
|
+
expect(first.success).toBe(true);
|
|
191
|
+
|
|
192
|
+
// Second completion with same acceptor
|
|
193
|
+
const second = completeA2AInvite({
|
|
194
|
+
token: created.token!,
|
|
195
|
+
senderAssistantId: "sender-platform-id-789",
|
|
196
|
+
acceptor: ACCEPTOR,
|
|
197
|
+
});
|
|
198
|
+
expect(second.success).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("already-redeemed by different acceptor returns error", () => {
|
|
202
|
+
const created = createA2AInvite({});
|
|
203
|
+
expect(created.success).toBe(true);
|
|
204
|
+
|
|
205
|
+
// First completion
|
|
206
|
+
const first = completeA2AInvite({
|
|
207
|
+
token: created.token!,
|
|
208
|
+
senderAssistantId: "sender-platform-id-789",
|
|
209
|
+
acceptor: ACCEPTOR,
|
|
210
|
+
});
|
|
211
|
+
expect(first.success).toBe(true);
|
|
212
|
+
|
|
213
|
+
// Second completion with different acceptor
|
|
214
|
+
const second = completeA2AInvite({
|
|
215
|
+
token: created.token!,
|
|
216
|
+
senderAssistantId: "sender-platform-id-789",
|
|
217
|
+
acceptor: {
|
|
218
|
+
assistantId: "different-assistant-456",
|
|
219
|
+
displayName: "Different Bot",
|
|
220
|
+
gatewayUrl: "https://different.example.com",
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(second.success).toBe(false);
|
|
225
|
+
expect(second.error).toBe("already_redeemed_by_other");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("fails before claiming token when public base URL is not configured", () => {
|
|
229
|
+
const created = createA2AInvite({});
|
|
230
|
+
expect(created.success).toBe(true);
|
|
231
|
+
|
|
232
|
+
setConfig({ publicBaseUrl: "", ingressEnabled: true });
|
|
233
|
+
|
|
234
|
+
const result = completeA2AInvite({
|
|
235
|
+
token: created.token!,
|
|
236
|
+
senderAssistantId: "sender-platform-id-789",
|
|
237
|
+
acceptor: ACCEPTOR,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.success).toBe(false);
|
|
241
|
+
expect(result.error).toContain("public base URL");
|
|
242
|
+
|
|
243
|
+
// Verify the invite was NOT consumed
|
|
244
|
+
const invite = findById(created.inviteId!);
|
|
245
|
+
expect(invite!.status).toBe("active");
|
|
246
|
+
expect(invite!.useCount).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for A2A invite creation handler.
|
|
3
|
+
*
|
|
4
|
+
* Uses the real DB (via `initializeDb()`) and the test preload which sets
|
|
5
|
+
* `VELLUM_WORKSPACE_DIR` to a per-file temp directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
mock.module("../../../util/logger.js", () => ({
|
|
11
|
+
getLogger: () =>
|
|
12
|
+
new Proxy({} as Record<string, unknown>, {
|
|
13
|
+
get: () => () => {},
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
invalidateConfigCache,
|
|
19
|
+
loadRawConfig,
|
|
20
|
+
saveRawConfig,
|
|
21
|
+
setNestedValue,
|
|
22
|
+
} from "../../../config/loader.js";
|
|
23
|
+
import { getContact } from "../../../contacts/contact-store.js";
|
|
24
|
+
import { getSqlite } from "../../../memory/db-connection.js";
|
|
25
|
+
import { initializeDb } from "../../../memory/db-init.js";
|
|
26
|
+
import { findById } from "../../../memory/invite-store.js";
|
|
27
|
+
import { createA2AInvite, getA2AConfig } from "../config-a2a.js";
|
|
28
|
+
|
|
29
|
+
initializeDb();
|
|
30
|
+
|
|
31
|
+
function resetTables(): void {
|
|
32
|
+
const sqlite = getSqlite();
|
|
33
|
+
sqlite.run("DELETE FROM assistant_ingress_invites");
|
|
34
|
+
sqlite.run("DELETE FROM assistant_contact_metadata");
|
|
35
|
+
sqlite.run("DELETE FROM contact_channels");
|
|
36
|
+
sqlite.run("DELETE FROM contacts");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setConfig(opts: {
|
|
40
|
+
a2aEnabled?: boolean;
|
|
41
|
+
publicBaseUrl?: string;
|
|
42
|
+
ingressEnabled?: boolean;
|
|
43
|
+
}): void {
|
|
44
|
+
const raw = loadRawConfig();
|
|
45
|
+
if (opts.a2aEnabled !== undefined) {
|
|
46
|
+
setNestedValue(raw, "a2a.enabled", opts.a2aEnabled);
|
|
47
|
+
}
|
|
48
|
+
if (opts.publicBaseUrl !== undefined) {
|
|
49
|
+
setNestedValue(raw, "ingress.publicBaseUrl", opts.publicBaseUrl);
|
|
50
|
+
}
|
|
51
|
+
if (opts.ingressEnabled !== undefined) {
|
|
52
|
+
setNestedValue(raw, "ingress.enabled", opts.ingressEnabled);
|
|
53
|
+
}
|
|
54
|
+
saveRawConfig(raw);
|
|
55
|
+
invalidateConfigCache();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("createA2AInvite", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
resetTables();
|
|
61
|
+
setConfig({
|
|
62
|
+
a2aEnabled: false,
|
|
63
|
+
publicBaseUrl: "https://example.vellum.ai",
|
|
64
|
+
ingressEnabled: true,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns success with inviteId, token, expiresAt, senderGatewayUrl", () => {
|
|
69
|
+
const result = createA2AInvite({});
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
expect(result.inviteId).toBeDefined();
|
|
72
|
+
expect(result.token).toBeDefined();
|
|
73
|
+
expect(result.expiresAt).toBeDefined();
|
|
74
|
+
expect(result.senderGatewayUrl).toBe("https://example.vellum.ai");
|
|
75
|
+
expect(result.error).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("creates invite in DB with source_channel=a2a, status=active, max_uses=1", () => {
|
|
79
|
+
const result = createA2AInvite({});
|
|
80
|
+
expect(result.inviteId).toBeDefined();
|
|
81
|
+
|
|
82
|
+
const invite = findById(result.inviteId!);
|
|
83
|
+
expect(invite).not.toBeNull();
|
|
84
|
+
expect(invite!.sourceChannel).toBe("a2a");
|
|
85
|
+
expect(invite!.status).toBe("active");
|
|
86
|
+
expect(invite!.maxUses).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("auto-enables A2A if not already on", () => {
|
|
90
|
+
// A2A starts disabled
|
|
91
|
+
expect(getA2AConfig().enabled).toBe(false);
|
|
92
|
+
|
|
93
|
+
const result = createA2AInvite({});
|
|
94
|
+
expect(result.success).toBe(true);
|
|
95
|
+
|
|
96
|
+
// A2A should now be enabled
|
|
97
|
+
expect(getA2AConfig().enabled).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("creates placeholder contact with no channels, bound to invite via contactId", () => {
|
|
101
|
+
const result = createA2AInvite({});
|
|
102
|
+
expect(result.inviteId).toBeDefined();
|
|
103
|
+
|
|
104
|
+
const invite = findById(result.inviteId!);
|
|
105
|
+
expect(invite).not.toBeNull();
|
|
106
|
+
|
|
107
|
+
const contact = getContact(invite!.contactId);
|
|
108
|
+
expect(contact).not.toBeNull();
|
|
109
|
+
expect(contact!.displayName).toBe("Pending A2A invite");
|
|
110
|
+
expect(contact!.channels).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("returns error when public base URL is not configured", () => {
|
|
114
|
+
setConfig({
|
|
115
|
+
a2aEnabled: false,
|
|
116
|
+
publicBaseUrl: "",
|
|
117
|
+
ingressEnabled: true,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = createA2AInvite({});
|
|
121
|
+
expect(result.success).toBe(false);
|
|
122
|
+
expect(result.error).toContain("public base URL");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("custom expiry via expiresInHours", () => {
|
|
126
|
+
const before = Date.now();
|
|
127
|
+
const result = createA2AInvite({ expiresInHours: 24 });
|
|
128
|
+
const after = Date.now();
|
|
129
|
+
expect(result.success).toBe(true);
|
|
130
|
+
|
|
131
|
+
const invite = findById(result.inviteId!);
|
|
132
|
+
expect(invite).not.toBeNull();
|
|
133
|
+
|
|
134
|
+
const expectedMinMs = before + 24 * 60 * 60 * 1000;
|
|
135
|
+
const expectedMaxMs = after + 24 * 60 * 60 * 1000;
|
|
136
|
+
expect(invite!.expiresAt).toBeGreaterThanOrEqual(expectedMinMs);
|
|
137
|
+
expect(invite!.expiresAt).toBeLessThanOrEqual(expectedMaxMs);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("default expiry is 72 hours", () => {
|
|
141
|
+
const before = Date.now();
|
|
142
|
+
const result = createA2AInvite({});
|
|
143
|
+
const after = Date.now();
|
|
144
|
+
expect(result.success).toBe(true);
|
|
145
|
+
|
|
146
|
+
const invite = findById(result.inviteId!);
|
|
147
|
+
expect(invite).not.toBeNull();
|
|
148
|
+
|
|
149
|
+
const expectedMinMs = before + 72 * 60 * 60 * 1000;
|
|
150
|
+
const expectedMaxMs = after + 72 * 60 * 60 * 1000;
|
|
151
|
+
expect(invite!.expiresAt).toBeGreaterThanOrEqual(expectedMinMs);
|
|
152
|
+
expect(invite!.expiresAt).toBeLessThanOrEqual(expectedMaxMs);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for A2A invite redemption handler (receiver side).
|
|
3
|
+
*
|
|
4
|
+
* Uses the real DB (via `initializeDb()`) and the test preload which sets
|
|
5
|
+
* `VELLUM_WORKSPACE_DIR` to a per-file temp directory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
mock.module("../../../util/logger.js", () => ({
|
|
11
|
+
getLogger: () =>
|
|
12
|
+
new Proxy({} as Record<string, unknown>, {
|
|
13
|
+
get: () => () => {},
|
|
14
|
+
}),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
invalidateConfigCache,
|
|
19
|
+
loadRawConfig,
|
|
20
|
+
saveRawConfig,
|
|
21
|
+
setNestedValue,
|
|
22
|
+
} from "../../../config/loader.js";
|
|
23
|
+
import {
|
|
24
|
+
getAssistantContactMetadata,
|
|
25
|
+
getContact,
|
|
26
|
+
} from "../../../contacts/contact-store.js";
|
|
27
|
+
import { getSqlite } from "../../../memory/db-connection.js";
|
|
28
|
+
import { initializeDb } from "../../../memory/db-init.js";
|
|
29
|
+
import { getA2AConfig, redeemA2AInvite } from "../config-a2a.js";
|
|
30
|
+
|
|
31
|
+
initializeDb();
|
|
32
|
+
|
|
33
|
+
function resetTables(): void {
|
|
34
|
+
const sqlite = getSqlite();
|
|
35
|
+
sqlite.run("DELETE FROM assistant_contact_metadata");
|
|
36
|
+
sqlite.run("DELETE FROM contact_channels");
|
|
37
|
+
sqlite.run("DELETE FROM contacts");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setConfig(opts: { a2aEnabled?: boolean }): void {
|
|
41
|
+
const raw = loadRawConfig();
|
|
42
|
+
if (opts.a2aEnabled !== undefined) {
|
|
43
|
+
setNestedValue(raw, "a2a.enabled", opts.a2aEnabled);
|
|
44
|
+
}
|
|
45
|
+
saveRawConfig(raw);
|
|
46
|
+
invalidateConfigCache();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SENDER = {
|
|
50
|
+
assistantId: "sender-assistant-123",
|
|
51
|
+
displayName: "Sender Bot",
|
|
52
|
+
gatewayUrl: "https://sender.example.com",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe("redeemA2AInvite", () => {
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
resetTables();
|
|
58
|
+
setConfig({ a2aEnabled: false });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("happy path: creates local contact with sender identity", () => {
|
|
62
|
+
const result = redeemA2AInvite({ sender: SENDER });
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
expect(result.contactId).toBeDefined();
|
|
66
|
+
expect(result.alreadyConnected).toBeUndefined();
|
|
67
|
+
expect(result.error).toBeUndefined();
|
|
68
|
+
|
|
69
|
+
const contact = getContact(result.contactId!);
|
|
70
|
+
expect(contact).not.toBeNull();
|
|
71
|
+
expect(contact!.displayName).toBe("Sender Bot");
|
|
72
|
+
expect(contact!.channels).toHaveLength(1);
|
|
73
|
+
expect(contact!.channels[0]!.type).toBe("a2a");
|
|
74
|
+
expect(contact!.channels[0]!.status).toBe("active");
|
|
75
|
+
expect(contact!.channels[0]!.policy).toBe("allow");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("idempotency: already-connected sender returns alreadyConnected", () => {
|
|
79
|
+
const first = redeemA2AInvite({ sender: SENDER });
|
|
80
|
+
expect(first.success).toBe(true);
|
|
81
|
+
|
|
82
|
+
const second = redeemA2AInvite({ sender: SENDER });
|
|
83
|
+
expect(second.success).toBe(true);
|
|
84
|
+
expect(second.alreadyConnected).toBe(true);
|
|
85
|
+
expect(second.contactId).toBe(first.contactId);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("auto-enables A2A if disabled", () => {
|
|
89
|
+
setConfig({ a2aEnabled: false });
|
|
90
|
+
|
|
91
|
+
const result = redeemA2AInvite({ sender: SENDER });
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
|
|
94
|
+
// Verify A2A was auto-enabled by checking config
|
|
95
|
+
const config = getA2AConfig();
|
|
96
|
+
expect(config.enabled).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("assistantContactMetadata has correct assistantId and gatewayUrl", () => {
|
|
100
|
+
const result = redeemA2AInvite({ sender: SENDER });
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
|
|
103
|
+
const metadata = getAssistantContactMetadata(result.contactId!);
|
|
104
|
+
expect(metadata).not.toBeNull();
|
|
105
|
+
expect(metadata!.species).toBe("vellum");
|
|
106
|
+
expect(metadata!.metadata).toEqual({
|
|
107
|
+
assistantId: "sender-assistant-123",
|
|
108
|
+
gatewayUrl: "https://sender.example.com",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("channel address uses sender.assistantId.toLowerCase()", () => {
|
|
113
|
+
const senderWithUppercase = {
|
|
114
|
+
...SENDER,
|
|
115
|
+
assistantId: "UPPER-Case-SENDER-ID",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const result = redeemA2AInvite({ sender: senderWithUppercase });
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
|
|
121
|
+
const contact = getContact(result.contactId!);
|
|
122
|
+
expect(contact!.channels[0]!.address).toBe("upper-case-sender-id");
|
|
123
|
+
expect(contact!.channels[0]!.externalUserId).toBe("UPPER-Case-SENDER-ID");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("does not make outbound fetch calls", () => {
|
|
127
|
+
// This test verifies that redeemA2AInvite is purely local — no fetch mock
|
|
128
|
+
// is installed. If it tried to fetch, the call would fail and the test
|
|
129
|
+
// would throw.
|
|
130
|
+
const result = redeemA2AInvite({ sender: SENDER });
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|