Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4277,6 +4277,14 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the orchestra

7. **Separation from channel guardian approvals**: Guardian action requests are SEPARATE from `channelGuardianApprovalRequests` (the existing channel tool-approval system). The channel guardian approval system handles tool-use permission grants (approve/deny a specific tool invocation). Guardian action requests handle free-form questions from voice calls that require human input to continue the conversation.

#### Guardian Request Copy Generation Pipeline

Thread titles and initial messages for guardian question threads are generated via `guardian-question-copy.ts`. The module calls the configured LLM provider (with `modelIntent: 'latency-optimized'`) to produce an emoji-prefixed, attention-oriented title and a richer initial message that explains the live-call context. A 5-second timeout guards the generation call. When no provider is configured, generation times out, or parsing fails, the module falls back to deterministic copy (`buildFallbackCopy`) that uses a warning emoji prefix and a simple template containing the question text. The generative copy is awaited only in the macOS delivery branch so that Telegram/SMS deliveries dispatch without LLM latency.

#### macOS Notification + Deep-Link Flow

When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification under the `GUARDIAN_REQUEST` category. The notification title mirrors the emoji-prefixed thread title from the copy generation pipeline. Tapping the notification triggers the `openConversationThread` deep-link handler, which switches the main window to the guardian question thread so the user can reply immediately.

### SQLite Tables

