fleet-memory/hindsight-integrations/openclaw/tests/integration.test.ts
Nicolò Boschi 6c695eb9f8
fix: improve openclaw test coverage (#396)
* fix: improve openclaw test coverage

* test(openclaw): export stripMemoryTags/extractRecallQuery and add hook integration tests

- Extract stripMemoryTags and extractRecallQuery as exported pure functions
  from index.ts so hooks share one implementation and tests cover the real code
- Update before_agent_start to call extractRecallQuery; update agent_end to
  call stripMemoryTags instead of duplicating the regex inline
- Rewrite index.test.ts to import the real functions (no more local duplicate)
  and add 11 tests for extractRecallQuery covering all envelope-stripping cases
- Add tests/hooks.integration.test.ts: loads the plugin via mock MoltbotPluginAPI
  in HTTP mode, spies on client.recall/retain, and exercises all hook behaviours:
  excluded providers, short messages, memory injection format, tag stripping,
  transcript formatting, array content blocks, metadata, document_id derivation
2026-02-18 14:10:33 +01:00

385 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration tests for the Hindsight OpenClaw integration.
*
* Tests both HTTP mode (direct API calls) and Embed mode (subprocess/daemon).
*
* Requirements:
* HTTP mode: Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
* Embed mode: hindsight-embed package at HINDSIGHT_EMBED_PACKAGE_PATH
* + LLM credentials (HINDSIGHT_API_LLM_PROVIDER / HINDSIGHT_API_LLM_API_KEY)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { HindsightClient } from '../src/client.js';
import { HindsightEmbedManager } from '../src/embed-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ---------------------------------------------------------------------------
// Test configuration (driven by environment variables)
// ---------------------------------------------------------------------------
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const LLM_PROVIDER = process.env.HINDSIGHT_API_LLM_PROVIDER || '';
const LLM_API_KEY = process.env.HINDSIGHT_API_LLM_API_KEY || '';
const LLM_MODEL = process.env.HINDSIGHT_API_LLM_MODEL || '';
// Embed package path defaults to the sibling hindsight-embed directory in the repo
const EMBED_PACKAGE_PATH =
process.env.HINDSIGHT_EMBED_PACKAGE_PATH ||
join(__dirname, '..', '..', '..', 'hindsight-embed');
// Port for the test embed daemon (different from production default 9077 to avoid conflicts)
const EMBED_TEST_PORT = 19077;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomBankId(): string {
return `openclaw_test_${Math.random().toString(36).slice(2, 14)}`;
}
async function waitForApi(url: string, maxMs = 5000): Promise<boolean> {
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1000) });
if (res.ok) return true;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
// ---------------------------------------------------------------------------
// HTTP Mode Tests
// ---------------------------------------------------------------------------
describe('HindsightClient HTTP Mode', () => {
let client: HindsightClient;
beforeAll(async () => {
const reachable = await waitForApi(HINDSIGHT_API_URL);
if (!reachable) {
throw new Error(
`Hindsight API not reachable at ${HINDSIGHT_API_URL}. ` +
'Start the server before running integration tests.',
);
}
client = new HindsightClient({
llmProvider: LLM_PROVIDER || 'openai',
llmApiKey: LLM_API_KEY || 'test-key',
llmModel: LLM_MODEL || undefined,
apiUrl: HINDSIGHT_API_URL,
});
});
it('should retain a conversation', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nMy name is Alice and I love hiking.\n[user:end]\n\n' +
'[role: assistant]\nNice to meet you, Alice!\n[assistant:end]',
document_id: 'http-retain-test-1',
metadata: { channel_type: 'slack', sender_id: 'U001' },
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('http-retain-test-1');
});
it('should retain with auto-generated document id', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI work at TechCorp as a software engineer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
});
it('should recall from an empty bank without error', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should set bank mission without throwing', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// setBankMission on a non-existent bank logs a warning but does not throw
await expect(
client.setBankMission('You are an assistant helping users via Slack.'),
).resolves.not.toThrow();
});
it('should set bank mission after retain creates the bank', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
// Create the bank by retaining something first
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
// Now set the mission bank exists so this should succeed
await expect(
client.setBankMission('You are a helpful AI assistant.'),
).resolves.not.toThrow();
});
it('should retain and then recall relevant memories', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy favorite programming language is Python.\n[user:end]\n\n' +
'[role: assistant]\nPython is a great choice!\n[assistant:end]',
document_id: `session-${Date.now()}`,
});
const response = await client.recall({
query: 'What programming language do I like?',
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should silently truncate recall queries over 800 chars', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const longQuery = 'Tell me about my interests. '.repeat(50); // > 800 chars
const response = await client.recall({ query: longQuery, max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should use custom max_tokens in recall request', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'anything', max_tokens: 256 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
});
it('should map recall results to MemoryResult shape', async () => {
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy reading science fiction books.\n[user:end]\n\n' +
'[role: assistant]\nSounds like a great hobby!\n[assistant:end]',
document_id: 'mapping-test',
});
const response = await client.recall({ query: 'What are my hobbies?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// Embed Mode Tests (subprocess / daemon)
// ---------------------------------------------------------------------------
describe('HindsightClient Embed Mode (Subprocess)', () => {
let client: HindsightClient;
let embedManager: HindsightEmbedManager;
const hasEmbedCredentials = Boolean(LLM_PROVIDER && LLM_API_KEY);
beforeAll(async () => {
if (!hasEmbedCredentials) {
console.warn(
'[Integration] Skipping embed mode tests: ' +
'HINDSIGHT_API_LLM_PROVIDER and HINDSIGHT_API_LLM_API_KEY must both be set.',
);
return;
}
embedManager = new HindsightEmbedManager(
EMBED_TEST_PORT,
LLM_PROVIDER,
LLM_API_KEY,
LLM_MODEL || undefined,
undefined, // no custom base URL
0, // never idle-timeout
'latest',
EMBED_PACKAGE_PATH,
);
await embedManager.start();
client = new HindsightClient({
llmProvider: LLM_PROVIDER,
llmApiKey: LLM_API_KEY,
llmModel: LLM_MODEL || undefined,
embedPackagePath: EMBED_PACKAGE_PATH,
});
}, 120_000); // daemon startup can take up to 2 minutes
afterAll(async () => {
if (embedManager) {
await embedManager.stop();
}
}, 30_000);
it('should retain a conversation via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content:
'[role: user]\nI love hiking in the mountains.\n[user:end]\n\n' +
'[role: assistant]\nSounds adventurous!\n[assistant:end]',
document_id: 'embed-retain-test-1',
});
expect(response).toBeDefined();
expect(response.message).toBeDefined();
expect(response.document_id).toBe('embed-retain-test-1');
}, 60_000);
it('should retain with auto-generated document id via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.retain({
content: '[role: user]\nI am a TypeScript developer.\n[user:end]',
});
expect(response).toBeDefined();
expect(response.document_id).toBe('conversation');
}, 60_000);
it('should recall from an empty bank without error via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
const response = await client.recall({ query: 'What do I like?', max_tokens: 512 });
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should set bank mission via subprocess without throwing', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Create bank by retaining first, then set mission
await client.retain({ content: '[role: user]\nHello\n[user:end]' });
await expect(
client.setBankMission('Test mission for embed integration tests.'),
).resolves.not.toThrow();
}, 60_000);
it('should retain and then recall relevant memories via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nMy cat is named Whiskers and she is 3 years old.\n[user:end]\n\n' +
'[role: assistant]\nWhat a lovely name!\n[assistant:end]',
document_id: `embed-e2e-${Date.now()}`,
});
const response = await client.recall({
query: "What is my cat's name?",
max_tokens: 1024,
});
expect(response).toBeDefined();
expect(Array.isArray(response.results)).toBe(true);
}, 60_000);
it('should map recall results to MemoryResult shape via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
await client.retain({
content:
'[role: user]\nI enjoy cooking Italian food.\n[user:end]\n\n' +
'[role: assistant]\nItalian cuisine is delicious!\n[assistant:end]',
document_id: 'embed-shape-test',
});
const response = await client.recall({ query: 'What food do I like?', max_tokens: 1024 });
for (const result of response.results) {
expect(typeof result.id).toBe('string');
expect(typeof result.text).toBe('string');
expect(typeof result.type).toBe('string');
expect(Array.isArray(result.entities)).toBe(true);
}
}, 60_000);
it('should handle full end-to-end workflow via subprocess', async () => {
if (!hasEmbedCredentials) return;
const bankId = randomBankId();
client.setBankId(bankId);
// Step 1: Retain
const retainResp = await client.retain({
content:
'[role: user]\nI am learning Rust programming.\n[user:end]\n\n' +
'[role: assistant]\nRust is a powerful systems language!\n[assistant:end]',
document_id: `embed-workflow-${Date.now()}`,
metadata: { channel_type: 'telegram', sender_id: '999' },
});
expect(retainResp).toBeDefined();
// Step 2: Recall
const recallResp = await client.recall({
query: 'What am I learning?',
max_tokens: 1024,
});
expect(recallResp).toBeDefined();
expect(Array.isArray(recallResp.results)).toBe(true);
}, 60_000);
});