import { afterEach, describe, expect, it } from 'vitest'; import { generateSchedule, getAvailabilitySnapshot, listConversationEntries, replyToConversation, setAvailabilityStatus, startConversation, type ConversationMemorySelectionResult, type ConversationTurnPlan, type ScheduleGenerationResult, type StructuredModelAdapter, } from '../src'; import { persistFactDrafts } from '../src/memory'; import { closeOpenDbs, createDb } from './helpers'; afterEach(closeOpenDbs); describe('conversation APIs', () => { it('blocks replies while the persona is offline', async () => { const db = await createDb(); await db.upsertSpace({ name: 'persona:minji', metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } }, }); await setAvailabilityStatus(db, { spaceName: 'persona:minji', mode: 'offline', reason: 'Sleeping', effectiveFrom: '2026-05-12T00:00:00.000Z', until: '2026-05-12T08:00:00.000Z', sourceType: 'manual', }); let responseCalls = 0; const result = await replyToConversation(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo', counterpartDisplayName: 'Shinwoo', message: '지금뭐해', currentTime: '2026-05-12T07:00:00.000Z', mandatoryMemoryModel: createSelectionModel([]), contextualMemoryModel: createSelectionModel([]), responseModel: { provider: 'fake-structured', model: 'response-model', async generateObject(): Promise { responseCalls += 1; return { mode: 'reply', messages: ['안자'] } as TOutput; }, }, }); expect(result.blocked).toBe(true); expect(result.messages).toEqual([]); expect(responseCalls).toBe(0); }); it('retrieves mandatory/contextual memories, generates DM-style replies, and stores the turn', async () => { const db = await createDb(); await seedPersonaMemory(db); const mandatoryPrompts: string[] = []; const contextualPrompts: string[] = []; const responsePrompts: string[] = []; const result = await replyToConversation(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo', counterpartDisplayName: 'Shinwoo', message: '오늘저녁뭐해?', currentTime: '2026-05-12T12:00:00.000Z', mandatoryMemoryModel: createSelectionModel(['m1', 'm2'], mandatoryPrompts), contextualMemoryModel: createSelectionModel(['m3'], contextualPrompts), responseModel: createResponseModel( { mode: 'reply', messages: ['저녁엔가족이랑먹어', '왜궁금해'], }, responsePrompts, ), rng: () => 0, }); expect(mandatoryPrompts[0]).toContain('yesterday, today, and tomorrow schedules'); expect(contextualPrompts[0]).toContain('오늘저녁뭐해?'); expect(responsePrompts[0]).toContain('family dinner'); expect(result.messages).toHaveLength(2); expect(result.messages[0]?.replyDelaySeconds).toBe(1); expect(result.messages[0]?.typingDelaySeconds).toBeGreaterThan(0); expect(result.messages[1]?.replyDelaySeconds).toBe(0); expect(result.usedMemories).toHaveLength(3); const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' }); expect(history.map((entry) => `${entry.direction}:${entry.text}`)).toEqual([ 'inbound:오늘저녁뭐해?', 'outbound:저녁엔가족이랑먹어', 'outbound:왜궁금해', ]); }); it('can proactively start a conversation without inbound user text', async () => { const db = await createDb(); await seedPersonaMemory(db); const result = await startConversation(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo', counterpartDisplayName: 'Shinwoo', currentTime: '2026-05-12T15:00:00.000Z', mandatoryMemoryModel: createSelectionModel(['m1']), contextualMemoryModel: createSelectionModel([]), responseModel: createResponseModel({ mode: 'reply', messages: ['지금뭐해'] }), rng: () => 0, }); expect(result.messages.map((message) => message.text)).toEqual(['지금뭐해']); const history = await listConversationEntries(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo' }); expect(history[0]?.proactive).toBe(true); }); it('executes availability tool calls after farewell-style refusal messages', async () => { const db = await createDb(); await seedPersonaMemory(db); await generateSchedule(db, { spaceName: 'persona:minji', displayName: 'Minji', currentDate: '2026-05-12', scope: 'day', structuredModel: { provider: 'fake-structured', model: 'schedule-model', async generateObject(): Promise { return { events: [ { title: 'Exam', startAt: '2026-05-12T10:00:00.000Z', endAt: '2026-05-12T12:00:00.000Z', availabilityMode: 'offline', availabilityReason: 'Exam starting', kind: 'special', }, ], } satisfies ScheduleGenerationResult as TOutput; }, }, }); const prompts: string[] = []; const result = await startConversation(db, { spaceName: 'persona:minji', counterpartId: 'user:shinwoo', counterpartDisplayName: 'Shinwoo', currentTime: '2026-05-12T09:55:00.000Z', mandatoryMemoryModel: createSelectionModel(['m1']), contextualMemoryModel: createSelectionModel([]), responseModel: createResponseModel( { mode: 'refuse', messages: ['나이제가봐야해', '이따연락해'], toolCalls: [ { name: 'setAvailabilityStatus', arguments: { mode: 'offline', reason: 'Exam starting', effectiveFrom: '2026-05-12T09:58:00.000Z', until: '2026-05-12T12:00:00.000Z', }, }, ], }, prompts, ), }); expect(prompts[0]).toContain('next availability transition'); expect(result.messages.map((message) => message.text)).toEqual(['나이제가봐야해', '이따연락해']); const snapshot = await getAvailabilitySnapshot(db, { spaceName: 'persona:minji', at: '2026-05-12T10:00:00.000Z', }); expect(snapshot.current.mode).toBe('offline'); expect(snapshot.current.reason).toBe('Exam starting'); }); }); function createSelectionModel(memoryIds: string[], prompts: string[] = []): StructuredModelAdapter { return { provider: 'fake-structured', model: 'selection-model', async generateObject(request: { prompt: string }): Promise { prompts.push(request.prompt); return { memoryIds } satisfies ConversationMemorySelectionResult as TOutput; }, }; } function createResponseModel(plan: ConversationTurnPlan, prompts: string[] = []): StructuredModelAdapter { return { provider: 'fake-structured', model: 'response-model', async generateObject(request: { prompt: string }): Promise { prompts.push(request.prompt); return plan as TOutput; }, }; } async function seedPersonaMemory(db: Awaited>) { await db.upsertSpace({ name: 'persona:minji', metadata: { boxbrain: { domain: 'persona.space', personaId: 'minji', displayName: 'Minji' } }, }); await persistFactDrafts(db, { spaceName: 'persona:minji', domain: 'persona.biography', source: 'boxbrain:test', facts: [ { statement: 'Minji prefers quiet evenings and close family dinners.', topics: [{ name: 'Minji' }, { name: 'family dinner' }], }, { statement: 'Minji knows Shinwoo as a close contact she messages casually.', topics: [{ name: 'Minji' }, { name: 'Shinwoo' }], }, ], }); await generateSchedule(db, { spaceName: 'persona:minji', displayName: 'Minji', currentDate: '2026-05-12', scope: 'day', structuredModel: { provider: 'fake-structured', model: 'schedule-model', async generateObject(): Promise { return { events: [ { title: 'Family dinner', description: 'Dinner at home with family.', startAt: '2026-05-12T18:00:00.000Z', endAt: '2026-05-12T20:00:00.000Z', availabilityMode: 'offline', availabilityReason: 'family dinner', kind: 'special', }, ], } satisfies ScheduleGenerationResult as TOutput; }, }, }); }