All five tables live in `~/.vellum/workspace/data/db/assistant.db` alongside existing tables:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2614,6 +2614,7 @@ exports[`IPC message snapshots ServerMessage types guardian_request_thread_creat
{
"callSessionId": "call-001",
"conversationId": "conv-guardian-001",
"questionText": "What is the gate code?",
"requestId": "req-guardian-001",
"title": "Guardian action request",
"type": "guardian_request_thread_created",
Expand Down
136 changes: 136 additions & 0 deletions assistant/src/__tests__/guardian-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ mock.module('../runtime/gateway-client.js', () => ({
},
}));

// Mock guardian-question-copy to return deterministic values without hitting a real provider.
// Only generateGuardianCopy (the async LLM call) is mocked; buildFallbackCopy is the real
// implementation passed through so guardian-dispatch can use it if needed.
let mockGuardianCopy = {
threadTitle: '\u{1F6A8} Caller needs the gate code',
initialMessage: 'Your assistant needs your input during a live phone call.\n\nQuestion: What is the gate code?\n\nReply to this message with your answer.',
};

mock.module('../calls/guardian-question-copy.js', () => ({
generateGuardianCopy: async (questionText: string) => ({
threadTitle: mockGuardianCopy.threadTitle,
initialMessage: mockGuardianCopy.initialMessage.includes(questionText)
? mockGuardianCopy.initialMessage
: mockGuardianCopy.initialMessage.replace(/Question: .*/, `Question: ${questionText}`),
}),
// Pass through the real buildFallbackCopy implementation (tested in guardian-question-copy.test.ts)
buildFallbackCopy: (questionText: string) => ({
threadTitle: `\u26A0\uFE0F ${questionText.slice(0, 70)}`,
initialMessage: [
'Your assistant needs your input during a phone call.',
'',
`Question: ${questionText}`,
'',
'Reply to this message with your answer.',
].join('\n'),
}),
}));

import { initializeDb, getDb, resetDb } from '../memory/db.js';
import { conversations } from '../memory/schema.js';
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
Expand Down Expand Up @@ -87,6 +115,10 @@ function resetTables(): void {
mockTelegramBinding = null;
mockSmsBinding = null;
deliveredMessages.length = 0;
mockGuardianCopy = {
threadTitle: '\u{1F6A8} Caller needs the gate code',
initialMessage: 'Your assistant needs your input during a live phone call.\n\nQuestion: What is the gate code?\n\nReply to this message with your answer.',
};
}

describe('guardian-dispatch', () => {
Expand Down Expand Up @@ -250,4 +282,108 @@ describe('guardian-dispatch', () => {
pendingQuestion: pq,
})).resolves.toBeUndefined();
});

test('broadcast title is emoji-prefixed and does not start with "Guardian question:"', async () => {
const convId = 'conv-dispatch-6';
ensureConversation(convId);

const session = createCallSession({
conversationId: convId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});
const pq = createPendingQuestion(session.id, 'What is the gate code?');

const broadcastedMessages: unknown[] = [];
const broadcastFn = (msg: unknown) => { broadcastedMessages.push(msg); };

await dispatchGuardianQuestion({
callSessionId: session.id,
conversationId: convId,
assistantId: 'self',
pendingQuestion: pq,
broadcast: broadcastFn,
});

const msg = broadcastedMessages[0] as Record<string, unknown>;
const title = msg.title as string;

// Title must NOT start with the old static "Guardian question:" prefix
expect(title.startsWith('Guardian question:')).toBe(false);

// Title must start with an emoji (code point > 127 or common emoji ranges)
const firstCodePoint = title.codePointAt(0)!;
expect(firstCodePoint).toBeGreaterThan(127);
});

test('broadcast includes questionText field matching the original question', async () => {
const convId = 'conv-dispatch-7';
ensureConversation(convId);

const questionText = 'What is the WiFi password?';
const session = createCallSession({
conversationId: convId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});
const pq = createPendingQuestion(session.id, questionText);

const broadcastedMessages: unknown[] = [];
const broadcastFn = (msg: unknown) => { broadcastedMessages.push(msg); };

await dispatchGuardianQuestion({
callSessionId: session.id,
conversationId: convId,
assistantId: 'self',
pendingQuestion: pq,
broadcast: broadcastFn,
});

expect(broadcastedMessages).toHaveLength(1);
const msg = broadcastedMessages[0] as Record<string, unknown>;
expect(msg.type).toBe('guardian_request_thread_created');
expect(msg.questionText).toBe(questionText);
});

test('initial message in mac conversation contains question text from generative copy', async () => {
const convId = 'conv-dispatch-8';
ensureConversation(convId);

// Set mock copy to a known generative-style message
mockGuardianCopy = {
threadTitle: '\u{1F4DE} Live call: Gate code needed',
initialMessage: 'You have an active phone call that needs your help.\n\nThe caller is asking: What is the gate code?\n\nPlease reply with your answer to resume the call.',
};

const session = createCallSession({
conversationId: convId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});
const pq = createPendingQuestion(session.id, 'What is the gate code?');

const broadcastedMessages: unknown[] = [];
const broadcastFn = (msg: unknown) => { broadcastedMessages.push(msg); };

await dispatchGuardianQuestion({
callSessionId: session.id,
conversationId: convId,
assistantId: 'self',
pendingQuestion: pq,
broadcast: broadcastFn,
});

const msg = broadcastedMessages[0] as Record<string, unknown>;
const macConvId = msg.conversationId as string;

const messages = getMessages(macConvId);
expect(messages.length).toBeGreaterThanOrEqual(1);
const content = messages[0].content;
// The generative copy should be used as the initial message
expect(content).toContain('What is the gate code?');
expect(content).toContain('active phone call');
});
});
47 changes: 47 additions & 0 deletions assistant/src/__tests__/guardian-question-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, test, expect } from 'bun:test';
import { buildFallbackCopy } from '../calls/guardian-question-copy.js';

describe('buildFallbackCopy', () => {
test('threadTitle starts with warning emoji', () => {
const result = buildFallbackCopy('What is the gate code?');
expect(result.threadTitle.startsWith('\u26A0\uFE0F')).toBe(true);
});

test('threadTitle does not start with "Guardian question:"', () => {
const result = buildFallbackCopy('What is the gate code?');
expect(result.threadTitle.startsWith('Guardian question:')).toBe(false);
});

test('threadTitle is under 80 characters for reasonable input', () => {
const result = buildFallbackCopy('What is the gate code?');
expect(result.threadTitle.length).toBeLessThan(80);
});

test('initialMessage contains the question text', () => {
const question = 'Should I let the delivery driver in?';
const result = buildFallbackCopy(question);
expect(result.initialMessage).toContain(question);
});

test('initialMessage contains "Reply to this message" instruction', () => {
const result = buildFallbackCopy('Any question here');
expect(result.initialMessage).toContain('Reply to this message');
});

test('very long question text gets truncated in title', () => {
const longQuestion = 'A'.repeat(200);
const result = buildFallbackCopy(longQuestion);

// Title should use questionText.slice(0, 70), so the question portion is at most 70 chars
// Plus the emoji prefix and space, should still be well under 80
expect(result.threadTitle.length).toBeLessThanOrEqual(
'\u26A0\uFE0F '.length + 70,
);

// The full question should NOT appear in the title
expect(result.threadTitle).not.toContain(longQuestion);

// But the full question should still appear in the initial message
expect(result.initialMessage).toContain(longQuestion);
});
});
1 change: 1 addition & 0 deletions assistant/src/__tests__/ipc-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,7 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
requestId: 'req-guardian-001',
callSessionId: 'call-001',
title: 'Guardian action request',
questionText: 'What is the gate code?',
},
subagent_spawned: {
type: 'subagent_spawned',
Expand Down
20 changes: 17 additions & 3 deletions assistant/src/calls/guardian-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { addMessage } from '../memory/conversation-store.js';
import type { CallPendingQuestion } from './types.js';
import { readHttpToken } from '../util/platform.js';
import type { ServerMessage } from '../daemon/ipc-contract.js';
import { generateGuardianCopy } from './guardian-question-copy.js';

const log = getLogger('guardian-dispatch');

Expand Down Expand Up @@ -104,10 +105,19 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
// Mac (internal) delivery — always created
destinations.push({ channel: 'macos' });

// Start LLM copy generation concurrently — only awaited in the macOS branch
// so external channels (Telegram, SMS) dispatch without LLM latency.
const guardianCopyPromise = generateGuardianCopy(
pendingQuestion.questionText,
request.requestCode,
);

// Create delivery rows and dispatch
for (const dest of destinations) {
if (dest.channel === 'macos') {
// Create a dedicated server-side conversation for the mac guardian thread
// Create conversation and delivery row synchronously so they exist
// before awaiting LLM copy — prevents a race where an external channel
// reply resolves the request before the macOS delivery is created.
const macConvKey = `asst:${assistantId}:guardian:request:${request.id}`;
const { conversationId: macConversationId } = getOrCreateConversation(macConvKey);

Expand All @@ -117,11 +127,14 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
destinationConversationId: macConversationId,
});

// Now await LLM-generated copy for the message content and thread title
const guardianCopy = await guardianCopyPromise;

// Add the guardian question as the initial message in the thread
addMessage(
macConversationId,
'assistant',
JSON.stringify([{ type: 'text', text: `Your assistant needs your input during a phone call.\n\nQuestion: ${request.questionText}\n\nReply to this message with your answer.` }]),
JSON.stringify([{ type: 'text', text: guardianCopy.initialMessage }]),
{ userMessageChannel: 'voice', assistantMessageChannel: 'macos' },
);

Expand All @@ -132,7 +145,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
conversationId: macConversationId,
requestId: request.id,
callSessionId,
title: `Guardian question: ${pendingQuestion.questionText.slice(0, 80)}`,
title: guardianCopy.threadTitle,
questionText: request.questionText,
} as ServerMessage);
}
updateDeliveryStatus(delivery.id, 'sent');
Expand Down
Loading
Loading