264 lines
8.1 KiB
TypeScript
264 lines
8.1 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { IdentityDB } from 'identitydb';
|
|
import { initializePersona, type PersonaBiographyResult, type PersonaFactExtractionResult } from '../src';
|
|
import type { ImageModelAdapter, StructuredModelAdapter } from '../src';
|
|
|
|
const openDbs: IdentityDB[] = [];
|
|
|
|
async function createDb() {
|
|
const db = await IdentityDB.connect({ client: 'sqlite', filename: ':memory:' });
|
|
await db.initialize();
|
|
openDbs.push(db);
|
|
return db;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
while (openDbs.length > 0) {
|
|
await openDbs.pop()!.close();
|
|
}
|
|
});
|
|
|
|
describe('initializePersona', () => {
|
|
it('generates a biography, extracts facts, and stores them in the persona space', async () => {
|
|
const db = await createDb();
|
|
const prompts: string[] = [];
|
|
const structured = createStructuredAdapter(prompts);
|
|
|
|
const persona = await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet, playful, observant.',
|
|
history: 'Grew up near Busan and studied design.',
|
|
values: ['loyalty', 'creative honesty'],
|
|
likes: ['quiet cafés'],
|
|
dislikes: ['loud arguments'],
|
|
relationships: [{ name: 'Haru', relationship: 'best friend', description: 'childhood friend' }],
|
|
currentDate: '2026-05-11',
|
|
structuredModel: structured,
|
|
});
|
|
|
|
expect(persona).toMatchObject({
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
spaceName: 'persona:minji',
|
|
biography: 'Minji grew up near the sea and values loyal friendships.',
|
|
});
|
|
expect(prompts[0]).toContain('Quiet, playful, observant.');
|
|
expect(prompts[0]).toContain('creative honesty');
|
|
expect(prompts[0]).toContain('Haru');
|
|
expect(prompts[0]).toContain('Current date: 2026-05-11');
|
|
expect(prompts[1]).toContain('Minji grew up near the sea');
|
|
|
|
const facts = await db.getTopicFacts('Minji', { spaceName: persona.spaceName });
|
|
expect(facts.map((fact) => fact.statement)).toContain('Minji grew up near the sea.');
|
|
});
|
|
|
|
it('does not call the image adapter unless a profile image is requested', async () => {
|
|
const db = await createDb();
|
|
const structured = createStructuredAdapter([]);
|
|
let imageCalls = 0;
|
|
const imageModel: ImageModelAdapter = {
|
|
provider: 'fake-image',
|
|
model: 'fake-image-model',
|
|
async generateImage() {
|
|
imageCalls += 1;
|
|
return { url: 'https://example.test/minji.png' };
|
|
},
|
|
};
|
|
|
|
const persona = await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: structured,
|
|
imageModel,
|
|
});
|
|
|
|
expect(persona.profileImageUrl).toBeUndefined();
|
|
expect(imageCalls).toBe(0);
|
|
});
|
|
|
|
it('can generate and store a persona profile image when requested', async () => {
|
|
const db = await createDb();
|
|
const structured = createStructuredAdapter([]);
|
|
let imagePrompt = '';
|
|
const imageModel: ImageModelAdapter = {
|
|
provider: 'fake-image',
|
|
model: 'fake-image-model',
|
|
async generateImage(request) {
|
|
imagePrompt = request.prompt;
|
|
return { url: 'https://example.test/minji.png' };
|
|
},
|
|
};
|
|
|
|
const persona = await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: structured,
|
|
imageModel,
|
|
generateProfileImage: true,
|
|
});
|
|
|
|
expect(imagePrompt).toContain('Minji');
|
|
expect(persona.profileImageUrl).toBe('https://example.test/minji.png');
|
|
|
|
const facts = await db.getTopicFacts('profile image', { spaceName: persona.spaceName });
|
|
expect(facts.map((fact) => fact.statement)).toContain('Minji has a generated profile image at https://example.test/minji.png.');
|
|
});
|
|
|
|
it('generates unique default persona spaces when no id is supplied', async () => {
|
|
const db = await createDb();
|
|
|
|
const first = await initializePersona(db, {
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
});
|
|
const second = await initializePersona(db, {
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
});
|
|
|
|
expect(first.id).not.toBe(second.id);
|
|
expect(first.spaceName).not.toBe(second.spaceName);
|
|
});
|
|
|
|
it('rejects accidental reuse of an existing persona space by default', async () => {
|
|
const db = await createDb();
|
|
await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
});
|
|
|
|
await expect(
|
|
initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji Again',
|
|
personality: 'Different.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
}),
|
|
).rejects.toThrow(/already exists/);
|
|
});
|
|
|
|
it('allows explicit reuse of an existing persona space', async () => {
|
|
const db = await createDb();
|
|
await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
});
|
|
|
|
const persona = await initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
reuseExistingSpace: true,
|
|
});
|
|
|
|
expect(persona.spaceName).toBe('persona:minji');
|
|
});
|
|
|
|
it('rejects malformed structured biography output', async () => {
|
|
const db = await createDb();
|
|
const malformed: StructuredModelAdapter = {
|
|
provider: 'fake-structured',
|
|
model: 'fake-structured-model',
|
|
async generateObject<TOutput>(): Promise<TOutput> {
|
|
return { biography: '' } as TOutput;
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
initializePersona(db, {
|
|
id: 'broken',
|
|
displayName: 'Broken',
|
|
personality: 'Quiet.',
|
|
structuredModel: malformed,
|
|
}),
|
|
).rejects.toThrow(/biography/);
|
|
expect(await db.getSpaceByName('persona:broken')).toBeNull();
|
|
});
|
|
|
|
it('rejects malformed extracted fact topics before creating a space', async () => {
|
|
const db = await createDb();
|
|
const malformedFacts: StructuredModelAdapter = {
|
|
provider: 'fake-structured',
|
|
model: 'fake-structured-model',
|
|
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
|
if (request.prompt.includes('Create a concrete')) {
|
|
return { biography: 'Broken has a biography.' } as TOutput;
|
|
}
|
|
|
|
return {
|
|
facts: [
|
|
{
|
|
statement: 'Broken has a malformed topic.',
|
|
topics: [{}],
|
|
},
|
|
],
|
|
} as TOutput;
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
initializePersona(db, {
|
|
id: 'broken-topic',
|
|
displayName: 'Broken Topic',
|
|
personality: 'Quiet.',
|
|
structuredModel: malformedFacts,
|
|
}),
|
|
).rejects.toThrow(/topic name/);
|
|
expect(await db.getSpaceByName('persona:broken-topic')).toBeNull();
|
|
});
|
|
|
|
it('requires an image model when profile image generation is requested', async () => {
|
|
const db = await createDb();
|
|
|
|
await expect(
|
|
initializePersona(db, {
|
|
id: 'minji',
|
|
displayName: 'Minji',
|
|
personality: 'Quiet.',
|
|
structuredModel: createStructuredAdapter([]),
|
|
generateProfileImage: true,
|
|
}),
|
|
).rejects.toThrow(/imageModel/);
|
|
});
|
|
});
|
|
|
|
function createStructuredAdapter(prompts: string[]): StructuredModelAdapter {
|
|
let call = 0;
|
|
return {
|
|
provider: 'fake-structured',
|
|
model: 'fake-structured-model',
|
|
async generateObject<TOutput>(request: { prompt: string }): Promise<TOutput> {
|
|
prompts.push(request.prompt);
|
|
call += 1;
|
|
if (call === 1) {
|
|
return {
|
|
biography: 'Minji grew up near the sea and values loyal friendships.',
|
|
} satisfies PersonaBiographyResult as TOutput;
|
|
}
|
|
|
|
return {
|
|
facts: [
|
|
{
|
|
statement: 'Minji grew up near the sea.',
|
|
topics: [{ name: 'Minji' }, { name: 'sea' }],
|
|
},
|
|
{
|
|
statement: 'Minji values loyal friendships.',
|
|
topics: [{ name: 'Minji' }, { name: 'loyal friendships' }],
|
|
},
|
|
],
|
|
} satisfies PersonaFactExtractionResult as TOutput;
|
|
},
|
|
};
|
|
}
|