Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
84 changes: 38 additions & 46 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

517 changes: 0 additions & 517 deletions assistant/src/__tests__/call-bridge.test.ts

This file was deleted.

130 changes: 130 additions & 0 deletions assistant/src/__tests__/call-conversation-messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const testDir = mkdtempSync(join(tmpdir(), 'call-conversation-messages-test-'));

mock.module('../util/platform.js', () => ({
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: () => () => {},
}),
}));

import { initializeDb, getDb, resetDb } from '../memory/db.js';
import { conversations } from '../memory/schema.js';
import { createCallSession, updateCallSession, recordCallEvent } from '../calls/call-store.js';
import { getMessages } from '../memory/conversation-store.js';
import { buildCallCompletionMessage, persistCallCompletionMessage } from '../calls/call-conversation-messages.js';

initializeDb();

function ensureConversation(id: string): void {
const db = getDb();
const now = Date.now();
db.insert(conversations).values({
id,
title: `Conversation ${id}`,
createdAt: now,
updatedAt: now,
}).run();
}

function resetTables(): void {
const db = getDb();
db.run('DELETE FROM call_events');
db.run('DELETE FROM call_pending_questions');
db.run('DELETE FROM call_sessions');
db.run('DELETE FROM messages');
db.run('DELETE FROM conversations');
}

function getLatestAssistantText(conversationId: string): string {
const rows = getMessages(conversationId).filter((m) => m.role === 'assistant');
expect(rows.length).toBeGreaterThan(0);
const latest = rows[rows.length - 1];
const parsed = JSON.parse(latest.content) as Array<{ type: string; text?: string }>;
return parsed.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
}

describe('call-conversation-messages', () => {
beforeEach(() => {
resetTables();
});

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

test('buildCallCompletionMessage labels failed calls correctly', () => {
const conversationId = 'conv-call-msg-failed';
ensureConversation(conversationId);
const session = createCallSession({
conversationId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});

updateCallSession(session.id, { status: 'in_progress', startedAt: 1_000 });
updateCallSession(session.id, { status: 'failed', endedAt: 6_000 });
recordCallEvent(session.id, 'call_connected');
recordCallEvent(session.id, 'call_failed');

expect(buildCallCompletionMessage(session.id)).toBe('**Call failed** (5s). 2 event(s) recorded.');
});

test('buildCallCompletionMessage labels cancelled calls correctly', () => {
const conversationId = 'conv-call-msg-cancelled';
ensureConversation(conversationId);
const session = createCallSession({
conversationId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});

updateCallSession(session.id, { status: 'in_progress', startedAt: 1_000 });
updateCallSession(session.id, { status: 'cancelled', endedAt: 4_000 });
recordCallEvent(session.id, 'call_connected');
recordCallEvent(session.id, 'call_ended');

expect(buildCallCompletionMessage(session.id)).toBe('**Call cancelled** (3s). 2 event(s) recorded.');
});

test('persistCallCompletionMessage keeps completed label when status is completed', () => {
const conversationId = 'conv-call-msg-completed';
ensureConversation(conversationId);
const session = createCallSession({
conversationId,
provider: 'twilio',
fromNumber: '+15550001111',
toNumber: '+15550002222',
});

updateCallSession(session.id, { status: 'completed' });
recordCallEvent(session.id, 'call_ended');

const summary = persistCallCompletionMessage(conversationId, session.id);
expect(summary).toBe('**Call completed**. 1 event(s) recorded.');
expect(getLatestAssistantText(conversationId)).toBe('**Call completed**. 1 event(s) recorded.');
});
});
74 changes: 64 additions & 10 deletions assistant/src/__tests__/call-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,11 @@ describe('call-orchestrator', () => {
orchestrator.destroy();
});

// ── ASK_USER pattern ──────────────────────────────────────────────
// ── ASK_GUARDIAN pattern ──────────────────────────────────────────

