@vellumai/assistant 0.4.35 → 0.4.37
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +44 -49
- package/README.md +32 -20
- package/docs/architecture/keychain-broker.md +186 -0
- package/docs/architecture/security.md +110 -116
- package/docs/runbook-trusted-contacts.md +2 -2
- package/docs/skills.md +25 -25
- package/package.json +5 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +11 -2
- package/src/__tests__/actor-token-service.test.ts +1 -0
- package/src/__tests__/amazon-cdp-integration.test.ts +74 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +38 -9
- package/src/__tests__/assistant-id-boundary-guard.test.ts +29 -0
- package/src/__tests__/browser-fill-credential.test.ts +1 -1
- package/src/__tests__/bundle-scanner.test.ts +1 -1
- package/src/__tests__/channel-guardian.test.ts +102 -102
- package/src/__tests__/channel-invite-transport.test.ts +155 -256
- package/src/__tests__/channel-readiness-routes.test.ts +336 -0
- package/src/__tests__/checker.test.ts +6 -6
- package/src/__tests__/chrome-cdp.test.ts +350 -0
- package/src/__tests__/computer-use-session-lifecycle.test.ts +3 -3
- package/src/__tests__/computer-use-session-working-dir.test.ts +86 -52
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +1 -1
- package/src/__tests__/config-loader-migration.test.ts +85 -0
- package/src/__tests__/conversation-pairing.test.ts +370 -5
- package/src/__tests__/credential-broker-browser-fill.test.ts +1 -10
- package/src/__tests__/credential-broker-server-use.test.ts +1 -10
- package/src/__tests__/credential-security-e2e.test.ts +7 -1
- package/src/__tests__/credential-security-invariants.test.ts +14 -20
- package/src/__tests__/credential-vault-unit.test.ts +1 -11
- package/src/__tests__/credential-vault.test.ts +5 -19
- package/src/__tests__/credentials-cli.test.ts +814 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +23 -4
- package/src/__tests__/email-invite-adapter.test.ts +78 -0
- package/src/__tests__/email-service-config-fallback.test.ts +102 -0
- package/src/__tests__/encrypted-store.test.ts +6 -6
- package/src/__tests__/ephemeral-permissions.test.ts +3 -3
- package/src/__tests__/gateway-only-enforcement.test.ts +5 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +70 -12
- package/src/__tests__/guardian-outbound-http.test.ts +53 -47
- package/src/__tests__/handle-user-message-secret-resume.test.ts +23 -0
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +32 -23
- package/src/__tests__/handlers-telegram-config.test.ts +8 -2
- package/src/__tests__/handlers-twitter-config.test.ts +2 -2
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +108 -7
- package/src/__tests__/ingress-reconcile.test.ts +6 -0
- package/src/__tests__/intent-routing.test.ts +23 -4
- package/src/__tests__/invite-routes-http.test.ts +12 -0
- package/src/__tests__/ipc-snapshot.test.ts +8 -2
- package/src/__tests__/keychain-broker-client.test.ts +543 -0
- package/src/__tests__/llm-usage-store.test.ts +344 -0
- package/src/__tests__/mcp-client-auth.test.ts +2 -2
- package/src/__tests__/media-reuse-story.e2e.test.ts +1 -1
- package/src/__tests__/migration-transport.test.ts +49 -0
- package/src/__tests__/notification-broadcaster.test.ts +205 -5
- package/src/__tests__/notification-deep-link.test.ts +365 -1
- package/src/__tests__/oauth-connect-handler.test.ts +2 -2
- package/src/__tests__/onboarding-starter-tasks.test.ts +17 -4
- package/src/__tests__/proxy-approval-callback.test.ts +1 -1
- package/src/__tests__/recording-handler.test.ts +1 -1
- package/src/__tests__/recording-intent-handler.test.ts +6 -1
- package/src/__tests__/recording-state-machine.test.ts +1 -1
- package/src/__tests__/relay-server.test.ts +9 -1
- package/src/__tests__/ride-shotgun-handler.test.ts +499 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +160 -1
- package/src/__tests__/script-proxy-injection-runtime.test.ts +299 -2
- package/src/__tests__/script-proxy-profile-template-fallback.test.ts +1 -1
- package/src/__tests__/secret-onetime-send.test.ts +8 -2
- package/src/__tests__/secure-keys.test.ts +175 -216
- package/src/__tests__/session-confirmation-signals.test.ts +1 -1
- package/src/__tests__/session-messaging-secret-redirect.test.ts +1 -1
- package/src/__tests__/session-queue.test.ts +2 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +2 -2
- package/src/__tests__/skill-feature-flags-integration.test.ts +29 -4
- package/src/__tests__/skill-feature-flags.test.ts +12 -9
- package/src/__tests__/skill-load-feature-flag.test.ts +26 -5
- package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
- package/src/__tests__/skills.test.ts +34 -4
- package/src/__tests__/slack-channel-config.test.ts +2 -2
- package/src/__tests__/system-prompt.test.ts +26 -4
- package/src/__tests__/telegram-bot-username-resolution.test.ts +212 -0
- package/src/__tests__/telegram-invite-adapter.test.ts +164 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -1
- package/src/__tests__/tool-permission-simulate-handler.test.ts +8 -2
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +9 -1
- package/src/__tests__/twitter-auth-handler.test.ts +2 -2
- package/src/__tests__/twitter-oauth-client.test.ts +1 -1
- package/src/__tests__/usage-routes.test.ts +339 -0
- package/src/__tests__/whatsapp-invite-adapter.test.ts +94 -0
- package/src/agent/loop.ts +3 -0
- package/src/amazon/checkout.ts +0 -1
- package/src/approvals/guardian-request-resolvers.ts +9 -1
- package/src/bundler/app-bundler.ts +28 -12
- package/src/bundler/bundle-scanner.ts +1 -1
- package/src/bundler/bundle-signer.ts +3 -3
- package/src/bundler/manifest.ts +1 -1
- package/src/bundler/signature-verifier.ts +3 -3
- package/src/channels/config.ts +1 -1
- package/src/cli/AGENTS.md +63 -0
- package/src/cli/__tests__/notifications.test.ts +470 -0
- package/src/cli/amazon.ts +344 -167
- package/src/cli/audit.ts +85 -0
- package/src/cli/autonomy.ts +369 -0
- package/src/cli/channels.ts +51 -0
- package/src/cli/completions.ts +208 -0
- package/src/cli/config.ts +220 -0
- package/src/cli/contacts.ts +471 -0
- package/src/cli/credentials.ts +564 -0
- package/src/cli/default-action.ts +14 -0
- package/src/cli/dev.ts +131 -0
- package/src/cli/doctor.ts +398 -0
- package/src/cli/email.ts +494 -0
- package/src/cli/influencer.ts +72 -0
- package/src/cli/integrations.ts +248 -57
- package/src/cli/keys.ts +114 -0
- package/src/cli/map.ts +46 -54
- package/src/cli/mcp.ts +111 -3
- package/src/cli/{config-commands.ts → memory.ts} +134 -245
- package/src/cli/notifications.ts +407 -0
- package/src/cli/program.ts +65 -0
- package/src/cli/reference.ts +48 -0
- package/src/cli/sequence.ts +154 -0
- package/src/cli/sessions.ts +262 -0
- package/src/cli/trust.ts +175 -0
- package/src/cli/twitter.ts +323 -106
- package/src/config/__tests__/build-cli-reference-section.test.ts +49 -0
- package/src/config/bundled-skills/amazon/SKILL.md +2 -2
- package/src/config/bundled-skills/app-builder/TOOLS.json +26 -0
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +13 -0
- package/src/config/bundled-skills/contacts/SKILL.md +178 -10
- package/src/config/bundled-skills/doordash/doordash-cli.ts +23 -168
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +135 -34
- package/src/config/bundled-skills/messaging/tools/shared.ts +4 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +70 -17
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/core-schema.ts +7 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/loader.ts +26 -0
- package/src/config/schema.ts +4 -0
- package/src/config/skill-state.ts +0 -13
- package/src/config/system-prompt.ts +27 -0
- package/src/contacts/contact-store.ts +25 -0
- package/src/daemon/computer-use-session.ts +1 -1
- package/src/daemon/handlers/apps.ts +1 -0
- package/src/daemon/handlers/config-channels.ts +3 -3
- package/src/daemon/handlers/config-dispatch.ts +29 -0
- package/src/daemon/handlers/config-inbox.ts +4 -3
- package/src/daemon/handlers/config.ts +3 -43
- package/src/daemon/handlers/contacts.ts +34 -0
- package/src/daemon/handlers/index.ts +17 -3
- package/src/daemon/handlers/session-user-message.ts +7 -0
- package/src/daemon/handlers/sessions.ts +21 -2
- package/src/daemon/handlers/shared.ts +17 -0
- package/src/daemon/ipc-contract/apps.ts +2 -0
- package/src/daemon/ipc-contract/computer-use.ts +9 -0
- package/src/daemon/ipc-contract/contacts.ts +3 -3
- package/src/daemon/ipc-contract/inbox.ts +2 -0
- package/src/daemon/ipc-contract/messages.ts +4 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +0 -5
- package/src/daemon/ride-shotgun-handler.ts +139 -25
- package/src/daemon/session-agent-loop-handlers.ts +100 -0
- package/src/daemon/session-agent-loop.ts +72 -0
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/daemon/session.ts +23 -1
- package/src/daemon/tool-side-effects.ts +39 -1
- package/src/email/service.ts +59 -2
- package/src/index.ts +2 -60
- package/src/mcp/mcp-oauth-provider.ts +90 -8
- package/src/media/app-icon-generator.ts +86 -0
- package/src/memory/db-init.ts +11 -0
- package/src/memory/llm-usage-store.ts +186 -0
- package/src/memory/migrations/137-usage-dashboard-indexes.ts +26 -0
- package/src/memory/migrations/139-drop-usage-composite-indexes.ts +30 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/shared-app-links-store.ts +1 -1
- package/src/messaging/registry.ts +27 -0
- package/src/notifications/README.md +79 -70
- package/src/notifications/broadcaster.ts +2 -1
- package/src/notifications/conversation-pairing.ts +147 -13
- package/src/notifications/copy-composer.ts +7 -3
- package/src/notifications/destination-resolver.ts +14 -1
- package/src/notifications/emit-signal.ts +3 -2
- package/src/notifications/signal.ts +105 -1
- package/src/notifications/types.ts +16 -0
- package/src/permissions/checker.ts +29 -3
- package/src/permissions/prompter.ts +11 -3
- package/src/runtime/access-request-helper.ts +2 -1
- package/src/runtime/auth/route-policy.ts +7 -1
- package/src/runtime/channel-invite-transport.ts +40 -63
- package/src/runtime/channel-invite-transports/email.ts +13 -39
- package/src/runtime/channel-invite-transports/slack.ts +5 -34
- package/src/runtime/channel-invite-transports/sms.ts +8 -29
- package/src/runtime/channel-invite-transports/telegram.ts +69 -28
- package/src/runtime/channel-invite-transports/voice.ts +0 -7
- package/src/runtime/channel-invite-transports/whatsapp.ts +43 -0
- package/src/runtime/channel-readiness-service.ts +202 -45
- package/src/runtime/confirmation-request-guardian-bridge.ts +2 -1
- package/src/runtime/guardian-outbound-actions.ts +8 -5
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/invite-instruction-generator.ts +178 -0
- package/src/runtime/invite-service.ts +22 -25
- package/src/runtime/migrations/migration-transport.ts +13 -0
- package/src/runtime/routes/app-routes.ts +1 -1
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +8 -7
- package/src/runtime/routes/channel-readiness-routes.ts +30 -11
- package/src/runtime/routes/contact-routes.ts +54 -26
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +1 -1
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +2 -1
- package/src/runtime/routes/inbound-stages/verification-intercept.ts +2 -1
- package/src/runtime/routes/integration-routes.ts +1 -1
- package/src/runtime/routes/invite-routes.ts +1 -1
- package/src/runtime/routes/secret-routes.ts +31 -7
- package/src/runtime/routes/twilio-routes.ts +32 -1
- package/src/runtime/routes/usage-routes.ts +114 -0
- package/src/runtime/tool-grant-request-helper.ts +2 -1
- package/src/security/encrypted-store.ts +9 -5
- package/src/security/keychain-broker-client.ts +393 -0
- package/src/security/secure-keys.ts +106 -321
- package/src/tools/apps/executors.ts +73 -0
- package/src/tools/browser/auto-navigate.ts +15 -6
- package/src/tools/browser/chrome-cdp.ts +211 -0
- package/src/tools/browser/network-recorder.test.ts +83 -0
- package/src/tools/browser/network-recorder.ts +8 -7
- package/src/tools/browser/x-auto-navigate.ts +12 -6
- package/src/tools/credentials/policy-types.ts +24 -0
- package/src/tools/credentials/vault.ts +22 -27
- package/src/tools/network/script-proxy/session-manager.ts +47 -3
- package/src/tools/permission-checker.ts +1 -0
- package/src/tools/types.ts +2 -0
- package/src/tools/ui-surface/definitions.ts +1 -2
- package/src/tools/watch/watch-state.ts +2 -0
- package/src/__tests__/key-migration.test.ts +0 -240
- package/src/__tests__/keychain.test.ts +0 -286
- package/src/cli/core-commands.ts +0 -899
- package/src/security/keychain-to-encrypted-migration.ts +0 -66
- package/src/security/keychain.ts +0 -490
|
@@ -26,6 +26,9 @@ mock.module("../util/logger.js", () => ({
|
|
|
26
26
|
|
|
27
27
|
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
28
28
|
import {
|
|
29
|
+
getUsageDayBuckets,
|
|
30
|
+
getUsageGroupBreakdown,
|
|
31
|
+
getUsageTotals,
|
|
29
32
|
listUsageEvents,
|
|
30
33
|
recordUsageEvent,
|
|
31
34
|
} from "../memory/llm-usage-store.js";
|
|
@@ -69,6 +72,19 @@ const unpricedResult: PricingResult = {
|
|
|
69
72
|
pricingStatus: "unpriced",
|
|
70
73
|
};
|
|
71
74
|
|
|
75
|
+
/** Insert an event at a specific epoch-millis timestamp. */
|
|
76
|
+
function insertEventAt(
|
|
77
|
+
timestamp: number,
|
|
78
|
+
inputOverrides?: Partial<UsageEventInput>,
|
|
79
|
+
pricing: PricingResult = pricedResult,
|
|
80
|
+
): void {
|
|
81
|
+
const event = recordUsageEvent(makeInput(inputOverrides), pricing);
|
|
82
|
+
const db = getDb();
|
|
83
|
+
db.run(
|
|
84
|
+
`UPDATE llm_usage_events SET created_at = ${timestamp} WHERE id = '${event.id}'`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
describe("recordUsageEvent", () => {
|
|
73
89
|
beforeEach(() => {
|
|
74
90
|
const db = getDb();
|
|
@@ -245,3 +261,331 @@ describe("listUsageEvents", () => {
|
|
|
245
261
|
expect(typeof event.pricingStatus).toBe("string");
|
|
246
262
|
});
|
|
247
263
|
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Aggregation query tests
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe("getUsageTotals", () => {
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
const db = getDb();
|
|
272
|
+
db.run(`DELETE FROM llm_usage_events`);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("returns zeros when no events exist in range", () => {
|
|
276
|
+
const totals = getUsageTotals({ from: 0, to: 99999 });
|
|
277
|
+
expect(totals.totalInputTokens).toBe(0);
|
|
278
|
+
expect(totals.totalOutputTokens).toBe(0);
|
|
279
|
+
expect(totals.totalCacheCreationTokens).toBe(0);
|
|
280
|
+
expect(totals.totalCacheReadTokens).toBe(0);
|
|
281
|
+
expect(totals.totalEstimatedCostUsd).toBe(0);
|
|
282
|
+
expect(totals.eventCount).toBe(0);
|
|
283
|
+
expect(totals.pricedEventCount).toBe(0);
|
|
284
|
+
expect(totals.unpricedEventCount).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("sums tokens and cost across priced events", () => {
|
|
288
|
+
insertEventAt(
|
|
289
|
+
1000,
|
|
290
|
+
{ inputTokens: 100, outputTokens: 50 },
|
|
291
|
+
{
|
|
292
|
+
estimatedCostUsd: 0.01,
|
|
293
|
+
pricingStatus: "priced",
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
insertEventAt(
|
|
297
|
+
2000,
|
|
298
|
+
{ inputTokens: 200, outputTokens: 100 },
|
|
299
|
+
{
|
|
300
|
+
estimatedCostUsd: 0.02,
|
|
301
|
+
pricingStatus: "priced",
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const totals = getUsageTotals({ from: 0, to: 5000 });
|
|
306
|
+
expect(totals.totalInputTokens).toBe(300);
|
|
307
|
+
expect(totals.totalOutputTokens).toBe(150);
|
|
308
|
+
expect(totals.totalEstimatedCostUsd).toBeCloseTo(0.03);
|
|
309
|
+
expect(totals.eventCount).toBe(2);
|
|
310
|
+
expect(totals.pricedEventCount).toBe(2);
|
|
311
|
+
expect(totals.unpricedEventCount).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("counts priced and unpriced events separately", () => {
|
|
315
|
+
insertEventAt(1000, {}, pricedResult);
|
|
316
|
+
insertEventAt(
|
|
317
|
+
2000,
|
|
318
|
+
{ provider: "ollama", model: "llama3" },
|
|
319
|
+
unpricedResult,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const totals = getUsageTotals({ from: 0, to: 5000 });
|
|
323
|
+
expect(totals.eventCount).toBe(2);
|
|
324
|
+
expect(totals.pricedEventCount).toBe(1);
|
|
325
|
+
expect(totals.unpricedEventCount).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("respects time range boundaries (inclusive)", () => {
|
|
329
|
+
insertEventAt(1000);
|
|
330
|
+
insertEventAt(2000);
|
|
331
|
+
insertEventAt(3000);
|
|
332
|
+
|
|
333
|
+
// Only the middle event
|
|
334
|
+
const totals = getUsageTotals({ from: 2000, to: 2000 });
|
|
335
|
+
expect(totals.eventCount).toBe(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("excludes events outside the time range", () => {
|
|
339
|
+
insertEventAt(500);
|
|
340
|
+
insertEventAt(5000);
|
|
341
|
+
|
|
342
|
+
const totals = getUsageTotals({ from: 1000, to: 4000 });
|
|
343
|
+
expect(totals.eventCount).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("sums cache tokens including nulls", () => {
|
|
347
|
+
insertEventAt(1000, {
|
|
348
|
+
cacheCreationInputTokens: 50,
|
|
349
|
+
cacheReadInputTokens: 100,
|
|
350
|
+
});
|
|
351
|
+
insertEventAt(2000, {
|
|
352
|
+
cacheCreationInputTokens: null,
|
|
353
|
+
cacheReadInputTokens: null,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const totals = getUsageTotals({ from: 0, to: 5000 });
|
|
357
|
+
expect(totals.totalCacheCreationTokens).toBe(50);
|
|
358
|
+
expect(totals.totalCacheReadTokens).toBe(100);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("getUsageDayBuckets", () => {
|
|
363
|
+
beforeEach(() => {
|
|
364
|
+
const db = getDb();
|
|
365
|
+
db.run(`DELETE FROM llm_usage_events`);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Helper: epoch millis for a UTC date
|
|
369
|
+
function utcMs(year: number, month: number, day: number, hour = 0): number {
|
|
370
|
+
return Date.UTC(year, month - 1, day, hour);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
test("returns empty array when no events exist", () => {
|
|
374
|
+
const buckets = getUsageDayBuckets({ from: 0, to: 99999999999 });
|
|
375
|
+
expect(buckets).toHaveLength(0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("groups events into correct day buckets", () => {
|
|
379
|
+
const day1Start = utcMs(2025, 3, 1, 0);
|
|
380
|
+
const day1Mid = utcMs(2025, 3, 1, 12);
|
|
381
|
+
const day2Start = utcMs(2025, 3, 2, 6);
|
|
382
|
+
|
|
383
|
+
insertEventAt(day1Start, { inputTokens: 100, outputTokens: 10 });
|
|
384
|
+
insertEventAt(day1Mid, { inputTokens: 200, outputTokens: 20 });
|
|
385
|
+
insertEventAt(day2Start, { inputTokens: 300, outputTokens: 30 });
|
|
386
|
+
|
|
387
|
+
const buckets = getUsageDayBuckets({
|
|
388
|
+
from: utcMs(2025, 3, 1),
|
|
389
|
+
to: utcMs(2025, 3, 3),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
expect(buckets).toHaveLength(2);
|
|
393
|
+
expect(buckets[0].date).toBe("2025-03-01");
|
|
394
|
+
expect(buckets[0].totalInputTokens).toBe(300);
|
|
395
|
+
expect(buckets[0].totalOutputTokens).toBe(30);
|
|
396
|
+
expect(buckets[0].eventCount).toBe(2);
|
|
397
|
+
|
|
398
|
+
expect(buckets[1].date).toBe("2025-03-02");
|
|
399
|
+
expect(buckets[1].totalInputTokens).toBe(300);
|
|
400
|
+
expect(buckets[1].totalOutputTokens).toBe(30);
|
|
401
|
+
expect(buckets[1].eventCount).toBe(1);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("buckets are ordered by date ascending", () => {
|
|
405
|
+
insertEventAt(utcMs(2025, 3, 3));
|
|
406
|
+
insertEventAt(utcMs(2025, 3, 1));
|
|
407
|
+
insertEventAt(utcMs(2025, 3, 2));
|
|
408
|
+
|
|
409
|
+
const buckets = getUsageDayBuckets({
|
|
410
|
+
from: utcMs(2025, 3, 1),
|
|
411
|
+
to: utcMs(2025, 3, 4),
|
|
412
|
+
});
|
|
413
|
+
expect(buckets.map((b) => b.date)).toEqual([
|
|
414
|
+
"2025-03-01",
|
|
415
|
+
"2025-03-02",
|
|
416
|
+
"2025-03-03",
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("handles day boundary correctly (midnight UTC)", () => {
|
|
421
|
+
// Last millisecond of March 1 and first millisecond of March 2
|
|
422
|
+
const endOfDay1 = utcMs(2025, 3, 1, 23) + 59 * 60 * 1000 + 59 * 1000;
|
|
423
|
+
const startOfDay2 = utcMs(2025, 3, 2, 0);
|
|
424
|
+
|
|
425
|
+
insertEventAt(endOfDay1, { inputTokens: 111 });
|
|
426
|
+
insertEventAt(startOfDay2, { inputTokens: 222 });
|
|
427
|
+
|
|
428
|
+
const buckets = getUsageDayBuckets({
|
|
429
|
+
from: utcMs(2025, 3, 1),
|
|
430
|
+
to: utcMs(2025, 3, 3),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(buckets).toHaveLength(2);
|
|
434
|
+
expect(buckets[0].date).toBe("2025-03-01");
|
|
435
|
+
expect(buckets[0].totalInputTokens).toBe(111);
|
|
436
|
+
expect(buckets[1].date).toBe("2025-03-02");
|
|
437
|
+
expect(buckets[1].totalInputTokens).toBe(222);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("sums cost correctly with mixed priced/unpriced events", () => {
|
|
441
|
+
const day = utcMs(2025, 3, 1);
|
|
442
|
+
insertEventAt(day, {}, { estimatedCostUsd: 0.05, pricingStatus: "priced" });
|
|
443
|
+
insertEventAt(day + 1000, { provider: "ollama" }, unpricedResult);
|
|
444
|
+
|
|
445
|
+
const buckets = getUsageDayBuckets({
|
|
446
|
+
from: utcMs(2025, 3, 1),
|
|
447
|
+
to: utcMs(2025, 3, 2),
|
|
448
|
+
});
|
|
449
|
+
expect(buckets).toHaveLength(1);
|
|
450
|
+
expect(buckets[0].totalEstimatedCostUsd).toBeCloseTo(0.05);
|
|
451
|
+
expect(buckets[0].eventCount).toBe(2);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("getUsageGroupBreakdown", () => {
|
|
456
|
+
beforeEach(() => {
|
|
457
|
+
const db = getDb();
|
|
458
|
+
db.run(`DELETE FROM llm_usage_events`);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("returns empty array when no events exist", () => {
|
|
462
|
+
const groups = getUsageGroupBreakdown({ from: 0, to: 99999 }, "actor");
|
|
463
|
+
expect(groups).toHaveLength(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("groups by actor", () => {
|
|
467
|
+
insertEventAt(
|
|
468
|
+
1000,
|
|
469
|
+
{ actor: "main_agent", inputTokens: 100 },
|
|
470
|
+
{
|
|
471
|
+
estimatedCostUsd: 0.01,
|
|
472
|
+
pricingStatus: "priced",
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
insertEventAt(
|
|
476
|
+
2000,
|
|
477
|
+
{ actor: "main_agent", inputTokens: 200 },
|
|
478
|
+
{
|
|
479
|
+
estimatedCostUsd: 0.02,
|
|
480
|
+
pricingStatus: "priced",
|
|
481
|
+
},
|
|
482
|
+
);
|
|
483
|
+
insertEventAt(
|
|
484
|
+
3000,
|
|
485
|
+
{ actor: "title_generator", inputTokens: 50 },
|
|
486
|
+
{
|
|
487
|
+
estimatedCostUsd: 0.005,
|
|
488
|
+
pricingStatus: "priced",
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const groups = getUsageGroupBreakdown({ from: 0, to: 5000 }, "actor");
|
|
493
|
+
expect(groups).toHaveLength(2);
|
|
494
|
+
|
|
495
|
+
// Ordered by cost descending
|
|
496
|
+
expect(groups[0].group).toBe("main_agent");
|
|
497
|
+
expect(groups[0].totalInputTokens).toBe(300);
|
|
498
|
+
expect(groups[0].totalEstimatedCostUsd).toBeCloseTo(0.03);
|
|
499
|
+
expect(groups[0].eventCount).toBe(2);
|
|
500
|
+
|
|
501
|
+
expect(groups[1].group).toBe("title_generator");
|
|
502
|
+
expect(groups[1].totalInputTokens).toBe(50);
|
|
503
|
+
expect(groups[1].eventCount).toBe(1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("groups by provider", () => {
|
|
507
|
+
insertEventAt(
|
|
508
|
+
1000,
|
|
509
|
+
{ provider: "anthropic" },
|
|
510
|
+
{
|
|
511
|
+
estimatedCostUsd: 0.05,
|
|
512
|
+
pricingStatus: "priced",
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
insertEventAt(2000, { provider: "ollama" }, unpricedResult);
|
|
516
|
+
|
|
517
|
+
const groups = getUsageGroupBreakdown({ from: 0, to: 5000 }, "provider");
|
|
518
|
+
expect(groups).toHaveLength(2);
|
|
519
|
+
expect(groups[0].group).toBe("anthropic");
|
|
520
|
+
expect(groups[0].totalEstimatedCostUsd).toBeCloseTo(0.05);
|
|
521
|
+
expect(groups[1].group).toBe("ollama");
|
|
522
|
+
expect(groups[1].totalEstimatedCostUsd).toBe(0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("groups by model", () => {
|
|
526
|
+
insertEventAt(
|
|
527
|
+
1000,
|
|
528
|
+
{ model: "claude-sonnet-4-20250514" },
|
|
529
|
+
{
|
|
530
|
+
estimatedCostUsd: 0.03,
|
|
531
|
+
pricingStatus: "priced",
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
insertEventAt(
|
|
535
|
+
2000,
|
|
536
|
+
{ model: "claude-sonnet-4-20250514" },
|
|
537
|
+
{
|
|
538
|
+
estimatedCostUsd: 0.02,
|
|
539
|
+
pricingStatus: "priced",
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
insertEventAt(3000, { model: "llama3" }, unpricedResult);
|
|
543
|
+
|
|
544
|
+
const groups = getUsageGroupBreakdown({ from: 0, to: 5000 }, "model");
|
|
545
|
+
expect(groups).toHaveLength(2);
|
|
546
|
+
expect(groups[0].group).toBe("claude-sonnet-4-20250514");
|
|
547
|
+
expect(groups[0].totalEstimatedCostUsd).toBeCloseTo(0.05);
|
|
548
|
+
expect(groups[0].eventCount).toBe(2);
|
|
549
|
+
expect(groups[1].group).toBe("llama3");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("respects time range", () => {
|
|
553
|
+
insertEventAt(1000, { actor: "main_agent" });
|
|
554
|
+
insertEventAt(5000, { actor: "title_generator" });
|
|
555
|
+
|
|
556
|
+
const groups = getUsageGroupBreakdown({ from: 2000, to: 4000 }, "actor");
|
|
557
|
+
expect(groups).toHaveLength(0);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("orders groups by estimated cost descending", () => {
|
|
561
|
+
insertEventAt(
|
|
562
|
+
1000,
|
|
563
|
+
{ actor: "main_agent" },
|
|
564
|
+
{
|
|
565
|
+
estimatedCostUsd: 0.01,
|
|
566
|
+
pricingStatus: "priced",
|
|
567
|
+
},
|
|
568
|
+
);
|
|
569
|
+
insertEventAt(
|
|
570
|
+
2000,
|
|
571
|
+
{ actor: "title_generator" },
|
|
572
|
+
{
|
|
573
|
+
estimatedCostUsd: 0.05,
|
|
574
|
+
pricingStatus: "priced",
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
insertEventAt(
|
|
578
|
+
3000,
|
|
579
|
+
{ actor: "context_compactor" },
|
|
580
|
+
{
|
|
581
|
+
estimatedCostUsd: 0.03,
|
|
582
|
+
pricingStatus: "priced",
|
|
583
|
+
},
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const groups = getUsageGroupBreakdown({ from: 0, to: 5000 }, "actor");
|
|
587
|
+
expect(groups[0].group).toBe("title_generator");
|
|
588
|
+
expect(groups[1].group).toBe("context_compactor");
|
|
589
|
+
expect(groups[2].group).toBe("main_agent");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
@@ -3,8 +3,8 @@ import { describe, expect, jest, mock, test } from "bun:test";
|
|
|
3
3
|
// Mock secure-keys so McpOAuthProvider doesn't try to access the keychain
|
|
4
4
|
mock.module("../security/secure-keys.js", () => ({
|
|
5
5
|
getSecureKeyAsync: jest.fn().mockResolvedValue(null),
|
|
6
|
-
setSecureKeyAsync: jest.fn().mockResolvedValue(
|
|
7
|
-
deleteSecureKeyAsync: jest.fn().mockResolvedValue(
|
|
6
|
+
setSecureKeyAsync: jest.fn().mockResolvedValue(true),
|
|
7
|
+
deleteSecureKeyAsync: jest.fn().mockResolvedValue("deleted"),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
10
|
const { McpClient } = await import("../mcp/client.js");
|
|
@@ -81,7 +81,7 @@ mock.module("../tools/credentials/metadata-store.js", () => ({
|
|
|
81
81
|
mock.module("../security/secure-keys.js", () => ({
|
|
82
82
|
getSecureKey: (account: string) => secureKeyValues.get(account),
|
|
83
83
|
setSecureKey: () => true,
|
|
84
|
-
deleteSecureKey: () =>
|
|
84
|
+
deleteSecureKey: () => "deleted",
|
|
85
85
|
listSecureKeys: () => [],
|
|
86
86
|
getBackendType: () => "encrypted",
|
|
87
87
|
_resetBackend: () => {},
|
|
@@ -191,6 +191,55 @@ describe("Auth headers", () => {
|
|
|
191
191
|
expect(headers["Authorization"]).toBeUndefined();
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
test("managed request includes Vellum-Organization-Id from defaultHeaders", async () => {
|
|
195
|
+
const { fetchFn, captured } = capturingFetch(200, {
|
|
196
|
+
is_valid: true,
|
|
197
|
+
errors: [],
|
|
198
|
+
manifest: {},
|
|
199
|
+
});
|
|
200
|
+
await validateBundle(
|
|
201
|
+
managedConfig({
|
|
202
|
+
fetchFn,
|
|
203
|
+
defaultHeaders: { "Vellum-Organization-Id": "org-123" },
|
|
204
|
+
}),
|
|
205
|
+
sampleFileData,
|
|
206
|
+
);
|
|
207
|
+
const headers = captured[0].init.headers as Record<string, string>;
|
|
208
|
+
expect(headers["Vellum-Organization-Id"]).toBe("org-123");
|
|
209
|
+
// Managed auth header should still be present
|
|
210
|
+
expect(headers["X-Session-Token"]).toBe("test-session-token");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("runtime request is unchanged when no defaultHeaders provided", async () => {
|
|
214
|
+
const { fetchFn, captured } = capturingFetch(200, {
|
|
215
|
+
is_valid: true,
|
|
216
|
+
errors: [],
|
|
217
|
+
manifest: {},
|
|
218
|
+
});
|
|
219
|
+
await validateBundle(runtimeConfig({ fetchFn }), sampleFileData);
|
|
220
|
+
const headers = captured[0].init.headers as Record<string, string>;
|
|
221
|
+
expect(headers["Authorization"]).toBe("Bearer test-jwt");
|
|
222
|
+
expect(headers["Vellum-Organization-Id"]).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("auth header wins over same-named entry in defaultHeaders", async () => {
|
|
226
|
+
const { fetchFn, captured } = capturingFetch(200, {
|
|
227
|
+
is_valid: true,
|
|
228
|
+
errors: [],
|
|
229
|
+
manifest: {},
|
|
230
|
+
});
|
|
231
|
+
// defaultHeaders sets X-Session-Token, but authHeader should override it
|
|
232
|
+
await validateBundle(
|
|
233
|
+
managedConfig({
|
|
234
|
+
fetchFn,
|
|
235
|
+
defaultHeaders: { "X-Session-Token": "should-be-overridden" },
|
|
236
|
+
}),
|
|
237
|
+
sampleFileData,
|
|
238
|
+
);
|
|
239
|
+
const headers = captured[0].init.headers as Record<string, string>;
|
|
240
|
+
expect(headers["X-Session-Token"]).toBe("test-session-token");
|
|
241
|
+
});
|
|
242
|
+
|
|
194
243
|
test("no auth header when authHeader is not provided", async () => {
|
|
195
244
|
const { fetchFn, captured } = capturingFetch(200, {
|
|
196
245
|
is_valid: true,
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* - Reports delivery results per channel
|
|
9
9
|
* - Emits notification_thread_created only when a new conversation is created
|
|
10
10
|
* - Does NOT emit notification_thread_created when reusing an existing thread
|
|
11
|
+
* - Threads destination binding context into conversation pairing for external channels
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
14
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
import type { PairingOptions } from "../notifications/conversation-pairing.js";
|
|
14
17
|
|
|
15
18
|
// -- Mocks (must be declared before importing modules that depend on them) ----
|
|
16
19
|
|
|
@@ -21,12 +24,26 @@ mock.module("../util/logger.js", () => ({
|
|
|
21
24
|
}),
|
|
22
25
|
}));
|
|
23
26
|
|
|
24
|
-
// Mock destination-resolver to return a destination for every requested channel
|
|
27
|
+
// Mock destination-resolver to return a destination for every requested channel.
|
|
28
|
+
// External channels (sms, telegram, slack) include bindingContext.
|
|
25
29
|
mock.module("../notifications/destination-resolver.js", () => ({
|
|
26
30
|
resolveDestinations: (channels: string[]) => {
|
|
27
31
|
const m = new Map();
|
|
28
32
|
for (const ch of channels) {
|
|
29
|
-
|
|
33
|
+
const isExternal = ch === "sms" || ch === "telegram" || ch === "slack";
|
|
34
|
+
m.set(ch, {
|
|
35
|
+
channel: ch,
|
|
36
|
+
endpoint: `mock-${ch}`,
|
|
37
|
+
...(isExternal
|
|
38
|
+
? {
|
|
39
|
+
bindingContext: {
|
|
40
|
+
sourceChannel: ch,
|
|
41
|
+
externalChatId: `ext-chat-${ch}`,
|
|
42
|
+
externalUserId: `ext-user-${ch}`,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
: {}),
|
|
46
|
+
});
|
|
30
47
|
}
|
|
31
48
|
return m;
|
|
32
49
|
},
|
|
@@ -39,15 +56,27 @@ mock.module("../notifications/deliveries-store.js", () => ({
|
|
|
39
56
|
}));
|
|
40
57
|
|
|
41
58
|
// Configurable mock for conversation-pairing.
|
|
42
|
-
//
|
|
59
|
+
// Captures call arguments so tests can inspect what was passed in.
|
|
43
60
|
// Set `nextPairingResult` to override the return value for a single call.
|
|
44
61
|
let nextPairingResult:
|
|
45
62
|
| import("../notifications/conversation-pairing.js").PairingResult
|
|
46
63
|
| null = null;
|
|
47
64
|
let pairingCallCount = 0;
|
|
48
65
|
|
|
66
|
+
interface PairingCall {
|
|
67
|
+
channel: string;
|
|
68
|
+
options?: PairingOptions;
|
|
69
|
+
}
|
|
70
|
+
const pairingCalls: PairingCall[] = [];
|
|
71
|
+
|
|
49
72
|
mock.module("../notifications/conversation-pairing.js", () => ({
|
|
50
|
-
pairDeliveryWithConversation: async (
|
|
73
|
+
pairDeliveryWithConversation: async (
|
|
74
|
+
_signal: unknown,
|
|
75
|
+
channel: string,
|
|
76
|
+
_copy: unknown,
|
|
77
|
+
options?: PairingOptions,
|
|
78
|
+
) => {
|
|
79
|
+
pairingCalls.push({ channel, options });
|
|
51
80
|
if (nextPairingResult) {
|
|
52
81
|
const result = nextPairingResult;
|
|
53
82
|
nextPairingResult = null;
|
|
@@ -138,6 +167,10 @@ class MockAdapter implements ChannelAdapter {
|
|
|
138
167
|
// -- Tests -------------------------------------------------------------------
|
|
139
168
|
|
|
140
169
|
describe("notification broadcaster", () => {
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
pairingCalls.length = 0;
|
|
172
|
+
nextPairingResult = null;
|
|
173
|
+
});
|
|
141
174
|
test("dispatches to the vellum adapter when selected", async () => {
|
|
142
175
|
const vellumAdapter = new MockAdapter("vellum");
|
|
143
176
|
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
@@ -381,4 +414,171 @@ describe("notification broadcaster", () => {
|
|
|
381
414
|
expect(dispatchCalls).toHaveLength(1);
|
|
382
415
|
expect(dispatchCalls[0].conversationId).toBe("conv-reused-456");
|
|
383
416
|
});
|
|
417
|
+
|
|
418
|
+
// ── Destination binding context ────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
test("SMS delivery carries destination binding context into pairing", async () => {
|
|
421
|
+
const smsAdapter = new MockAdapter("sms");
|
|
422
|
+
const broadcaster = new NotificationBroadcaster([smsAdapter]);
|
|
423
|
+
|
|
424
|
+
const signal = makeSignal();
|
|
425
|
+
const decision = makeDecision({
|
|
426
|
+
selectedChannels: ["sms"],
|
|
427
|
+
renderedCopy: {
|
|
428
|
+
sms: { title: "SMS Alert", body: "Something happened" },
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
433
|
+
|
|
434
|
+
const smsCall = pairingCalls.find((c) => c.channel === "sms");
|
|
435
|
+
expect(smsCall).toBeDefined();
|
|
436
|
+
expect(smsCall!.options?.bindingContext).toEqual({
|
|
437
|
+
sourceChannel: "sms",
|
|
438
|
+
externalChatId: "ext-chat-sms",
|
|
439
|
+
externalUserId: "ext-user-sms",
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("Telegram delivery carries destination binding context into pairing", async () => {
|
|
444
|
+
const telegramAdapter = new MockAdapter("telegram");
|
|
445
|
+
const broadcaster = new NotificationBroadcaster([telegramAdapter]);
|
|
446
|
+
|
|
447
|
+
const signal = makeSignal();
|
|
448
|
+
const decision = makeDecision({
|
|
449
|
+
selectedChannels: ["telegram"],
|
|
450
|
+
renderedCopy: {
|
|
451
|
+
telegram: { title: "Telegram Alert", body: "Something happened" },
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
456
|
+
|
|
457
|
+
const telegramCall = pairingCalls.find((c) => c.channel === "telegram");
|
|
458
|
+
expect(telegramCall).toBeDefined();
|
|
459
|
+
expect(telegramCall!.options?.bindingContext).toEqual({
|
|
460
|
+
sourceChannel: "telegram",
|
|
461
|
+
externalChatId: "ext-chat-telegram",
|
|
462
|
+
externalUserId: "ext-user-telegram",
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("Slack delivery carries destination binding context into pairing", async () => {
|
|
467
|
+
const slackAdapter = new MockAdapter("slack");
|
|
468
|
+
const broadcaster = new NotificationBroadcaster([slackAdapter]);
|
|
469
|
+
|
|
470
|
+
const signal = makeSignal();
|
|
471
|
+
const decision = makeDecision({
|
|
472
|
+
selectedChannels: ["slack"],
|
|
473
|
+
renderedCopy: {
|
|
474
|
+
slack: { title: "Slack Alert", body: "Something happened" },
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
479
|
+
|
|
480
|
+
const slackCall = pairingCalls.find((c) => c.channel === "slack");
|
|
481
|
+
expect(slackCall).toBeDefined();
|
|
482
|
+
expect(slackCall!.options?.bindingContext).toEqual({
|
|
483
|
+
sourceChannel: "slack",
|
|
484
|
+
externalChatId: "ext-chat-slack",
|
|
485
|
+
externalUserId: "ext-user-slack",
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("reused thread via binding-key continuation does NOT emit class-level onThreadCreated", async () => {
|
|
490
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
491
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
492
|
+
const ipcCalls: ThreadCreatedInfo[] = [];
|
|
493
|
+
broadcaster.setOnThreadCreated((info) => ipcCalls.push(info));
|
|
494
|
+
|
|
495
|
+
// Simulate binding-key continuation: pairing reuses an existing bound
|
|
496
|
+
// conversation (createdNewConversation=false, strategy=continue_existing_conversation)
|
|
497
|
+
nextPairingResult = {
|
|
498
|
+
conversationId: "conv-bound-sms-001",
|
|
499
|
+
messageId: "msg-bound-sms-001",
|
|
500
|
+
strategy: "continue_existing_conversation" as const,
|
|
501
|
+
createdNewConversation: false,
|
|
502
|
+
threadDecisionFallbackUsed: false,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const signal = makeSignal();
|
|
506
|
+
const decision = makeDecision();
|
|
507
|
+
|
|
508
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
509
|
+
|
|
510
|
+
// The class-level IPC callback should NOT fire because
|
|
511
|
+
// createdNewConversation is false — the thread already exists
|
|
512
|
+
// in the external channel and the client already knows about it.
|
|
513
|
+
expect(ipcCalls).toHaveLength(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("fresh conversation for continue_existing_conversation does NOT emit class-level onThreadCreated", async () => {
|
|
517
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
518
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
519
|
+
const ipcCalls: ThreadCreatedInfo[] = [];
|
|
520
|
+
broadcaster.setOnThreadCreated((info) => ipcCalls.push(info));
|
|
521
|
+
|
|
522
|
+
// First delivery to a new destination: creates a fresh conversation but
|
|
523
|
+
// the strategy is continue_existing_conversation (not start_new_conversation),
|
|
524
|
+
// so the IPC event should NOT fire — these are background threads not
|
|
525
|
+
// meant to appear in the sidebar.
|
|
526
|
+
nextPairingResult = {
|
|
527
|
+
conversationId: "conv-new-telegram-dest",
|
|
528
|
+
messageId: "msg-new-telegram-dest",
|
|
529
|
+
strategy: "continue_existing_conversation" as const,
|
|
530
|
+
createdNewConversation: true,
|
|
531
|
+
threadDecisionFallbackUsed: false,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const signal = makeSignal();
|
|
535
|
+
const decision = makeDecision();
|
|
536
|
+
|
|
537
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
538
|
+
|
|
539
|
+
// Even though createdNewConversation is true, the strategy is
|
|
540
|
+
// continue_existing_conversation, so the IPC gate rejects it.
|
|
541
|
+
expect(ipcCalls).toHaveLength(0);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("per-dispatch onThreadCreated fires for reused binding-key conversation", async () => {
|
|
545
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
546
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
547
|
+
const dispatchCalls: ThreadCreatedInfo[] = [];
|
|
548
|
+
|
|
549
|
+
// Binding-key reuse: conversation already exists
|
|
550
|
+
nextPairingResult = {
|
|
551
|
+
conversationId: "conv-bound-telegram-456",
|
|
552
|
+
messageId: "msg-bound-telegram-789",
|
|
553
|
+
strategy: "continue_existing_conversation" as const,
|
|
554
|
+
createdNewConversation: false,
|
|
555
|
+
threadDecisionFallbackUsed: false,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const signal = makeSignal();
|
|
559
|
+
const decision = makeDecision();
|
|
560
|
+
|
|
561
|
+
await broadcaster.broadcastDecision(signal, decision, {
|
|
562
|
+
onThreadCreated: (info) => dispatchCalls.push(info),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// The per-dispatch callback SHOULD fire regardless of reuse
|
|
566
|
+
// (callers like dispatchGuardianQuestion need it for bookkeeping)
|
|
567
|
+
expect(dispatchCalls).toHaveLength(1);
|
|
568
|
+
expect(dispatchCalls[0].conversationId).toBe("conv-bound-telegram-456");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("vellum delivery does NOT carry binding context into pairing", async () => {
|
|
572
|
+
const vellumAdapter = new MockAdapter("vellum");
|
|
573
|
+
const broadcaster = new NotificationBroadcaster([vellumAdapter]);
|
|
574
|
+
|
|
575
|
+
const signal = makeSignal();
|
|
576
|
+
const decision = makeDecision();
|
|
577
|
+
|
|
578
|
+
await broadcaster.broadcastDecision(signal, decision);
|
|
579
|
+
|
|
580
|
+
const vellumCall = pairingCalls.find((c) => c.channel === "vellum");
|
|
581
|
+
expect(vellumCall).toBeDefined();
|
|
582
|
+
expect(vellumCall!.options?.bindingContext).toBeUndefined();
|
|
583
|
+
});
|
|
384
584
|
});
|