@vellumai/assistant 0.3.28 → 0.4.0
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 +33 -3
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +3 -3
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-dispatch.test.ts +2 -0
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +183 -9
- package/src/__tests__/notification-decision-fallback.test.ts +2 -0
- package/src/__tests__/notification-decision-strategy.test.ts +61 -0
- package/src/__tests__/notification-guardian-path.test.ts +2 -0
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +50 -12
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/relay-server.ts +216 -27
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +19 -0
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/session-agent-loop.ts +5 -5
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +1 -20
- package/src/daemon/session-runtime-assembly.ts +28 -22
- package/src/daemon/session-tool-setup.ts +2 -2
- package/src/daemon/session.ts +3 -3
- package/src/memory/canonical-guardian-store.ts +63 -1
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema.ts +4 -0
- package/src/notifications/copy-composer.ts +15 -0
- package/src/runtime/access-request-helper.ts +43 -7
- package/src/runtime/actor-trust-resolver.ts +46 -50
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -96
- package/src/runtime/guardian-reply-router.ts +31 -1
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +2 -2
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +41 -10
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/tool-approval-handler.ts +11 -11
- package/src/tools/types.ts +2 -2
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'voice-invite-redemption-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
23
|
+
get: () => () => {},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { getSqlite, initializeDb, resetDb } from '../memory/db.js';
|
|
28
|
+
import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
|
|
29
|
+
import { upsertMember } from '../memory/ingress-member-store.js';
|
|
30
|
+
import { redeemVoiceInviteCode } from '../runtime/invite-redemption-service.js';
|
|
31
|
+
import { generateVoiceCode, hashVoiceCode } from '../util/voice-code.js';
|
|
32
|
+
|
|
33
|
+
initializeDb();
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
resetDb();
|
|
37
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function resetTables() {
|
|
41
|
+
getSqlite().run('DELETE FROM assistant_ingress_members');
|
|
42
|
+
getSqlite().run('DELETE FROM assistant_ingress_invites');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// generateVoiceCode
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('generateVoiceCode', () => {
|
|
50
|
+
test('generates a code with the default 6 digits', () => {
|
|
51
|
+
const code = generateVoiceCode();
|
|
52
|
+
expect(code.length).toBe(6);
|
|
53
|
+
expect(/^\d{6}$/.test(code)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('generates a code with the requested digit count', () => {
|
|
57
|
+
for (const digits of [4, 5, 6, 7, 8, 9, 10]) {
|
|
58
|
+
const code = generateVoiceCode(digits);
|
|
59
|
+
expect(code.length).toBe(digits);
|
|
60
|
+
expect(new RegExp(`^\\d{${digits}}$`).test(code)).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('throws for digit count below 4', () => {
|
|
65
|
+
expect(() => generateVoiceCode(3)).toThrow(/between 4 and 10/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('throws for digit count above 10', () => {
|
|
69
|
+
expect(() => generateVoiceCode(11)).toThrow(/between 4 and 10/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('produces different codes across multiple calls (randomness)', () => {
|
|
73
|
+
// Generate many codes and check that we don't get the same one every time.
|
|
74
|
+
// With 6 digits there are 900,000 possibilities, so getting 10 identical
|
|
75
|
+
// codes would be astronomically unlikely.
|
|
76
|
+
const codes = new Set<string>();
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
codes.add(generateVoiceCode());
|
|
79
|
+
}
|
|
80
|
+
// At least 2 distinct values in 10 tries
|
|
81
|
+
expect(codes.size).toBeGreaterThanOrEqual(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('generated code is within the valid numeric range', () => {
|
|
85
|
+
for (let i = 0; i < 20; i++) {
|
|
86
|
+
const code = generateVoiceCode(6);
|
|
87
|
+
const num = parseInt(code, 10);
|
|
88
|
+
// 6 digits: range [100000, 999999]
|
|
89
|
+
expect(num).toBeGreaterThanOrEqual(100000);
|
|
90
|
+
expect(num).toBeLessThanOrEqual(999999);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// hashVoiceCode
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('hashVoiceCode', () => {
|
|
100
|
+
test('produces a deterministic hash', () => {
|
|
101
|
+
const code = '123456';
|
|
102
|
+
const hash1 = hashVoiceCode(code);
|
|
103
|
+
const hash2 = hashVoiceCode(code);
|
|
104
|
+
expect(hash1).toBe(hash2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('produces a hex-encoded SHA-256 hash (64 chars)', () => {
|
|
108
|
+
const hash = hashVoiceCode('654321');
|
|
109
|
+
expect(hash.length).toBe(64);
|
|
110
|
+
expect(/^[0-9a-f]{64}$/.test(hash)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('different codes produce different hashes', () => {
|
|
114
|
+
const hash1 = hashVoiceCode('111111');
|
|
115
|
+
const hash2 = hashVoiceCode('222222');
|
|
116
|
+
expect(hash1).not.toBe(hash2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// redeemVoiceInviteCode
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('redeemVoiceInviteCode', () => {
|
|
125
|
+
beforeEach(resetTables);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Helper: create a voice invite with a known code and return the
|
|
129
|
+
* invite record plus the plaintext code.
|
|
130
|
+
*/
|
|
131
|
+
function createVoiceInvite(opts: {
|
|
132
|
+
callerPhone?: string;
|
|
133
|
+
maxUses?: number;
|
|
134
|
+
expiresInMs?: number;
|
|
135
|
+
voiceCodeDigits?: number;
|
|
136
|
+
assistantId?: string;
|
|
137
|
+
} = {}) {
|
|
138
|
+
const digits = opts.voiceCodeDigits ?? 6;
|
|
139
|
+
const code = generateVoiceCode(digits);
|
|
140
|
+
const codeHash = hashVoiceCode(code);
|
|
141
|
+
|
|
142
|
+
const { invite } = createInvite({
|
|
143
|
+
assistantId: opts.assistantId ?? 'self',
|
|
144
|
+
sourceChannel: 'voice',
|
|
145
|
+
maxUses: opts.maxUses ?? 1,
|
|
146
|
+
expiresInMs: opts.expiresInMs,
|
|
147
|
+
expectedExternalUserId: opts.callerPhone ?? '+15551234567',
|
|
148
|
+
voiceCodeHash: codeHash,
|
|
149
|
+
voiceCodeDigits: digits,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { invite, code };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('happy path: correct caller + correct code redeems successfully', () => {
|
|
156
|
+
const phone = '+15551234567';
|
|
157
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
158
|
+
|
|
159
|
+
const result = redeemVoiceInviteCode({
|
|
160
|
+
callerExternalUserId: phone,
|
|
161
|
+
sourceChannel: 'voice',
|
|
162
|
+
code,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(result.ok).toBe(true);
|
|
166
|
+
expect(result).toMatchObject({
|
|
167
|
+
ok: true,
|
|
168
|
+
type: 'redeemed',
|
|
169
|
+
memberId: expect.any(String),
|
|
170
|
+
inviteId: expect.any(String),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('wrong caller identity fails with generic error', () => {
|
|
175
|
+
const { code } = createVoiceInvite({ callerPhone: '+15551234567' });
|
|
176
|
+
|
|
177
|
+
const result = redeemVoiceInviteCode({
|
|
178
|
+
callerExternalUserId: '+19999999999',
|
|
179
|
+
sourceChannel: 'voice',
|
|
180
|
+
code,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('wrong code fails with generic error', () => {
|
|
187
|
+
createVoiceInvite({ callerPhone: '+15551234567' });
|
|
188
|
+
|
|
189
|
+
const result = redeemVoiceInviteCode({
|
|
190
|
+
callerExternalUserId: '+15551234567',
|
|
191
|
+
sourceChannel: 'voice',
|
|
192
|
+
code: '000000',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('expired invite fails', () => {
|
|
199
|
+
const phone = '+15551234567';
|
|
200
|
+
const { code } = createVoiceInvite({ callerPhone: phone, expiresInMs: -1 });
|
|
201
|
+
|
|
202
|
+
const result = redeemVoiceInviteCode({
|
|
203
|
+
callerExternalUserId: phone,
|
|
204
|
+
sourceChannel: 'voice',
|
|
205
|
+
code,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('max uses exhausted fails', () => {
|
|
212
|
+
const phone = '+15551234567';
|
|
213
|
+
const { code } = createVoiceInvite({ callerPhone: phone, maxUses: 1 });
|
|
214
|
+
|
|
215
|
+
// First redemption succeeds
|
|
216
|
+
const first = redeemVoiceInviteCode({
|
|
217
|
+
callerExternalUserId: phone,
|
|
218
|
+
sourceChannel: 'voice',
|
|
219
|
+
code,
|
|
220
|
+
});
|
|
221
|
+
expect(first.ok).toBe(true);
|
|
222
|
+
|
|
223
|
+
// Second redemption fails — max uses exhausted
|
|
224
|
+
const second = redeemVoiceInviteCode({
|
|
225
|
+
callerExternalUserId: phone,
|
|
226
|
+
sourceChannel: 'voice',
|
|
227
|
+
code,
|
|
228
|
+
});
|
|
229
|
+
expect(second).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('revoked invite fails', () => {
|
|
233
|
+
const phone = '+15551234567';
|
|
234
|
+
const { invite, code } = createVoiceInvite({ callerPhone: phone });
|
|
235
|
+
|
|
236
|
+
revokeInvite(invite.id);
|
|
237
|
+
|
|
238
|
+
const result = redeemVoiceInviteCode({
|
|
239
|
+
callerExternalUserId: phone,
|
|
240
|
+
sourceChannel: 'voice',
|
|
241
|
+
code,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('voice-only invite cannot be redeemed if sourceChannel on invite is not voice', () => {
|
|
248
|
+
// Create a non-voice invite with voice code metadata to simulate a
|
|
249
|
+
// hypothetical misconfiguration. The redemption service filters by
|
|
250
|
+
// sourceChannel='voice', so non-voice invites are invisible.
|
|
251
|
+
const code = generateVoiceCode(6);
|
|
252
|
+
const codeHash = hashVoiceCode(code);
|
|
253
|
+
|
|
254
|
+
createInvite({
|
|
255
|
+
sourceChannel: 'telegram',
|
|
256
|
+
maxUses: 1,
|
|
257
|
+
expectedExternalUserId: '+15551234567',
|
|
258
|
+
voiceCodeHash: codeHash,
|
|
259
|
+
voiceCodeDigits: 6,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const result = redeemVoiceInviteCode({
|
|
263
|
+
callerExternalUserId: '+15551234567',
|
|
264
|
+
sourceChannel: 'voice',
|
|
265
|
+
code,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// findActiveVoiceInvites filters by sourceChannel='voice', so the
|
|
269
|
+
// telegram invite won't be found.
|
|
270
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('already-member caller gets already_member outcome', () => {
|
|
274
|
+
const phone = '+15551234567';
|
|
275
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
276
|
+
|
|
277
|
+
// Pre-create an active member for this phone on voice channel
|
|
278
|
+
upsertMember({
|
|
279
|
+
sourceChannel: 'voice',
|
|
280
|
+
externalUserId: phone,
|
|
281
|
+
status: 'active',
|
|
282
|
+
policy: 'allow',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = redeemVoiceInviteCode({
|
|
286
|
+
callerExternalUserId: phone,
|
|
287
|
+
sourceChannel: 'voice',
|
|
288
|
+
code,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.ok).toBe(true);
|
|
292
|
+
expect(result).toMatchObject({
|
|
293
|
+
ok: true,
|
|
294
|
+
type: 'already_member',
|
|
295
|
+
memberId: expect.any(String),
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('blocked member gets generic failure to avoid leaking membership status', () => {
|
|
300
|
+
const phone = '+15551234567';
|
|
301
|
+
const { code } = createVoiceInvite({ callerPhone: phone });
|
|
302
|
+
|
|
303
|
+
upsertMember({
|
|
304
|
+
sourceChannel: 'voice',
|
|
305
|
+
externalUserId: phone,
|
|
306
|
+
status: 'blocked',
|
|
307
|
+
policy: 'deny',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const result = redeemVoiceInviteCode({
|
|
311
|
+
callerExternalUserId: phone,
|
|
312
|
+
sourceChannel: 'voice',
|
|
313
|
+
code,
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('empty callerExternalUserId fails', () => {
|
|
320
|
+
const result = redeemVoiceInviteCode({
|
|
321
|
+
callerExternalUserId: '',
|
|
322
|
+
sourceChannel: 'voice',
|
|
323
|
+
code: '123456',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(result).toEqual({ ok: false, reason: 'invalid_or_expired' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
});
|
|
@@ -53,6 +53,8 @@ mock.module('../util/logger.js', () => ({
|
|
|
53
53
|
|
|
54
54
|
mock.module('../config/loader.js', () => ({
|
|
55
55
|
getConfig: () => ({
|
|
56
|
+
ui: {},
|
|
57
|
+
|
|
56
58
|
provider: 'anthropic',
|
|
57
59
|
providerOrder: ['anthropic'],
|
|
58
60
|
apiKeys: { anthropic: 'test-key' },
|
|
@@ -266,7 +268,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
266
268
|
|
|
267
269
|
const guardianContext: GuardianRuntimeContext = {
|
|
268
270
|
sourceChannel: 'voice',
|
|
269
|
-
|
|
271
|
+
trustClass: 'trusted_contact',
|
|
270
272
|
requesterExternalUserId: 'caller-123',
|
|
271
273
|
};
|
|
272
274
|
|
|
@@ -307,7 +309,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
307
309
|
|
|
308
310
|
const guardianContext: GuardianRuntimeContext = {
|
|
309
311
|
sourceChannel: 'voice',
|
|
310
|
-
|
|
312
|
+
trustClass: 'trusted_contact',
|
|
311
313
|
requesterExternalUserId: 'caller-123',
|
|
312
314
|
};
|
|
313
315
|
|
|
@@ -343,7 +345,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
343
345
|
|
|
344
346
|
const guardianContext: GuardianRuntimeContext = {
|
|
345
347
|
sourceChannel: 'voice',
|
|
346
|
-
|
|
348
|
+
trustClass: 'trusted_contact',
|
|
347
349
|
};
|
|
348
350
|
|
|
349
351
|
await startVoiceTurn({
|
|
@@ -373,7 +375,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
373
375
|
|
|
374
376
|
const guardianContext: GuardianRuntimeContext = {
|
|
375
377
|
sourceChannel: 'voice',
|
|
376
|
-
|
|
378
|
+
trustClass: 'guardian',
|
|
377
379
|
};
|
|
378
380
|
|
|
379
381
|
await startVoiceTurn({
|
|
@@ -407,7 +409,7 @@ describe('voice bridge confirmation handling (grant consumption via primitive)',
|
|
|
407
409
|
|
|
408
410
|
const guardianContext: GuardianRuntimeContext = {
|
|
409
411
|
sourceChannel: 'voice',
|
|
410
|
-
|
|
412
|
+
trustClass: 'trusted_contact',
|
|
411
413
|
requesterExternalUserId: 'caller-123',
|
|
412
414
|
};
|
|
413
415
|
|
|
@@ -304,7 +304,7 @@ describe('voice-session-bridge', () => {
|
|
|
304
304
|
isInbound: true,
|
|
305
305
|
guardianContext: {
|
|
306
306
|
sourceChannel: 'voice',
|
|
307
|
-
|
|
307
|
+
trustClass: 'trusted_contact',
|
|
308
308
|
guardianExternalUserId: '+15550009999',
|
|
309
309
|
guardianChatId: '+15550009999',
|
|
310
310
|
requesterExternalUserId: '+15550002222',
|
|
@@ -340,7 +340,7 @@ describe('voice-session-bridge', () => {
|
|
|
340
340
|
isInbound: true,
|
|
341
341
|
guardianContext: {
|
|
342
342
|
sourceChannel: 'voice',
|
|
343
|
-
|
|
343
|
+
trustClass: 'unknown',
|
|
344
344
|
denialReason: 'no_binding',
|
|
345
345
|
},
|
|
346
346
|
onTextDelta: () => {},
|
|
@@ -374,7 +374,7 @@ describe('voice-session-bridge', () => {
|
|
|
374
374
|
isInbound: true,
|
|
375
375
|
guardianContext: {
|
|
376
376
|
sourceChannel: 'voice',
|
|
377
|
-
|
|
377
|
+
trustClass: 'guardian',
|
|
378
378
|
guardianExternalUserId: '+15550001111',
|
|
379
379
|
guardianChatId: '+15550001111',
|
|
380
380
|
},
|
|
@@ -407,7 +407,7 @@ describe('voice-session-bridge', () => {
|
|
|
407
407
|
|
|
408
408
|
const guardianCtx = {
|
|
409
409
|
sourceChannel: 'voice' as const,
|
|
410
|
-
|
|
410
|
+
trustClass: 'guardian' as const,
|
|
411
411
|
guardianExternalUserId: '+15550001111',
|
|
412
412
|
guardianChatId: '+15550001111',
|
|
413
413
|
};
|
|
@@ -450,7 +450,7 @@ describe('voice-session-bridge', () => {
|
|
|
450
450
|
isInbound: true,
|
|
451
451
|
guardianContext: {
|
|
452
452
|
sourceChannel: 'voice',
|
|
453
|
-
|
|
453
|
+
trustClass: 'trusted_contact',
|
|
454
454
|
},
|
|
455
455
|
onTextDelta: () => {},
|
|
456
456
|
onComplete: () => {},
|
|
@@ -499,7 +499,7 @@ describe('voice-session-bridge', () => {
|
|
|
499
499
|
isInbound: true,
|
|
500
500
|
guardianContext: {
|
|
501
501
|
sourceChannel: 'voice',
|
|
502
|
-
|
|
502
|
+
trustClass: 'trusted_contact',
|
|
503
503
|
},
|
|
504
504
|
onTextDelta: () => {},
|
|
505
505
|
onComplete: () => {},
|
|
@@ -574,7 +574,7 @@ describe('voice-session-bridge', () => {
|
|
|
574
574
|
isInbound: true,
|
|
575
575
|
guardianContext: {
|
|
576
576
|
sourceChannel: 'voice',
|
|
577
|
-
|
|
577
|
+
trustClass: 'trusted_contact',
|
|
578
578
|
guardianExternalUserId: '+15550009999',
|
|
579
579
|
guardianChatId: '+15550009999',
|
|
580
580
|
requesterExternalUserId: '+15550002222',
|
|
@@ -644,7 +644,7 @@ describe('voice-session-bridge', () => {
|
|
|
644
644
|
isInbound: true,
|
|
645
645
|
guardianContext: {
|
|
646
646
|
sourceChannel: 'voice',
|
|
647
|
-
|
|
647
|
+
trustClass: 'unknown',
|
|
648
648
|
denialReason: 'no_binding',
|
|
649
649
|
},
|
|
650
650
|
onTextDelta: () => {},
|
|
@@ -768,7 +768,7 @@ describe('voice-session-bridge', () => {
|
|
|
768
768
|
isInbound: true,
|
|
769
769
|
guardianContext: {
|
|
770
770
|
sourceChannel: 'voice',
|
|
771
|
-
|
|
771
|
+
trustClass: 'guardian',
|
|
772
772
|
guardianExternalUserId: '+15550001111',
|
|
773
773
|
guardianChatId: '+15550001111',
|
|
774
774
|
},
|
|
@@ -835,7 +835,7 @@ describe('voice-session-bridge', () => {
|
|
|
835
835
|
isInbound: true,
|
|
836
836
|
guardianContext: {
|
|
837
837
|
sourceChannel: 'voice',
|
|
838
|
-
|
|
838
|
+
trustClass: 'guardian',
|
|
839
839
|
guardianExternalUserId: '+15550001111',
|
|
840
840
|
guardianChatId: '+15550001111',
|
|
841
841
|
},
|
|
@@ -10,8 +10,12 @@ import { existsSync,mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
|
|
13
|
-
import { afterEach,beforeEach, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
_resetEnrichmentService,
|
|
17
|
+
getEnrichmentService,
|
|
18
|
+
} from '../workspace/commit-message-enrichment-service.js';
|
|
15
19
|
import {
|
|
16
20
|
_resetGitServiceRegistry,
|
|
17
21
|
getWorkspaceGitService,
|
|
@@ -36,12 +40,19 @@ describe('Workspace git lifecycle (integration)', () => {
|
|
|
36
40
|
_resetHeartbeatState();
|
|
37
41
|
});
|
|
38
42
|
|
|
39
|
-
afterEach(() => {
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
45
|
+
_resetEnrichmentService();
|
|
40
46
|
if (existsSync(testDir)) {
|
|
41
47
|
rmSync(testDir, { recursive: true, force: true });
|
|
42
48
|
}
|
|
43
49
|
});
|
|
44
50
|
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
try { await getEnrichmentService().shutdown(); } catch { /* ignore */ }
|
|
53
|
+
_resetEnrichmentService();
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
// Build a clean git env: strip all GIT_* env vars that CI runners
|
|
46
57
|
// inject, then set GIT_CEILING_DIRECTORIES to isolate test repos.
|
|
47
58
|
function gitEnv(cwd: string): Record<string, string> {
|
|
@@ -12,13 +12,11 @@ import { getGatewayInternalBaseUrl } from '../config/env.js';
|
|
|
12
12
|
import type { ServerMessage } from '../daemon/ipc-contract.js';
|
|
13
13
|
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
markTimedOutWithReason,
|
|
21
|
-
} from '../memory/guardian-action-store.js';
|
|
15
|
+
expireCanonicalGuardianRequest,
|
|
16
|
+
getCanonicalRequestByPendingQuestionId,
|
|
17
|
+
getPendingCanonicalRequestByCallSessionId,
|
|
18
|
+
listCanonicalGuardianDeliveries,
|
|
19
|
+
} from '../memory/canonical-guardian-store.js';
|
|
22
20
|
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
23
21
|
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
24
22
|
import { getLogger } from '../util/logger.js';
|
|
@@ -709,8 +707,7 @@ export class CallController {
|
|
|
709
707
|
effectiveToolMeta && this.pendingConsultation.toolApprovalMeta
|
|
710
708
|
? effectiveToolMeta.toolName === this.pendingConsultation.toolApprovalMeta.toolName
|
|
711
709
|
&& effectiveToolMeta.inputDigest === this.pendingConsultation.toolApprovalMeta.inputDigest
|
|
712
|
-
: !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta
|
|
713
|
-
&& questionText === this.pendingConsultation.questionText;
|
|
710
|
+
: !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta;
|
|
714
711
|
|
|
715
712
|
if (isSameToolAction) {
|
|
716
713
|
// Same tool/action — coalesce. Keep the existing consultation
|
|
@@ -728,11 +725,11 @@ export class CallController {
|
|
|
728
725
|
// Expire the previous consultation's storage records so stale
|
|
729
726
|
// guardian answers cannot match the old request.
|
|
730
727
|
expirePendingQuestions(this.callSessionId);
|
|
731
|
-
const previousRequest =
|
|
728
|
+
const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
|
|
732
729
|
if (previousRequest) {
|
|
733
730
|
// Immediately expire with 'superseded' reason to prevent
|
|
734
731
|
// stale answers from resolving the old request.
|
|
735
|
-
|
|
732
|
+
expireCanonicalGuardianRequest(previousRequest.id);
|
|
736
733
|
log.info(
|
|
737
734
|
{ callSessionId: this.callSessionId, requestId: previousRequest.id },
|
|
738
735
|
'Superseded guardian action request (materially different intent)',
|
|
@@ -769,9 +766,9 @@ export class CallController {
|
|
|
769
766
|
// a completed call with a dangling pendingQuestion, and guardian
|
|
770
767
|
// replies are cleanly rejected instead of hitting answerCall failures.
|
|
771
768
|
expirePendingQuestions(this.callSessionId);
|
|
772
|
-
const previousRequest =
|
|
769
|
+
const previousRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
|
|
773
770
|
if (previousRequest) {
|
|
774
|
-
|
|
771
|
+
expireCanonicalGuardianRequest(previousRequest.id);
|
|
775
772
|
}
|
|
776
773
|
|
|
777
774
|
this.pendingConsultation = null;
|
|
@@ -857,7 +854,7 @@ export class CallController {
|
|
|
857
854
|
}
|
|
858
855
|
|
|
859
856
|
private isCallerGuardian(): boolean {
|
|
860
|
-
return this.guardianContext?.
|
|
857
|
+
return this.guardianContext?.trustClass === 'guardian';
|
|
861
858
|
}
|
|
862
859
|
|
|
863
860
|
/**
|
|
@@ -896,11 +893,16 @@ export class CallController {
|
|
|
896
893
|
inputDigest: effectiveToolMeta?.inputDigest,
|
|
897
894
|
}).then(() => {
|
|
898
895
|
// Backfill supersession chain: now that the new request exists in
|
|
899
|
-
// the store,
|
|
896
|
+
// the store, link the old request to the new one.
|
|
900
897
|
if (supersededRequestId) {
|
|
901
|
-
const newRequest =
|
|
898
|
+
const newRequest = getCanonicalRequestByPendingQuestionId(stablePendingQuestionId);
|
|
902
899
|
if (newRequest) {
|
|
903
|
-
|
|
900
|
+
// Canonical store does not track supersession metadata;
|
|
901
|
+
// the old request was already expired above.
|
|
902
|
+
log.info(
|
|
903
|
+
{ callSessionId: this.callSessionId, oldRequestId: supersededRequestId, newRequestId: newRequest.id },
|
|
904
|
+
'Supersession chain: new canonical request created',
|
|
905
|
+
);
|
|
904
906
|
}
|
|
905
907
|
}
|
|
906
908
|
});
|
|
@@ -918,17 +920,18 @@ export class CallController {
|
|
|
918
920
|
// send expiry notices to guardian destinations. Deliveries
|
|
919
921
|
// must be captured before markTimedOutWithReason changes
|
|
920
922
|
// their status.
|
|
921
|
-
const pendingActionRequest =
|
|
923
|
+
const pendingActionRequest = getPendingCanonicalRequestByCallSessionId(this.callSessionId);
|
|
922
924
|
if (pendingActionRequest) {
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
+
const canonicalDeliveries = listCanonicalGuardianDeliveries(pendingActionRequest.id);
|
|
926
|
+
// Expire the canonical request and its deliveries
|
|
927
|
+
expireCanonicalGuardianRequest(pendingActionRequest.id);
|
|
925
928
|
log.info(
|
|
926
929
|
{ callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
|
|
927
|
-
'Marked guardian
|
|
930
|
+
'Marked canonical guardian request as timed out',
|
|
928
931
|
);
|
|
929
932
|
void sendGuardianExpiryNotices(
|
|
930
|
-
|
|
931
|
-
|
|
933
|
+
canonicalDeliveries,
|
|
934
|
+
this.assistantId,
|
|
932
935
|
getGatewayInternalBaseUrl(),
|
|
933
936
|
readHttpToken() ?? undefined,
|
|
934
937
|
).catch((err) => {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { addMessage } from '../memory/conversation-store.js';
|
|
13
|
-
import type { GuardianActionDelivery } from '../memory/guardian-action-store.js';
|
|
14
13
|
import {
|
|
15
14
|
expireGuardianActionRequest,
|
|
16
15
|
getDeliveriesByRequestId,
|
|
@@ -37,8 +36,17 @@ let sweepInProgress = false;
|
|
|
37
36
|
* Deliveries must be captured *before* their status is changed to 'expired'
|
|
38
37
|
* so the sent/pending filter still matches.
|
|
39
38
|
*/
|
|
39
|
+
/** Minimal delivery shape used by the expiry notice sender. */
|
|
40
|
+
export interface ExpiryDeliveryInfo {
|
|
41
|
+
id: string;
|
|
42
|
+
status: string;
|
|
43
|
+
destinationChannel: string;
|
|
44
|
+
destinationConversationId: string | null;
|
|
45
|
+
destinationChatId: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
export async function sendGuardianExpiryNotices(
|
|
41
|
-
deliveries:
|
|
49
|
+
deliveries: ExpiryDeliveryInfo[],
|
|
42
50
|
assistantId: string,
|
|
43
51
|
gatewayBaseUrl: string,
|
|
44
52
|
bearerToken?: string,
|