fleet-memory/hindsight-integrations/chat/src/wrapper.test.ts
Ben fed987f931
feat: add Chat SDK integration for persistent chat bot memory (#442)
Adds @vectorize-io/hindsight-chat, a wrapper for the Vercel Chat SDK
that gives any chat bot (Slack, Discord, Teams, etc.) long-term memory
via Hindsight. Includes withHindsightChat() handler wrapper with
auto-recall, auto-retain, and memoriesAsSystemPrompt() formatting.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:07:47 +01:00

395 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { withHindsightChat } from './wrapper.js';
import type {
HindsightClient,
ChatThread,
ChatMessage,
HindsightChatContext,
} from './types.js';
// --- Mocks ---
function mockClient(overrides?: Partial<HindsightClient>): HindsightClient {
return {
retain: vi.fn().mockResolvedValue({
success: true,
bank_id: 'test-bank',
items_count: 1,
async: false,
}),
recall: vi.fn().mockResolvedValue({
results: [
{ id: 'mem-1', text: 'User likes TypeScript', type: 'experience' },
],
entities: {
'ent-1': {
entity_id: 'ent-1',
canonical_name: 'User',
observations: [{ text: 'Prefers dark mode' }],
},
},
}),
reflect: vi.fn().mockResolvedValue({
text: 'User is a TypeScript developer',
based_on: [],
}),
...overrides,
};
}
function mockThread(): ChatThread {
return {
post: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
unsubscribe: vi.fn().mockResolvedValue(undefined),
isSubscribed: vi.fn().mockResolvedValue(false),
startTyping: vi.fn().mockResolvedValue(undefined),
state: Promise.resolve(null),
setState: vi.fn().mockResolvedValue(undefined),
};
}
function mockMessage(overrides?: Partial<ChatMessage>): ChatMessage {
return {
author: { userId: 'user-123', name: 'Alice', isMe: false },
text: 'What do you know about me?',
threadId: 'thread-1',
...overrides,
};
}
describe('withHindsightChat', () => {
let client: HindsightClient;
let thread: ChatThread;
let message: ChatMessage;
beforeEach(() => {
client = mockClient();
thread = mockThread();
message = mockMessage();
});
describe('bankId resolution', () => {
it('uses static bankId', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'static-bank' },
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'static-bank',
message.text,
expect.any(Object)
);
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'static-bank' })
);
});
it('uses dynamic bankId from message', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: (msg) => `bank-${msg.author.userId}` },
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith(
'bank-user-123',
message.text,
expect.any(Object)
);
expect(handler).toHaveBeenCalledWith(
thread,
message,
expect.objectContaining({ bankId: 'bank-user-123' })
);
});
});
describe('auto-recall', () => {
it('recalls by default', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledOnce();
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'mid',
maxTokens: undefined,
types: undefined,
includeEntities: true,
});
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toHaveLength(1);
expect(ctx.memories[0].text).toBe('User likes TypeScript');
expect(ctx.entities).not.toBeNull();
});
it('can be disabled', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', recall: { enabled: false } },
handler
);
await wrapped(thread, message);
expect(client.recall).not.toHaveBeenCalled();
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toEqual([]);
expect(ctx.entities).toBeNull();
});
it('passes recall options through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
recall: {
budget: 'high',
maxTokens: 500,
types: ['experience'],
includeEntities: false,
},
},
handler
);
await wrapped(thread, message);
expect(client.recall).toHaveBeenCalledWith('bank', message.text, {
budget: 'high',
maxTokens: 500,
types: ['experience'],
includeEntities: false,
});
});
it('skips recall for empty message text', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, mockMessage({ text: '' }));
expect(client.recall).not.toHaveBeenCalled();
});
it('handles recall errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
client = mockClient({
recall: vi.fn().mockRejectedValue(new Error('Network error')),
});
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-recall failed:',
expect.any(Error)
);
// Handler still runs with empty memories
const ctx: HindsightChatContext = handler.mock.calls[0][2];
expect(ctx.memories).toEqual([]);
warnSpy.mockRestore();
});
});
describe('auto-retain', () => {
it('does not retain by default', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(client.retain).not.toHaveBeenCalled();
});
it('retains when enabled', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
tags: undefined,
metadata: undefined,
async: true,
});
});
it('passes retain options through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{
client,
bankId: 'bank',
retain: {
enabled: true,
tags: ['slack'],
metadata: { source: 'chat' },
async: false,
},
},
handler
);
await wrapped(thread, message);
expect(client.retain).toHaveBeenCalledWith('bank', message.text, {
tags: ['slack'],
metadata: { source: 'chat' },
async: false,
});
});
it('skips retain for bot messages (isMe)', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(
thread,
mockMessage({ author: { userId: 'bot', isMe: true } })
);
expect(client.retain).not.toHaveBeenCalled();
});
it('skips retain for empty text', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
await wrapped(thread, mockMessage({ text: '' }));
expect(client.retain).not.toHaveBeenCalled();
});
it('handles retain errors gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
client = mockClient({
retain: vi.fn().mockRejectedValue(new Error('Retain failed')),
});
const handler = vi.fn();
const wrapped = withHindsightChat(
{ client, bankId: 'bank', retain: { enabled: true } },
handler
);
// Should not throw
await wrapped(thread, message);
expect(warnSpy).toHaveBeenCalledWith(
'[hindsight-chat] Auto-retain failed:',
expect.any(Error)
);
// Handler still runs
expect(handler).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('context methods', () => {
it('memoriesAsSystemPrompt() formats recalled memories', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
const prompt = ctx.memoriesAsSystemPrompt();
expect(prompt).toContain('<memories>');
expect(prompt).toContain('User likes TypeScript');
expect(prompt).toContain('<entity_observations>');
});
it('ctx.retain() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.retain('New memory content', { tags: ['test'] });
expect(client.retain).toHaveBeenCalledWith('bank', 'New memory content', {
tags: ['test'],
});
});
it('ctx.recall() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.recall('search query', { budget: 'high' });
// Second call (first was auto-recall)
expect(client.recall).toHaveBeenCalledTimes(2);
expect(client.recall).toHaveBeenLastCalledWith('bank', 'search query', {
budget: 'high',
});
});
it('ctx.reflect() delegates to client', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
const ctx: HindsightChatContext = handler.mock.calls[0][2];
await ctx.reflect('What does the user prefer?');
expect(client.reflect).toHaveBeenCalledWith(
'bank',
'What does the user prefer?',
undefined
);
});
});
describe('handler invocation', () => {
it('passes thread and message through', async () => {
const handler = vi.fn();
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(handler).toHaveBeenCalledWith(thread, message, expect.any(Object));
});
it('awaits async handlers', async () => {
let completed = false;
const handler = async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
completed = true;
};
const wrapped = withHindsightChat({ client, bankId: 'bank' }, handler);
await wrapped(thread, message);
expect(completed).toBe(true);
});
});
});