@vellumai/assistant 0.4.52 → 0.4.53
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 +2 -2
- package/docs/architecture/keychain-broker.md +6 -20
- package/docs/architecture/memory.md +3 -3
- package/package.json +1 -1
- package/src/__tests__/approval-cascade.test.ts +3 -1
- package/src/__tests__/approval-routes-http.test.ts +0 -1
- package/src/__tests__/asset-materialize-tool.test.ts +0 -1
- package/src/__tests__/asset-search-tool.test.ts +0 -1
- package/src/__tests__/assistant-events-sse-hardening.test.ts +0 -1
- package/src/__tests__/attachments-store.test.ts +0 -1
- package/src/__tests__/avatar-e2e.test.ts +6 -1
- package/src/__tests__/browser-fill-credential.test.ts +3 -0
- package/src/__tests__/btw-routes.test.ts +39 -0
- package/src/__tests__/call-controller.test.ts +0 -1
- package/src/__tests__/call-domain.test.ts +1 -0
- package/src/__tests__/call-routes-http.test.ts +1 -2
- package/src/__tests__/canonical-guardian-store.test.ts +33 -2
- package/src/__tests__/channel-readiness-service.test.ts +1 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +6 -2
- package/src/__tests__/claude-code-tool-profiles.test.ts +7 -2
- package/src/__tests__/config-loader-backfill.test.ts +1 -2
- package/src/__tests__/config-schema.test.ts +6 -37
- package/src/__tests__/conversation-routes-slash-commands.test.ts +0 -1
- package/src/__tests__/credential-broker-server-use.test.ts +16 -16
- package/src/__tests__/credential-security-invariants.test.ts +14 -0
- package/src/__tests__/credential-vault-unit.test.ts +4 -4
- package/src/__tests__/error-handler-friendly-messages.test.ts +4 -5
- package/src/__tests__/gateway-only-enforcement.test.ts +0 -2
- package/src/__tests__/host-shell-tool.test.ts +0 -1
- package/src/__tests__/http-user-message-parity.test.ts +19 -0
- package/src/__tests__/list-messages-attachments.test.ts +0 -1
- package/src/__tests__/log-export-workspace.test.ts +233 -0
- package/src/__tests__/managed-proxy-context.test.ts +1 -1
- package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
- package/src/__tests__/media-generate-image.test.ts +7 -2
- package/src/__tests__/media-reuse-story.e2e.test.ts +0 -1
- package/src/__tests__/memory-regressions.test.ts +0 -1
- package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1
- package/src/__tests__/migration-export-http.test.ts +0 -1
- package/src/__tests__/migration-import-commit-http.test.ts +0 -1
- package/src/__tests__/migration-import-preflight-http.test.ts +0 -1
- package/src/__tests__/migration-validate-http.test.ts +0 -1
- package/src/__tests__/notification-schedule-dedup.test.ts +237 -0
- package/src/__tests__/oauth-cli.test.ts +1 -10
- package/src/__tests__/oauth-store.test.ts +3 -5
- package/src/__tests__/oauth2-gateway-transport.test.ts +5 -4
- package/src/__tests__/onboarding-starter-tasks.test.ts +1 -1
- package/src/__tests__/onboarding-template-contract.test.ts +1 -2
- package/src/__tests__/pricing.test.ts +0 -11
- package/src/__tests__/provider-commit-message-generator.test.ts +21 -14
- package/src/__tests__/provider-fail-open-selection.test.ts +9 -8
- package/src/__tests__/provider-managed-proxy-integration.test.ts +27 -24
- package/src/__tests__/provider-registry-ollama.test.ts +8 -2
- package/src/__tests__/recording-handler.test.ts +0 -1
- package/src/__tests__/relay-server.test.ts +0 -1
- package/src/__tests__/runtime-attachment-metadata.test.ts +0 -1
- package/src/__tests__/runtime-events-sse-parity.test.ts +0 -1
- package/src/__tests__/runtime-events-sse.test.ts +0 -1
- package/src/__tests__/secret-routes-managed-proxy.test.ts +0 -1
- package/src/__tests__/secret-scanner-executor.test.ts +0 -1
- package/src/__tests__/send-endpoint-busy.test.ts +0 -1
- package/src/__tests__/session-abort-tool-results.test.ts +3 -1
- package/src/__tests__/session-agent-loop-overflow.test.ts +1012 -838
- package/src/__tests__/session-agent-loop.test.ts +2 -2
- package/src/__tests__/session-confirmation-signals.test.ts +3 -1
- package/src/__tests__/session-error.test.ts +5 -4
- package/src/__tests__/session-history-web-search.test.ts +34 -9
- package/src/__tests__/session-pre-run-repair.test.ts +3 -1
- package/src/__tests__/session-provider-retry-repair.test.ts +31 -26
- package/src/__tests__/session-queue.test.ts +3 -1
- package/src/__tests__/session-runtime-assembly.test.ts +118 -0
- package/src/__tests__/session-slash-known.test.ts +31 -13
- package/src/__tests__/session-slash-queue.test.ts +3 -1
- package/src/__tests__/session-slash-unknown.test.ts +3 -1
- package/src/__tests__/session-workspace-cache-state.test.ts +3 -1
- package/src/__tests__/session-workspace-injection.test.ts +3 -1
- package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -1
- package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
- package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
- package/src/__tests__/skillssh-registry.test.ts +21 -0
- package/src/__tests__/slack-share-routes.test.ts +1 -1
- package/src/__tests__/swarm-recursion.test.ts +5 -1
- package/src/__tests__/swarm-session-integration.test.ts +25 -14
- package/src/__tests__/swarm-tool.test.ts +5 -2
- package/src/__tests__/telegram-bot-username-resolution.test.ts +2 -4
- package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1521 -0
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
- package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
- package/src/__tests__/tool-executor.test.ts +0 -1
- package/src/__tests__/trust-store.test.ts +5 -1
- package/src/__tests__/twilio-routes.test.ts +2 -2
- package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
- package/src/__tests__/voice-quality.test.ts +2 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/agent/loop.ts +17 -1
- package/src/bundler/app-bundler.ts +40 -24
- package/src/calls/call-controller.ts +16 -0
- package/src/calls/relay-server.ts +29 -13
- package/src/calls/voice-control-protocol.ts +1 -0
- package/src/calls/voice-quality.ts +1 -1
- package/src/calls/voice-session-bridge.ts +9 -3
- package/src/channels/types.ts +16 -0
- package/src/cli/commands/bash.ts +173 -0
- package/src/cli/commands/doctor.ts +5 -23
- package/src/cli/commands/oauth/connections.ts +4 -2
- package/src/cli/commands/oauth/providers.ts +1 -13
- package/src/cli/program.ts +2 -0
- package/src/cli/reference.ts +1 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -1
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +3 -5
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +2 -3
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +1 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +5 -6
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/loader.ts +7 -135
- package/src/config/schema.ts +0 -6
- package/src/config/schemas/channels.ts +1 -0
- package/src/config/schemas/elevenlabs.ts +2 -2
- package/src/contacts/contact-store.ts +21 -25
- package/src/contacts/contacts-write.ts +6 -6
- package/src/contacts/types.ts +2 -0
- package/src/context/token-estimator.ts +35 -2
- package/src/context/window-manager.ts +16 -2
- package/src/daemon/config-watcher.ts +24 -6
- package/src/daemon/context-overflow-reducer.ts +13 -2
- package/src/daemon/handlers/config-ingress.ts +25 -8
- package/src/daemon/handlers/config-model.ts +21 -15
- package/src/daemon/handlers/config-telegram.ts +18 -6
- package/src/daemon/handlers/dictation.ts +0 -429
- package/src/daemon/handlers/skills.ts +1 -200
- package/src/daemon/lifecycle.ts +8 -5
- package/src/daemon/message-types/contacts.ts +2 -0
- package/src/daemon/message-types/integrations.ts +1 -0
- package/src/daemon/message-types/sessions.ts +2 -0
- package/src/daemon/parse-actual-tokens-from-error.test.ts +75 -0
- package/src/daemon/server.ts +23 -2
- package/src/daemon/session-agent-loop-handlers.ts +1 -1
- package/src/daemon/session-agent-loop.ts +27 -79
- package/src/daemon/session-error.ts +5 -4
- package/src/daemon/session-process.ts +17 -10
- package/src/daemon/session-runtime-assembly.ts +50 -0
- package/src/daemon/session-slash.ts +32 -20
- package/src/daemon/session.ts +1 -0
- package/src/events/domain-events.ts +1 -0
- package/src/media/app-icon-generator.ts +2 -1
- package/src/media/avatar-router.ts +3 -2
- package/src/memory/canonical-guardian-store.ts +25 -3
- package/src/memory/db-init.ts +12 -0
- package/src/memory/embedding-backend.ts +25 -16
- package/src/memory/migrations/158-channel-interaction-columns.ts +18 -0
- package/src/memory/migrations/159-drop-contact-interaction-columns.ts +16 -0
- package/src/memory/migrations/160-drop-loopback-port-column.ts +13 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/retriever.test.ts +19 -12
- package/src/memory/schema/contacts.ts +2 -2
- package/src/memory/schema/oauth.ts +0 -1
- package/src/oauth/connect-orchestrator.ts +5 -3
- package/src/oauth/connect-types.ts +9 -2
- package/src/oauth/manual-token-connection.ts +9 -7
- package/src/oauth/oauth-store.ts +2 -8
- package/src/oauth/provider-behaviors.ts +10 -0
- package/src/oauth/seed-providers.ts +13 -5
- package/src/permissions/checker.ts +20 -1
- package/src/prompts/__tests__/build-cli-reference-section.test.ts +1 -1
- package/src/prompts/system-prompt.ts +2 -11
- package/src/prompts/templates/BOOTSTRAP.md +1 -3
- package/src/providers/anthropic/client.ts +16 -8
- package/src/providers/managed-proxy/constants.ts +1 -1
- package/src/providers/registry.ts +21 -15
- package/src/providers/types.ts +1 -1
- package/src/runtime/auth/route-policy.ts +4 -0
- package/src/runtime/channel-invite-transports/telegram.ts +12 -6
- package/src/runtime/channel-retry-sweep.ts +6 -0
- package/src/runtime/http-types.ts +1 -0
- package/src/runtime/middleware/error-handler.ts +1 -2
- package/src/runtime/routes/app-management-routes.ts +1 -0
- package/src/runtime/routes/btw-routes.ts +20 -1
- package/src/runtime/routes/conversation-routes.ts +32 -13
- package/src/runtime/routes/inbound-message-handler.ts +10 -2
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +5 -5
- package/src/runtime/routes/integrations/slack/share.ts +5 -5
- package/src/runtime/routes/log-export-routes.ts +122 -10
- package/src/runtime/routes/session-query-routes.ts +3 -3
- package/src/runtime/routes/settings-routes.ts +53 -0
- package/src/runtime/routes/workspace-routes.ts +3 -0
- package/src/runtime/verification-templates.ts +1 -1
- package/src/security/oauth2.ts +4 -4
- package/src/security/secure-keys.ts +4 -4
- package/src/signals/bash.ts +157 -0
- package/src/skills/skillssh-registry.ts +6 -1
- package/src/swarm/backend-claude-code.ts +6 -6
- package/src/swarm/worker-backend.ts +1 -1
- package/src/swarm/worker-runner.ts +1 -1
- package/src/telegram/bot-username.ts +11 -0
- package/src/tools/claude-code/claude-code.ts +4 -4
- package/src/tools/credentials/broker.ts +7 -5
- package/src/tools/credentials/vault.ts +3 -2
- package/src/tools/network/__tests__/web-search.test.ts +18 -86
- package/src/tools/network/web-search.ts +9 -15
- package/src/util/platform.ts +7 -1
- package/src/util/pricing.ts +0 -1
- package/src/workspace/provider-commit-message-generator.ts +10 -6
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test: recurring schedule notifications must not be
|
|
3
|
+
* deduplicated against prior firings of the same schedule.
|
|
4
|
+
*
|
|
5
|
+
* Before the fix, `schedule.complete` signals were emitted without a
|
|
6
|
+
* producer dedupeKey. The LLM decision engine would generate a stable
|
|
7
|
+
* key (e.g. `schedule:complete:<id>`) and `updateEventDedupeKey` would
|
|
8
|
+
* write it back to the event row. On the next firing, `checkDedupe`
|
|
9
|
+
* found the first row's stable key within the 1-hour window and
|
|
10
|
+
* silently blocked the notification.
|
|
11
|
+
*
|
|
12
|
+
* The fix: always supply a unique per-firing dedupeKey from the
|
|
13
|
+
* producer so `updateEventDedupeKey` is never called for schedule
|
|
14
|
+
* signals, and `checkDedupe` never finds a matching row.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
21
|
+
|
|
22
|
+
const testDir = mkdtempSync(
|
|
23
|
+
join(tmpdir(), "notification-schedule-dedup-test-"),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
mock.module("../util/platform.js", () => ({
|
|
27
|
+
getDataDir: () => testDir,
|
|
28
|
+
isMacOS: () => process.platform === "darwin",
|
|
29
|
+
isLinux: () => process.platform === "linux",
|
|
30
|
+
isWindows: () => process.platform === "win32",
|
|
31
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
32
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
33
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
34
|
+
ensureDataDir: () => {},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module("../util/logger.js", () => ({
|
|
38
|
+
getLogger: () =>
|
|
39
|
+
new Proxy({} as Record<string, unknown>, {
|
|
40
|
+
get: () => () => {},
|
|
41
|
+
}),
|
|
42
|
+
truncateForLog: (value: string) => value,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { getDb, initializeDb } from "../memory/db.js";
|
|
46
|
+
import { notificationEvents } from "../memory/schema.js";
|
|
47
|
+
import { runDeterministicChecks } from "../notifications/deterministic-checks.js";
|
|
48
|
+
import {
|
|
49
|
+
createEvent,
|
|
50
|
+
updateEventDedupeKey,
|
|
51
|
+
} from "../notifications/events-store.js";
|
|
52
|
+
import type { NotificationSignal } from "../notifications/signal.js";
|
|
53
|
+
import type { NotificationDecision } from "../notifications/types.js";
|
|
54
|
+
|
|
55
|
+
initializeDb();
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
try {
|
|
59
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
60
|
+
} catch {}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
// Clear notification events between tests for isolation
|
|
65
|
+
getDb().delete(notificationEvents).run();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function makeSignal(
|
|
69
|
+
overrides?: Partial<NotificationSignal>,
|
|
70
|
+
): NotificationSignal {
|
|
71
|
+
return {
|
|
72
|
+
signalId: `sig-${crypto.randomUUID()}`,
|
|
73
|
+
createdAt: Date.now(),
|
|
74
|
+
sourceChannel: "scheduler",
|
|
75
|
+
sourceSessionId: "schedule-123",
|
|
76
|
+
sourceEventName: "schedule.complete",
|
|
77
|
+
contextPayload: { scheduleId: "schedule-123", name: "Drink water" },
|
|
78
|
+
attentionHints: {
|
|
79
|
+
requiresAction: false,
|
|
80
|
+
urgency: "medium",
|
|
81
|
+
isAsyncBackground: true,
|
|
82
|
+
visibleInSourceNow: false,
|
|
83
|
+
},
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeDecision(
|
|
89
|
+
overrides?: Partial<NotificationDecision>,
|
|
90
|
+
): NotificationDecision {
|
|
91
|
+
return {
|
|
92
|
+
shouldNotify: true,
|
|
93
|
+
selectedChannels: ["vellum"],
|
|
94
|
+
reasoningSummary: "Schedule completed",
|
|
95
|
+
renderedCopy: {
|
|
96
|
+
vellum: { title: "Reminder", body: "Time to drink water" },
|
|
97
|
+
},
|
|
98
|
+
dedupeKey: "schedule:complete:schedule-123",
|
|
99
|
+
confidence: 0.9,
|
|
100
|
+
fallbackUsed: false,
|
|
101
|
+
...overrides,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("recurring schedule notification dedup", () => {
|
|
106
|
+
test("second firing is blocked when LLM stable key is written to first event row (the bug)", async () => {
|
|
107
|
+
// Simulate the BROKEN behavior: producer sends no dedupeKey,
|
|
108
|
+
// LLM generates a stable key, and updateEventDedupeKey writes it
|
|
109
|
+
// to the first event row.
|
|
110
|
+
|
|
111
|
+
const stableKey = "schedule:complete:schedule-123";
|
|
112
|
+
const firstId = crypto.randomUUID();
|
|
113
|
+
const secondId = crypto.randomUUID();
|
|
114
|
+
|
|
115
|
+
// First firing: create event with null dedupeKey, then backfill with LLM key
|
|
116
|
+
const firstSignal = makeSignal({ signalId: firstId });
|
|
117
|
+
createEvent({
|
|
118
|
+
id: firstSignal.signalId,
|
|
119
|
+
sourceEventName: "schedule.complete",
|
|
120
|
+
sourceChannel: "scheduler",
|
|
121
|
+
sourceSessionId: "schedule-123",
|
|
122
|
+
attentionHints: firstSignal.attentionHints,
|
|
123
|
+
payload: firstSignal.contextPayload,
|
|
124
|
+
// No dedupeKey — this is the bug scenario
|
|
125
|
+
});
|
|
126
|
+
// LLM decision generates a stable key, pipeline writes it back
|
|
127
|
+
updateEventDedupeKey(firstSignal.signalId, stableKey);
|
|
128
|
+
|
|
129
|
+
// Second firing: new event, same schedule
|
|
130
|
+
const secondSignal = makeSignal({ signalId: secondId });
|
|
131
|
+
createEvent({
|
|
132
|
+
id: secondSignal.signalId,
|
|
133
|
+
sourceEventName: "schedule.complete",
|
|
134
|
+
sourceChannel: "scheduler",
|
|
135
|
+
sourceSessionId: "schedule-123",
|
|
136
|
+
attentionHints: secondSignal.attentionHints,
|
|
137
|
+
payload: secondSignal.contextPayload,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// LLM generates the same stable key for the second firing
|
|
141
|
+
const decision = makeDecision({ dedupeKey: stableKey });
|
|
142
|
+
|
|
143
|
+
const result = await runDeterministicChecks(secondSignal, decision, {
|
|
144
|
+
connectedChannels: ["vellum"],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The second firing is BLOCKED — this is the bug
|
|
148
|
+
expect(result.passed).toBe(false);
|
|
149
|
+
expect(result.reason).toContain("Dedupe");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("second firing passes when producer supplies unique per-firing dedupeKey (the fix)", async () => {
|
|
153
|
+
const stableKey = "schedule:complete:schedule-123";
|
|
154
|
+
const firstId = crypto.randomUUID();
|
|
155
|
+
const secondId = crypto.randomUUID();
|
|
156
|
+
|
|
157
|
+
// First firing: producer supplies a timestamped dedupeKey
|
|
158
|
+
const firstSignal = makeSignal({ signalId: firstId });
|
|
159
|
+
createEvent({
|
|
160
|
+
id: firstSignal.signalId,
|
|
161
|
+
sourceEventName: "schedule.complete",
|
|
162
|
+
sourceChannel: "scheduler",
|
|
163
|
+
sourceSessionId: "schedule-123",
|
|
164
|
+
attentionHints: firstSignal.attentionHints,
|
|
165
|
+
payload: firstSignal.contextPayload,
|
|
166
|
+
dedupeKey: `schedule:complete:schedule-123:${Date.now() - 60_000}`,
|
|
167
|
+
});
|
|
168
|
+
// updateEventDedupeKey is NOT called because params.dedupeKey is truthy
|
|
169
|
+
|
|
170
|
+
// Second firing: new event with its own unique timestamped key
|
|
171
|
+
const secondSignal = makeSignal({ signalId: secondId });
|
|
172
|
+
createEvent({
|
|
173
|
+
id: secondSignal.signalId,
|
|
174
|
+
sourceEventName: "schedule.complete",
|
|
175
|
+
sourceChannel: "scheduler",
|
|
176
|
+
sourceSessionId: "schedule-123",
|
|
177
|
+
attentionHints: secondSignal.attentionHints,
|
|
178
|
+
payload: secondSignal.contextPayload,
|
|
179
|
+
dedupeKey: `schedule:complete:schedule-123:${Date.now()}`,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// LLM still generates a stable key — but no row in the DB has it
|
|
183
|
+
const decision = makeDecision({ dedupeKey: stableKey });
|
|
184
|
+
|
|
185
|
+
const result = await runDeterministicChecks(secondSignal, decision, {
|
|
186
|
+
connectedChannels: ["vellum"],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// The second firing PASSES — the fix works
|
|
190
|
+
expect(result.passed).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("notify mode with timestamped producer keys is not blocked", async () => {
|
|
194
|
+
const stableKey = "schedule:notify:schedule-123";
|
|
195
|
+
const firstId = crypto.randomUUID();
|
|
196
|
+
const secondId = crypto.randomUUID();
|
|
197
|
+
|
|
198
|
+
// First firing
|
|
199
|
+
const firstSignal = makeSignal({
|
|
200
|
+
signalId: firstId,
|
|
201
|
+
sourceEventName: "schedule.notify",
|
|
202
|
+
});
|
|
203
|
+
createEvent({
|
|
204
|
+
id: firstSignal.signalId,
|
|
205
|
+
sourceEventName: "schedule.notify",
|
|
206
|
+
sourceChannel: "scheduler",
|
|
207
|
+
sourceSessionId: "schedule-123",
|
|
208
|
+
attentionHints: firstSignal.attentionHints,
|
|
209
|
+
payload: firstSignal.contextPayload,
|
|
210
|
+
dedupeKey: `schedule:notify:schedule-123:${Date.now() - 60_000}`,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Second firing
|
|
214
|
+
const secondSignal = makeSignal({
|
|
215
|
+
signalId: secondId,
|
|
216
|
+
sourceEventName: "schedule.notify",
|
|
217
|
+
});
|
|
218
|
+
createEvent({
|
|
219
|
+
id: secondSignal.signalId,
|
|
220
|
+
sourceEventName: "schedule.notify",
|
|
221
|
+
sourceChannel: "scheduler",
|
|
222
|
+
sourceSessionId: "schedule-123",
|
|
223
|
+
attentionHints: secondSignal.attentionHints,
|
|
224
|
+
payload: secondSignal.contextPayload,
|
|
225
|
+
dedupeKey: `schedule:notify:schedule-123:${Date.now()}`,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// LLM generates stable key — no matching row
|
|
229
|
+
const decision = makeDecision({ dedupeKey: stableKey });
|
|
230
|
+
|
|
231
|
+
const result = await runDeterministicChecks(secondSignal, decision, {
|
|
232
|
+
connectedChannels: ["vellum"],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.passed).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -154,17 +154,8 @@ mock.module("../oauth/oauth-store.js", () => ({
|
|
|
154
154
|
|
|
155
155
|
// Stub out transitive dependencies that token-manager would normally pull in
|
|
156
156
|
mock.module("../security/secure-keys.js", () => ({
|
|
157
|
-
|
|
158
|
-
setSecureKey: () => true,
|
|
159
|
-
getSecureKeyAsync: async () => undefined,
|
|
157
|
+
getSecureKeyAsync: async (account: string) => mockGetSecureKey(account),
|
|
160
158
|
setSecureKeyAsync: async () => true,
|
|
161
|
-
deleteSecureKey: (account: string) => {
|
|
162
|
-
if (secureKeyStore.has(account)) {
|
|
163
|
-
secureKeyStore.delete(account);
|
|
164
|
-
return "deleted" as const;
|
|
165
|
-
}
|
|
166
|
-
return "not-found" as const;
|
|
167
|
-
},
|
|
168
159
|
deleteSecureKeyAsync: async (account: string) => {
|
|
169
160
|
if (secureKeyStore.has(account)) {
|
|
170
161
|
secureKeyStore.delete(account);
|
|
@@ -232,7 +232,7 @@ describe("provider operations", () => {
|
|
|
232
232
|
baseUrl: "https://api.github.com",
|
|
233
233
|
extraParams: { prompt: "consent" },
|
|
234
234
|
callbackTransport: "loopback",
|
|
235
|
-
|
|
235
|
+
|
|
236
236
|
pingUrl: "https://api.github.com/user",
|
|
237
237
|
},
|
|
238
238
|
]);
|
|
@@ -277,7 +277,7 @@ describe("provider operations", () => {
|
|
|
277
277
|
baseUrl: "https://api.github.com/v2",
|
|
278
278
|
extraParams: { prompt: "login" },
|
|
279
279
|
callbackTransport: "gateway",
|
|
280
|
-
|
|
280
|
+
|
|
281
281
|
pingUrl: "https://api.github.com/user-v2",
|
|
282
282
|
},
|
|
283
283
|
]);
|
|
@@ -300,7 +300,6 @@ describe("provider operations", () => {
|
|
|
300
300
|
expect(row!.tokenEndpointAuthMethod).toBe("client_secret_basic");
|
|
301
301
|
expect(JSON.parse(row!.extraParams!)).toEqual({ prompt: "login" });
|
|
302
302
|
expect(row!.callbackTransport).toBe("gateway");
|
|
303
|
-
expect(row!.loopbackPort).toBe(9999);
|
|
304
303
|
expect(row!.pingUrl).toBe("https://api.github.com/user-v2");
|
|
305
304
|
});
|
|
306
305
|
});
|
|
@@ -315,7 +314,7 @@ describe("provider operations", () => {
|
|
|
315
314
|
defaultScopes: ["repo"],
|
|
316
315
|
scopePolicy: {},
|
|
317
316
|
callbackTransport: "loopback",
|
|
318
|
-
|
|
317
|
+
|
|
319
318
|
},
|
|
320
319
|
]);
|
|
321
320
|
|
|
@@ -323,7 +322,6 @@ describe("provider operations", () => {
|
|
|
323
322
|
expect(row).toBeDefined();
|
|
324
323
|
expect(row!.providerKey).toBe("github");
|
|
325
324
|
expect(row!.callbackTransport).toBe("loopback");
|
|
326
|
-
expect(row!.loopbackPort).toBe(8765);
|
|
327
325
|
});
|
|
328
326
|
|
|
329
327
|
test("returns undefined for unknown keys", () => {
|
|
@@ -185,6 +185,7 @@ describe("OAuth2 gateway transport", () => {
|
|
|
185
185
|
// The auth URL should contain the gateway redirect_uri, not a loopback one
|
|
186
186
|
expect(capturedAuthUrl).toContain("redirect_uri=");
|
|
187
187
|
expect(capturedAuthUrl).not.toContain("127.0.0.1");
|
|
188
|
+
expect(capturedAuthUrl).not.toMatch(/localhost:\d+/);
|
|
188
189
|
expect(capturedAuthUrl).toContain(
|
|
189
190
|
encodeURIComponent("https://gw.example.com"),
|
|
190
191
|
);
|
|
@@ -212,9 +213,9 @@ describe("OAuth2 gateway transport", () => {
|
|
|
212
213
|
// Give the loopback server time to start
|
|
213
214
|
await new Promise((r) => setTimeout(r, 50));
|
|
214
215
|
|
|
215
|
-
// Auth URL should use a
|
|
216
|
+
// Auth URL should use a localhost redirect_uri
|
|
216
217
|
expect(capturedAuthUrl).toContain("redirect_uri=");
|
|
217
|
-
expect(capturedAuthUrl).
|
|
218
|
+
expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
|
|
218
219
|
expect(capturedAuthUrl).toContain(encodeURIComponent("/oauth/callback"));
|
|
219
220
|
|
|
220
221
|
// Extract the redirect_uri and simulate the callback
|
|
@@ -277,7 +278,7 @@ describe("OAuth2 gateway transport", () => {
|
|
|
277
278
|
await new Promise((r) => setTimeout(r, 50));
|
|
278
279
|
|
|
279
280
|
// Should use loopback redirect even though gateway URL is available
|
|
280
|
-
expect(capturedAuthUrl).
|
|
281
|
+
expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
|
|
281
282
|
expect(capturedAuthUrl).not.toContain("gw.example.com");
|
|
282
283
|
|
|
283
284
|
// Simulate callback to loopback server
|
|
@@ -395,7 +396,7 @@ describe("OAuth2 gateway transport", () => {
|
|
|
395
396
|
await new Promise((r) => setTimeout(r, 50));
|
|
396
397
|
|
|
397
398
|
expect(capturedAuthUrl).toContain("redirect_uri=");
|
|
398
|
-
expect(capturedAuthUrl).
|
|
399
|
+
expect(capturedAuthUrl).toMatch(/localhost|127\.0\.0\.1/);
|
|
399
400
|
expect(capturedAuthUrl).toContain("code_challenge=");
|
|
400
401
|
expect(capturedAuthUrl).toContain("code_challenge_method=S256");
|
|
401
402
|
|
|
@@ -76,7 +76,7 @@ describe("buildStarterTaskPlaybookSection", () => {
|
|
|
76
76
|
const section = buildStarterTaskPlaybookSection();
|
|
77
77
|
expect(section).toContain("### Playbook: make_it_yours");
|
|
78
78
|
expect(section).toContain("accent color");
|
|
79
|
-
expect(section).toContain("
|
|
79
|
+
expect(section).toContain("Color Preference");
|
|
80
80
|
expect(section).toContain("user_selected");
|
|
81
81
|
});
|
|
82
82
|
|
|
@@ -73,8 +73,7 @@ describe("onboarding template contracts", () => {
|
|
|
73
73
|
// User detail fields must be resolved (provided, inferred, or declined)
|
|
74
74
|
expect(lower).toContain("resolved");
|
|
75
75
|
expect(lower).toContain("work role");
|
|
76
|
-
expect(lower).toContain("2 suggestions
|
|
77
|
-
expect(lower).toContain("selected one, deferred both");
|
|
76
|
+
expect(lower).toContain("2 suggestions from step 6");
|
|
78
77
|
});
|
|
79
78
|
|
|
80
79
|
test("contains refusal policy", () => {
|
|
@@ -23,17 +23,6 @@ describe("resolvePricing", () => {
|
|
|
23
23
|
expect(result.estimatedCostUsd).toBe(5 + 25);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("returns priced for claude-opus-4-6-fast", () => {
|
|
27
|
-
const result = resolvePricing(
|
|
28
|
-
"anthropic",
|
|
29
|
-
"claude-opus-4-6-fast",
|
|
30
|
-
1_000_000,
|
|
31
|
-
1_000_000,
|
|
32
|
-
);
|
|
33
|
-
expect(result.pricingStatus).toBe("priced");
|
|
34
|
-
expect(result.estimatedCostUsd).toBe(30 + 150);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
26
|
test("returns priced for claude-opus-4", () => {
|
|
38
27
|
const result = resolvePricing(
|
|
39
28
|
"anthropic",
|
|
@@ -5,13 +5,25 @@ import type { AssistantConfig } from "../config/types.js";
|
|
|
5
5
|
import type { Provider, ProviderResponse } from "../providers/types.js";
|
|
6
6
|
import type { CommitContext } from "../workspace/commit-message-provider.js";
|
|
7
7
|
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Mock secure keys — controls what getSecureKeyAsync returns per provider
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
let mockSecureKeys: Record<string, string> = {};
|
|
12
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
13
|
+
getSecureKey: (name: string) => mockSecureKeys[name] ?? undefined,
|
|
14
|
+
getSecureKeyAsync: async (name: string) => mockSecureKeys[name] ?? undefined,
|
|
15
|
+
setSecureKey: () => true,
|
|
16
|
+
setSecureKeyAsync: async () => true,
|
|
17
|
+
deleteSecureKey: () => "deleted",
|
|
18
|
+
deleteSecureKeyAsync: async () => "deleted" as const,
|
|
19
|
+
}));
|
|
20
|
+
|
|
8
21
|
// ---------------------------------------------------------------------------
|
|
9
22
|
// Deep-clone a base config so each test can tweak fields independently
|
|
10
23
|
// ---------------------------------------------------------------------------
|
|
11
24
|
function cloneConfig(): AssistantConfig {
|
|
12
25
|
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
13
26
|
cfg.provider = "anthropic";
|
|
14
|
-
cfg.apiKeys = { anthropic: "sk-test-key" } as Record<string, string>;
|
|
15
27
|
cfg.workspaceGit.commitMessageLLM = {
|
|
16
28
|
...cfg.workspaceGit.commitMessageLLM,
|
|
17
29
|
enabled: true,
|
|
@@ -116,6 +128,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
116
128
|
beforeEach(() => {
|
|
117
129
|
_resetCommitMessageGenerator();
|
|
118
130
|
currentConfig = cloneConfig();
|
|
131
|
+
mockSecureKeys = { anthropic: "sk-test-key" };
|
|
119
132
|
mockSendMessage.mockReset();
|
|
120
133
|
resolvedProvider = {
|
|
121
134
|
provider: mockProvider,
|
|
@@ -149,7 +162,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
149
162
|
|
|
150
163
|
// 3. missing API key
|
|
151
164
|
test('missing API key → returns deterministic, reason "missing_provider_api_key"', async () => {
|
|
152
|
-
|
|
165
|
+
mockSecureKeys = {};
|
|
153
166
|
const gen = getCommitMessageGenerator();
|
|
154
167
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
155
168
|
changedFiles: baseContext.changedFiles,
|
|
@@ -161,7 +174,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
161
174
|
|
|
162
175
|
// 3b. No resolvable provider and no keys
|
|
163
176
|
test('no resolvable provider + no keys → returns deterministic, reason "missing_provider_api_key"', async () => {
|
|
164
|
-
|
|
177
|
+
mockSecureKeys = {};
|
|
165
178
|
resolvedProvider = null;
|
|
166
179
|
const gen = getCommitMessageGenerator();
|
|
167
180
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
@@ -174,10 +187,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
174
187
|
|
|
175
188
|
// 3c. No resolvable provider despite keys
|
|
176
189
|
test('no resolvable provider with keys present → returns deterministic, reason "provider_not_initialized"', async () => {
|
|
177
|
-
|
|
178
|
-
string,
|
|
179
|
-
string
|
|
180
|
-
>;
|
|
190
|
+
mockSecureKeys = { anthropic: "sk-test-key" };
|
|
181
191
|
resolvedProvider = null;
|
|
182
192
|
const gen = getCommitMessageGenerator();
|
|
183
193
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
@@ -332,7 +342,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
332
342
|
// 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
|
|
333
343
|
test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
|
|
334
344
|
(currentConfig as Record<string, unknown>).provider = "ollama";
|
|
335
|
-
|
|
345
|
+
mockSecureKeys = {};
|
|
336
346
|
resolvedProvider = {
|
|
337
347
|
provider: mockProvider,
|
|
338
348
|
configuredProviderName: "ollama",
|
|
@@ -352,10 +362,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
352
362
|
// 13. Unknown provider without fast model default → missing_fast_model, no provider call
|
|
353
363
|
test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
|
|
354
364
|
(currentConfig as Record<string, unknown>).provider = "exotic-provider";
|
|
355
|
-
|
|
356
|
-
string,
|
|
357
|
-
string
|
|
358
|
-
>;
|
|
365
|
+
mockSecureKeys = { "exotic-provider": "sk-exotic" };
|
|
359
366
|
resolvedProvider = {
|
|
360
367
|
provider: mockProvider,
|
|
361
368
|
configuredProviderName: "exotic-provider",
|
|
@@ -374,7 +381,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
374
381
|
// 14. Fast-model override enables LLM path for provider without built-in default
|
|
375
382
|
test("fast-model override enables LLM path for provider without built-in default", async () => {
|
|
376
383
|
(currentConfig as Record<string, unknown>).provider = "ollama";
|
|
377
|
-
|
|
384
|
+
mockSecureKeys = {}; // Ollama is keyless
|
|
378
385
|
resolvedProvider = {
|
|
379
386
|
provider: mockProvider,
|
|
380
387
|
configuredProviderName: "ollama",
|
|
@@ -403,7 +410,7 @@ describe("ProviderCommitMessageGenerator", () => {
|
|
|
403
410
|
test("configured provider unavailable -> selected fallback provider model mapping is used", async () => {
|
|
404
411
|
currentConfig.provider = "anthropic";
|
|
405
412
|
currentConfig.providerOrder = ["openai"];
|
|
406
|
-
|
|
413
|
+
mockSecureKeys = { openai: "sk-openai" };
|
|
407
414
|
resolvedProvider = {
|
|
408
415
|
provider: mockProvider,
|
|
409
416
|
configuredProviderName: "anthropic",
|
|
@@ -10,6 +10,7 @@ import { credentialKey } from "../security/credential-key.js";
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
let mockPlatformBaseUrl = "";
|
|
12
12
|
let mockAssistantApiKey = "";
|
|
13
|
+
let mockProviderKeys: Record<string, string> = {};
|
|
13
14
|
|
|
14
15
|
const actualEnv = await import("../config/env.js");
|
|
15
16
|
mock.module("../config/env.js", () => ({
|
|
@@ -24,7 +25,7 @@ mock.module("../security/secure-keys.js", () => ({
|
|
|
24
25
|
if (key === credentialKey("vellum", "assistant_api_key")) {
|
|
25
26
|
return mockAssistantApiKey || null;
|
|
26
27
|
}
|
|
27
|
-
return null;
|
|
28
|
+
return mockProviderKeys[key] ?? null;
|
|
28
29
|
},
|
|
29
30
|
}));
|
|
30
31
|
|
|
@@ -44,8 +45,8 @@ import { ProviderNotConfiguredError } from "../util/errors.js";
|
|
|
44
45
|
|
|
45
46
|
/** Initialize registry with anthropic + openai for most tests. */
|
|
46
47
|
function setupTwoProviders() {
|
|
48
|
+
mockProviderKeys = { anthropic: "test-key", openai: "test-key" };
|
|
47
49
|
initializeProviders({
|
|
48
|
-
apiKeys: { anthropic: "test-key", openai: "test-key" },
|
|
49
50
|
provider: "anthropic",
|
|
50
51
|
model: "test-model",
|
|
51
52
|
});
|
|
@@ -53,8 +54,8 @@ function setupTwoProviders() {
|
|
|
53
54
|
|
|
54
55
|
/** Initialize registry with no providers (empty keys, non-registerable primary). */
|
|
55
56
|
function setupNoProviders() {
|
|
57
|
+
mockProviderKeys = {};
|
|
56
58
|
initializeProviders({
|
|
57
|
-
apiKeys: {},
|
|
58
59
|
provider: "gemini",
|
|
59
60
|
model: "test-model",
|
|
60
61
|
});
|
|
@@ -183,8 +184,8 @@ describe("managed proxy fallback", () => {
|
|
|
183
184
|
test("openai registered via managed fallback when no user key but proxy context is valid", () => {
|
|
184
185
|
enableManagedProxy();
|
|
185
186
|
try {
|
|
187
|
+
mockProviderKeys = { anthropic: "test-key" };
|
|
186
188
|
initializeProviders({
|
|
187
|
-
apiKeys: { anthropic: "test-key" },
|
|
188
189
|
provider: "anthropic",
|
|
189
190
|
model: "test-model",
|
|
190
191
|
});
|
|
@@ -200,8 +201,8 @@ describe("managed proxy fallback", () => {
|
|
|
200
201
|
test("user key takes precedence over managed fallback", () => {
|
|
201
202
|
enableManagedProxy();
|
|
202
203
|
try {
|
|
204
|
+
mockProviderKeys = { anthropic: "test-key", openai: "user-openai-key" };
|
|
203
205
|
initializeProviders({
|
|
204
|
-
apiKeys: { anthropic: "test-key", openai: "user-openai-key" },
|
|
205
206
|
provider: "anthropic",
|
|
206
207
|
model: "test-model",
|
|
207
208
|
});
|
|
@@ -218,8 +219,8 @@ describe("managed proxy fallback", () => {
|
|
|
218
219
|
|
|
219
220
|
test("managed fallback not activated when proxy context is disabled", () => {
|
|
220
221
|
disableManagedProxy();
|
|
222
|
+
mockProviderKeys = { anthropic: "test-key" };
|
|
221
223
|
initializeProviders({
|
|
222
|
-
apiKeys: { anthropic: "test-key" },
|
|
223
224
|
provider: "anthropic",
|
|
224
225
|
model: "test-model",
|
|
225
226
|
});
|
|
@@ -232,8 +233,8 @@ describe("managed proxy fallback", () => {
|
|
|
232
233
|
test("managed providers participate in failover selection", () => {
|
|
233
234
|
enableManagedProxy();
|
|
234
235
|
try {
|
|
236
|
+
mockProviderKeys = { anthropic: "test-key" };
|
|
235
237
|
initializeProviders({
|
|
236
|
-
apiKeys: { anthropic: "test-key" },
|
|
237
238
|
provider: "anthropic",
|
|
238
239
|
model: "test-model",
|
|
239
240
|
});
|
|
@@ -257,8 +258,8 @@ describe("managed proxy fallback", () => {
|
|
|
257
258
|
enableManagedProxy();
|
|
258
259
|
try {
|
|
259
260
|
// No anthropic key, no gemini key — only managed providers available
|
|
261
|
+
mockProviderKeys = {};
|
|
260
262
|
initializeProviders({
|
|
261
|
-
apiKeys: {},
|
|
262
263
|
provider: "openai",
|
|
263
264
|
model: "test-model",
|
|
264
265
|
});
|