test('ASK_USER pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
test('ASK_GUARDIAN pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
mockStreamFn.mockImplementation(() =>
createMockStream(['Let me check on that. ', '[ASK_USER: What date works best?]']),
createMockStream(['Let me check on that. ', '[ASK_GUARDIAN: What date works best?]']),
);
const { session, relay, orchestrator } = setupOrchestrator('Book appointment');

Expand All @@ -342,9 +342,9 @@ describe('call-orchestrator', () => {
const updatedSession = getCallSession(session.id);
expect(updatedSession!.status).toBe('waiting_on_user');

// The ASK_USER marker text should NOT appear in the relay tokens
// The ASK_GUARDIAN marker text should NOT appear in the relay tokens
const allText = relay.sentTokens.map((t) => t.token).join('');
expect(allText).not.toContain('[ASK_USER:');
expect(allText).not.toContain('[ASK_GUARDIAN:');

orchestrator.destroy();
});
Expand Down Expand Up @@ -404,9 +404,9 @@ describe('call-orchestrator', () => {
// ── handleUserAnswer ──────────────────────────────────────────────

test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
// First utterance triggers ASK_USER
// First utterance triggers ASK_GUARDIAN
mockStreamFn.mockImplementation(() =>
createMockStream(['Hold on. [ASK_USER: Preferred time?]']),
createMockStream(['Hold on. [ASK_GUARDIAN: Preferred time?]']),
);
const { relay, orchestrator } = setupOrchestrator();

Expand Down Expand Up @@ -440,7 +440,7 @@ describe('call-orchestrator', () => {
test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
// Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
mockStreamFn.mockImplementation(() =>
createMockStream(['I\'m sorry, 7:30 is not available. ', '[ASK_USER: Is 8:00 okay instead?]']),
createMockStream(['I\'m sorry, 7:30 is not available. ', '[ASK_GUARDIAN: Is 8:00 okay instead?]']),
);

const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
Expand Down Expand Up @@ -572,6 +572,60 @@ describe('call-orchestrator', () => {
orchestrator.destroy();
});

test('barge-in cleanup never sends empty user turns to Anthropic', async () => {
let callCount = 0;
mockStreamFn.mockImplementation((...args: unknown[]) => {
callCount++;

// Initial outbound opener
if (callCount === 1) {
return createMockStream(['Hey Noa, this is Credence calling.']);
}

// First caller turn enters an in-flight LLM run that gets interrupted
if (callCount === 2) {
const emitter = new EventEmitter();
const options = args[1] as { signal?: AbortSignal } | undefined;
return {
on: (event: string, handler: (...evtArgs: unknown[]) => void) => {
emitter.on(event, handler);
return { on: () => ({ on: () => ({}) }) };
},
finalMessage: () =>
new Promise((_, reject) => {
options?.signal?.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
}, { once: true });
}),
};
}

// Second caller turn should never include an empty user message.
const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
const userMessages = firstArg.messages.filter((m) => m.role === 'user');
expect(userMessages.length).toBeGreaterThan(0);
expect(userMessages.every((m) => m.content.trim().length > 0)).toBe(true);
return createMockStream(['Got it, thanks for clarifying.']);
});

const { relay, orchestrator } = setupOrchestrator('Quick check-in');
await orchestrator.startInitialGreeting();

const firstTurnPromise = orchestrator.handleCallerUtterance('Hello?');
await new Promise((r) => setTimeout(r, 5));
const secondTurnPromise = orchestrator.handleCallerUtterance('What have you been up to lately?');

await Promise.all([firstTurnPromise, secondTurnPromise]);

const allTokens = relay.sentTokens.map((t) => t.token).join('');
expect(allTokens).toContain('Got it, thanks for clarifying.');
expect(allTokens).not.toContain('technical issue');

orchestrator.destroy();
});

test('rapid caller barge-in coalesces contiguous user turns for role alternation', async () => {
let callCount = 0;
mockStreamFn.mockImplementation((...args: unknown[]) => {
Expand Down Expand Up @@ -930,9 +984,9 @@ describe('call-orchestrator', () => {
});

test('handleUserInstruction: does not trigger LLM when orchestrator is not idle', async () => {
// First, trigger ASK_USER so orchestrator enters waiting_on_user
// First, trigger ASK_GUARDIAN so orchestrator enters waiting_on_user
mockStreamFn.mockImplementation(() =>
createMockStream(['Hold on. [ASK_USER: What time?]']),
createMockStream(['Hold on. [ASK_GUARDIAN: What time?]']),
);

const { session, orchestrator } = setupOrchestrator();
Expand Down
123 changes: 123 additions & 0 deletions assistant/src/__tests__/guardian-action-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-store-test-'));

mock.module('../util/platform.js', () => ({
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: () => () => {},
}),
}));

import { initializeDb, getDb, resetDb } from '../memory/db.js';
import { conversations } from '../memory/schema.js';
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
import {
createGuardianActionRequest,
createGuardianActionDelivery,
updateDeliveryStatus,
cancelGuardianActionRequest,
getGuardianActionRequest,
getDeliveriesByRequestId,
} from '../memory/guardian-action-store.js';

initializeDb();

function ensureConversation(id: string): void {
const db = getDb();
const now = Date.now();
db.insert(conversations).values({
id,
title: `Conversation ${id}`,
createdAt: now,
updatedAt: now,
}).run();
}

function resetTables(): void {
const db = getDb();
db.run('DELETE FROM guardian_action_deliveries');
db.run('DELETE FROM guardian_action_requests');
db.run('DELETE FROM call_pending_questions');
db.run('DELETE FROM call_events');
db.run('DELETE FROM call_sessions');
db.run('DELETE FROM conversations');
}

describe('guardian-action-store', () => {
beforeEach(() => {
resetTables();
});

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

test('cancelGuardianActionRequest cancels both pending and sent deliveries', () => {
const conversationId = 'conv-guardian-cancel';
ensureConversation(conversationId);

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

const request = createGuardianActionRequest({
kind: 'ask_guardian',
sourceChannel: 'voice',
sourceConversationId: conversationId,
callSessionId: session.id,
pendingQuestionId: pendingQuestion.id,
questionText: pendingQuestion.questionText,
expiresAt: Date.now() + 60_000,
});

const pendingDelivery = createGuardianActionDelivery({
requestId: request.id,
destinationChannel: 'mac',
destinationConversationId: 'conv-mac-guardian',
});
const sentDelivery = createGuardianActionDelivery({
requestId: request.id,
destinationChannel: 'telegram',
destinationChatId: 'chat-guardian',
destinationExternalUserId: 'guardian-user',
});
updateDeliveryStatus(sentDelivery.id, 'sent');

cancelGuardianActionRequest(request.id);

const updatedRequest = getGuardianActionRequest(request.id);
expect(updatedRequest).not.toBeNull();
expect(updatedRequest!.status).toBe('cancelled');

const deliveries = getDeliveriesByRequestId(request.id);
const pendingAfter = deliveries.find((d) => d.id === pendingDelivery.id);
const sentAfter = deliveries.find((d) => d.id === sentDelivery.id);
expect(pendingAfter?.status).toBe('cancelled');
expect(sentAfter?.status).toBe('cancelled');
});
});
Loading
Loading