Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
102b9a8
fix(macos): add disabled guard to SwiftUI conversation zoom commands …
noanflaherty Feb 26, 2026
d057094
feat: reskin Mobile card to match Guardian Verification visual patter…
noanflaherty Feb 26, 2026
bf0ecde
fix: preserve text in stripHeavyContent, add early-return guard (#9313)
ashleeradka Feb 26, 2026
3b5e1bc
fix: guard trimForBackground against in-flight pagination (#9314)
ashleeradka Feb 26, 2026
ef77a0d
fix: omit textSegments when truncated, handle legacy tool results in …
ashleeradka Feb 26, 2026
118fa6c
fix: restore Button semantics for heartbeat run row expansion (#9304)
vincent0426 Feb 26, 2026
a34a9bb
fix: clear isContentStripped after rehydration for re-trim eligibilit…
ashleeradka Feb 26, 2026
f149222
fix(macos): use macOS 14-compatible SF Symbol for refresh buttons (#9…
noanflaherty Feb 26, 2026
df25509
fix: positional tool call matching, cache invalidation, contentOrder …
ashleeradka Feb 26, 2026
a510533
fix: same-timestamp getNextMessage + separate text truncation flag (#…
ashleeradka Feb 26, 2026
5b3e6ff
fix(macos): improve refresh button UX on Connect status rows (#9321)
noanflaherty Feb 26, 2026
0300ca3
fix(macos): simplify Platform connection error to "Could not connect"…
noanflaherty Feb 26, 2026
15f7640
fix: fail local hatch if base data directory already exists (#9322)
dvargasfuertes Feb 26, 2026
1523f31
fix: populate cachedInputFull on expand via onChange to prevent flash…
ashleeradka Feb 26, 2026
a1628cd
fix: proper compound cursor for deterministic pagination (#9326)
ashleeradka Feb 26, 2026
47d5c5b
fix: prevent pagination timeout from misclassifying late responses (#…
ashleeradka Feb 26, 2026
62b5cab
fix(macos): simplify Telegram guardian verification help text into si…
noanflaherty Feb 26, 2026
272f335
fix(macos): resolve two build errors on main (#9329)
noanflaherty Feb 26, 2026
74e6ce8
fix: remove unused parseFrontmatter function in CLI skills command (#…
siddseethepalli Feb 26, 2026
acf008f
feat: add vellum login/logout/whoami commands (#9178)
dvargasfuertes Feb 26, 2026
399c114
fix(macos): fix build errors from cross-module access and invalid Ani…
ashleeradka Feb 26, 2026
a5fe5ed
feat: outbound guardian verification via code entry in Settings UI (#…
noanflaherty Feb 26, 2026
b6b58e1
revert #9333 + rework guardian: show code in UI, reply in channel (#9…
noanflaherty Feb 26, 2026
7b7d210
Changes needed for Vellum Assistant on Mac Mini (#9335)
dvargasfuertes Feb 26, 2026
7e6e6ee
feat: accept bare verification codes and simplify guardian verify cop…
noanflaherty Feb 26, 2026
0f820fe
feat: use 6-digit codes for all channels and update verification copy…
noanflaherty Feb 26, 2026
19bf71a
docs: update skills and architecture for 6-digit verification codes (…
noanflaherty Feb 26, 2026
6fc814b
fix: prevent voice/phone call threads from leaking into desktop side …
noanflaherty Feb 26, 2026
3b74b06
docs: align guardian verification copy with create_challenge flow (#9…
noanflaherty Feb 26, 2026
fadb132
feat: install CLI symlink during desktop hatch and add --version flag…
dvargasfuertes Feb 26, 2026
25b9515
fix: add non-null assertion for Drizzle or() return type in conversat…
siddseethepalli Feb 26, 2026
bb216bb
fix: remove unused existsSync import in CLI gcp.ts (#9352)
siddseethepalli Feb 26, 2026
b8f8a56
fix(notifications): deduplicate conversation-viewed events by moving …
noanflaherty Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion assistant/src/__tests__/call-pointer-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,18 @@ describe('addPointerMessage', () => {
addPointerMessage(convId, 'started', '+15551234567');
const text = getLatestAssistantText(convId);
expect(text).toContain('Call to +15551234567 started');
expect(text).toContain('See voice thread');
expect(text).not.toContain('See voice thread');
});

test('started pointer message does not set userMessageChannel metadata', () => {
const convId = 'conv-ptr-no-channel';
ensureConversation(convId);
addPointerMessage(convId, 'started', '+15551234567');
const rows = getMessages(convId).filter((m) => m.role === 'assistant');
expect(rows.length).toBe(1);
const metadata = rows[0].metadata ? JSON.parse(rows[0].metadata) : null;
// metadata should be null/undefined — no userMessageChannel set
expect(metadata?.userMessageChannel).toBeUndefined();
});

test('adds a started pointer message with verification code', () => {
Expand Down
54 changes: 28 additions & 26 deletions assistant/src/__tests__/channel-guardian.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,27 +389,26 @@ describe('guardian service challenge validation', () => {

expect(result.challengeId).toBeDefined();
expect(result.secret).toBeDefined();
expect(result.secret.length).toBe(64); // 32 bytes hex-encoded
expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`);
expect(result.secret.length).toBe(6); // 6-digit numeric code
expect(result.verifyCommand).toBe(result.secret);
expect(result.ttlSeconds).toBe(600);
expect(result.instruction).toBeDefined();
expect(result.instruction.length).toBeGreaterThan(0);
expect(result.instruction).toContain('/guardian_verify');
expect(result.instruction).toContain('code you were given');
});

test('createVerificationChallenge produces a non-empty instruction for telegram channel', () => {
const result = createVerificationChallenge('asst-1', 'telegram');
expect(result.instruction).toBeDefined();
expect(result.instruction.length).toBeGreaterThan(0);
expect(result.instruction).toContain(result.verifyCommand);
expect(result.instruction).toContain('code you were given');
});

test('createVerificationChallenge produces a non-empty instruction for sms channel', () => {
const result = createVerificationChallenge('asst-1', 'sms');
expect(result.instruction).toBeDefined();
expect(result.instruction.length).toBeGreaterThan(0);
expect(result.instruction).toContain('/guardian_verify');
expect(result.instruction).toContain(result.verifyCommand);
expect(result.instruction).toContain('code you were given');
});

test('validateAndConsumeChallenge succeeds with correct secret', () => {
Expand Down Expand Up @@ -1557,25 +1556,24 @@ describe('voice guardian challenge generation', () => {
expect(result.secret.length).toBe(6);
});

test('createVerificationChallenge for non-voice returns 64-char hex secret', () => {
test('createVerificationChallenge for non-voice returns 6-digit numeric secret', () => {
const result = createVerificationChallenge('asst-1', 'telegram');

expect(result.secret.length).toBe(64);
expect(result.secret).toMatch(/^[a-f0-9]{64}$/);
expect(result.secret.length).toBe(6);
expect(result.secret).toMatch(/^\d{6}$/);
});

test('voice challenge verifyCommand contains the six-digit secret', () => {
const result = createVerificationChallenge('asst-1', 'voice');

expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`);
expect(result.verifyCommand).toBe(result.secret);
});

test('voice challenge instruction contains voice-specific copy', () => {
const result = createVerificationChallenge('asst-1', 'voice');

expect(result.instruction).toContain('six-digit code');
expect(result.instruction).toContain(result.secret);
expect(result.instruction).toContain('minutes');
});

test('voice challenge secrets are different across calls', () => {
Expand Down Expand Up @@ -2772,16 +2770,15 @@ describe('outbound SMS verification', () => {
GUARDIAN_VERIFY_TEMPLATE_KEYS.CHALLENGE_REQUEST,
{ code: '123456', expiresInMinutes: 10 },
);
expect(challengeSms).toContain('123456');
expect(challengeSms).toContain('10 minutes');
expect(challengeSms).toContain('verification code');
// Code should NOT appear in the message — user sees it in the app
expect(challengeSms).not.toContain('123456');
expect(challengeSms).toContain('code you were given');

const resendSms = composeVerificationSms(
GUARDIAN_VERIFY_TEMPLATE_KEYS.RESEND,
{ code: 'ABCDEF', expiresInMinutes: 5 },
);
expect(resendSms).toContain('ABCDEF');
expect(resendSms).toContain('5 minutes');
expect(resendSms).not.toContain('ABCDEF');
expect(resendSms).toContain('resent');

const alreadySms = composeVerificationSms(
Expand Down Expand Up @@ -2870,13 +2867,14 @@ describe('outbound SMS verification', () => {
expect(lastSms.to).toBe('+15551234567');
});

test('template composer includes assistantName when provided', () => {
test('template composer includes Vellum assistant prefix', () => {
const sms = composeVerificationSms(
GUARDIAN_VERIFY_TEMPLATE_KEYS.CHALLENGE_REQUEST,
{ code: '999999', expiresInMinutes: 10, assistantName: 'MyBot' },
);
expect(sms).toContain('[MyBot]');
expect(sms).toContain('999999');
expect(sms).toContain('Vellum assistant');
// Code should NOT appear in the message
expect(sms).not.toContain('999999');
});

test('cancel_outbound returns error when no active session', () => {
Expand Down Expand Up @@ -2984,7 +2982,7 @@ describe('outbound Telegram verification', () => {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(telegramDeliverCalls.length).toBe(1);
expect(telegramDeliverCalls[0].chatId).toBe('123456789');
expect(telegramDeliverCalls[0].text).toContain('/guardian_verify');
expect(telegramDeliverCalls[0].text).toContain('code you were given');
});

test('start_outbound for telegram without bot username fails', () => {
Expand Down Expand Up @@ -3273,30 +3271,34 @@ describe('outbound Telegram verification', () => {
expect(revoked).toBeNull();
});

test('telegram template includes /guardian_verify command', () => {
test('telegram template includes verification instruction without code', () => {
const msg = composeVerificationTelegram(
GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST,
{ code: 'abc123', expiresInMinutes: 10 },
);
expect(msg).toContain('/guardian_verify abc123');
// Should ask user to reply with code but NOT include the actual code
expect(msg).toContain('code you were given');
expect(msg).not.toContain('abc123');
});

test('telegram resend template includes (resent) suffix', () => {
const msg = composeVerificationTelegram(
GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_RESEND,
{ code: 'xyz789', expiresInMinutes: 5 },
);
expect(msg).toContain('/guardian_verify xyz789');
expect(msg).toContain('code you were given');
expect(msg).not.toContain('xyz789');
expect(msg).toContain('(resent)');
});

test('telegram template includes assistantName when provided', () => {
test('telegram template includes Vellum assistant prefix', () => {
const msg = composeVerificationTelegram(
GUARDIAN_VERIFY_TEMPLATE_KEYS.TELEGRAM_CHALLENGE_REQUEST,
{ code: '999999', expiresInMinutes: 10, assistantName: 'MyBot' },
);
expect(msg).toContain('[MyBot]');
expect(msg).toContain('999999');
expect(msg).toContain('Vellum assistant');
// Code should NOT appear in the message
expect(msg).not.toContain('999999');
});

test('start_outbound for telegram with missing destination fails', () => {
Expand Down
116 changes: 116 additions & 0 deletions assistant/src/__tests__/guardian-verification-voice-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Regression test: guardian verification calls must create a voice channel
* binding so the conversation never appears as an unbound desktop thread.
*/
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterAll, describe, expect, mock, test } from 'bun:test';

const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'guardian-verify-binding-test-')));

mock.module('../util/platform.js', () => ({
getRootDir: () => testDir,
getDataDir: () => testDir,
isMacOS: () => process.platform === 'darwin',
isLinux: () => process.platform === 'linux',
isWindows: () => process.platform === 'win32',
getSocketPath: () => join(testDir, 'test.sock'),
getPidPath: () => join(testDir, 'test.pid'),
getDbPath: () => join(testDir, 'test.db'),
getLogPath: () => join(testDir, 'test.log'),
ensureDataDir: () => {},
}));

mock.module('../util/logger.js', () => ({
getLogger: () =>
new Proxy({} as Record<string, unknown>, {
get: () => () => {},
}),
}));

mock.module('../calls/twilio-config.js', () => ({
getTwilioConfig: () => ({
accountSid: 'AC_test',
authToken: 'test_token',
phoneNumber: '+15550001111',
webhookBaseUrl: 'https://test.example.com',
wssBaseUrl: 'wss://test.example.com',
}),
}));

mock.module('../calls/twilio-provider.js', () => ({
TwilioConversationRelayProvider: class {
async checkCallerIdEligibility() {
return { eligible: true };
}
async initiateCall() {
return { callSid: 'CA_test_guardian_verify' };
}
},
}));

mock.module('../security/secure-keys.js', () => ({
getSecureKey: () => null,
}));

mock.module('../config/env.js', () => ({
getTwilioUserPhoneNumber: () => null,
}));

mock.module('../inbound/public-ingress-urls.js', () => ({
getTwilioVoiceWebhookUrl: () => 'https://test.example.com/voice',
getTwilioStatusCallbackUrl: () => 'https://test.example.com/status',
}));

mock.module('../config/loader.js', () => ({
loadConfig: () => ({
calls: {
callerIdentity: {
allowPerCallOverride: true,
},
},
}),
}));

mock.module('../runtime/channel-guardian-service.js', () => ({
isGuardian: () => false,
}));

mock.module('../memory/conversation-title-service.js', () => ({
queueGenerateConversationTitle: () => {},
}));

import { startGuardianVerificationCall } from '../calls/call-domain.js';
import { getBindingByConversation } from '../memory/external-conversation-store.js';
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
import { initializeDb, resetDb } from '../memory/db.js';

initializeDb();

afterAll(() => {
resetDb();
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
});

describe('startGuardianVerificationCall — voice binding', () => {
test('creates a voice channel binding for the guardian verification conversation', async () => {
const sessionId = 'gv-session-001';
const result = await startGuardianVerificationCall({
phoneNumber: '+15559999999',
guardianVerificationSessionId: sessionId,
});

expect(result.ok).toBe(true);

// Look up the conversation that was created for this guardian verification
const convKey = `guardian-verify:${sessionId}`;
const { conversationId } = getOrCreateConversation(convKey);

// The conversation must have a voice channel binding
const binding = getBindingByConversation(conversationId);
expect(binding).not.toBeNull();
expect(binding!.sourceChannel).toBe('voice');
});
});
14 changes: 10 additions & 4 deletions assistant/src/calls/call-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ export type StartGuardianVerificationCallResult =
/**
* Initiate an outbound call to the guardian's phone for verification.
*
* Creates a minimal call session (no task, no voice conversation) and
* Creates a minimal call session with a voice channel binding and
* passes `guardianVerificationSessionId` as a custom parameter so the
* relay server can detect this is a guardian verification call.
*/
Expand All @@ -606,12 +606,18 @@ export async function startGuardianVerificationCall(
return { ok: false, error: identityResult.error, status: 400 };
}

// Create a minimal conversation so the call session has a valid FK.
// The relay will detect the guardianVerificationSessionId custom param
// and enter verification mode instead of starting a normal agent flow.
// Create a minimal conversation so the call session has a valid FK,
// and bind it to the voice channel so it never appears as an unbound
// desktop thread.
const convKey = `guardian-verify:${guardianVerificationSessionId}`;
const { conversationId } = getOrCreateConversation(convKey);

upsertBinding({
conversationId,
sourceChannel: 'voice',
externalChatId: `guardian-verify:${guardianVerificationSessionId}`,
});

const session = createCallSession({
conversationId,
provider: 'twilio',
Expand Down
7 changes: 5 additions & 2 deletions assistant/src/calls/call-pointer-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function addPointerMessage(
case 'started':
text = extra?.verificationCode
? `\u{1F4DE} Call to ${phoneNumber} started. Verification code: ${extra.verificationCode}`
: `\u{1F4DE} Call to ${phoneNumber} started. See voice thread for details.`;
: `\u{1F4DE} Call to ${phoneNumber} started.`;
break;
case 'completed':
text = extra?.duration
Expand All @@ -33,11 +33,14 @@ export function addPointerMessage(
break;
}

// Pointer messages are assistant-generated status updates in the initiating
// desktop thread. Do not set userMessageChannel — doing so would mark the
// conversation's origin channel as voice, causing it to leak into the
// desktop thread list as a channel-bound session.
conversationStore.addMessage(
conversationId,
'assistant',
JSON.stringify([{ type: 'text', text }]),
{ userMessageChannel: 'voice', assistantMessageChannel: 'voice', userMessageInterface: 'voice', assistantMessageInterface: 'voice' },
);
}

Expand Down
13 changes: 6 additions & 7 deletions assistant/src/config/vellum-skills/telegram-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,14 @@ If the webhook secret changes (e.g., secret rotation), the gateway's credential

### Step 4: Register Bot Commands

Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` and `/guardian_verify` commands:
Send the `telegram_config` IPC message with `action: "set_commands"` to register the `/new` command:

```json
{
"type": "telegram_config",
"action": "set_commands",
"commands": [
{ "command": "new", "description": "Start a new conversation" },
{ "command": "guardian_verify", "description": "Verify your guardian identity" }
{ "command": "new", "description": "Start a new conversation" }
]
}
```
Expand All @@ -74,9 +73,9 @@ Now link the user's Telegram account as the trusted guardian for this bot. Tell
}
```

2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the instruction to the user. It will look like: "Send `/guardian_verify <secret>` to your bot from your Telegram account within 10 minutes."
2. The daemon returns a `guardian_verification_response` with `success: true`, `secret`, and `instruction`. Display the 6-digit `secret` code to the user. Tell them: "Open your chat with the Telegram bot and send this 6-digit code as a message."

3. Wait for the user to confirm they have sent the command. The verification happens automatically when the bot receives the `/guardian_verify` message — the channel inbound handler validates the token and creates the guardian binding.
3. Wait for the user to confirm they have replied with the code. The verification happens automatically when the bot receives the code — the channel inbound handler validates it and creates the guardian binding.

4. If the user confirms success: "Guardian verified! Your Telegram account is now the trusted guardian for this bot."

Expand Down Expand Up @@ -109,7 +108,7 @@ Summarize what was done:
- Bot identity: @{botUsername}
- Bot verified and credentials stored securely via daemon
- Webhook registration: handled automatically by the gateway
- Bot commands registered: /new, /guardian_verify
- Bot commands registered: /new
- Guardian identity: {verified | not configured}
- Guardian verification status: {verified via challenge | skipped}
- Routing configuration validated
Expand Down Expand Up @@ -144,5 +143,5 @@ The following steps still require **manual** action:
|------|---------|
| Bot token from @BotFather | User must create a bot and provide the token via secure prompt |
| Bot command registration | Registered via the setup skill (Step 4 above) |
| Guardian verification | User sends `/guardian_verify <secret>` to the bot (Step 5 above) |
| Guardian verification | User replies with the verification code in the bot's Telegram chat (Step 5 above) |
| Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration |
Loading
Loading