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>
395 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|