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
This commit is contained in:
Nicolò Boschi 2026-02-18 14:10:33 +01:00 committed by GitHub
parent 7eafba661e
commit 6c695eb9f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1240 additions and 135 deletions

View file

@ -726,6 +726,107 @@ jobs:
echo "=== API Server Logs ===" echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found" cat /tmp/api-server.log || echo "No API server log found"
test-openclaw-integration:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Install embed dependencies
working-directory: ./hindsight-embed
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Install openclaw integration dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run openclaw integration tests
working-directory: ./hindsight-integrations/openclaw
run: npm run test:integration
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-integration: test-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:

View file

@ -34,8 +34,9 @@
"build": "tsc", "build": "tsc",
"dev": "tsc --watch", "dev": "tsc --watch",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "vitest run", "test": "vitest run src",
"test:watch": "vitest", "test:watch": "vitest src",
"test:integration": "vitest run --config vitest.integration.config.ts",
"prepublishOnly": "npm run clean && npm run build" "prepublishOnly": "npm run clean && npm run build"
}, },
"dependencies": { "dependencies": {

View file

@ -237,20 +237,7 @@ export class HindsightClient {
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`); throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
} }
const response = await res.json() as { results?: any[] }; return res.json() as Promise<RecallResponse>;
const results = response.results || [];
return {
results: results.map((r: any) => ({
content: r.text || r.content || '',
score: r.score ?? 1.0,
metadata: {
document_id: r.document_id,
chunk_id: r.chunk_id,
...r.metadata,
},
})),
};
} }
private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> { private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
@ -265,21 +252,7 @@ export class HindsightClient {
timeout: timeoutMs ?? 30_000, // subprocess gets a longer default timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
}); });
// Parse JSON output - returns { entities: {...}, results: [...] } return JSON.parse(stdout) as RecallResponse;
const response = JSON.parse(stdout);
const results = response.results || [];
return {
results: results.map((r: any) => ({
content: r.text || r.content || '',
score: 1.0, // CLI doesn't return scores
metadata: {
document_id: r.document_id,
chunk_id: r.chunk_id,
...r.metadata,
},
})),
};
} catch (error) { } catch (error) {
throw new Error(`Failed to recall memories: ${error}`, { cause: error }); throw new Error(`Failed to recall memories: ${error}`, { cause: error });
} }

View file

@ -1,83 +1,143 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { stripMemoryTags, extractRecallQuery } from './index.js';
/** // ---------------------------------------------------------------------------
* Unit tests for the memory feedback loop fix. // stripMemoryTags
* Verifies that <hindsight_memories> and <relevant_memories> tags // ---------------------------------------------------------------------------
* are stripped from content before RETAIN to prevent duplicates.
*/
describe('Memory Tag Stripping', () => {
/**
* Simulates the tag stripping logic from agent_end hook
*/
function stripMemoryTags(content: string): string {
// Strip plugin-injected memory tags to prevent feedback loop
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
it('should strip simple hindsight_memories tags', () => { describe('stripMemoryTags', () => {
const input = 'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?'; it('strips simple hindsight_memories tags', () => {
const expected = 'User: Hello\n\nAssistant: How can I help?'; const input =
const result = stripMemoryTags(input); 'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
expect(result).toBe(expected); expect(stripMemoryTags(input)).toBe('User: Hello\n\nAssistant: How can I help?');
}); });
it('should strip relevant_memories tags', () => { it('strips relevant_memories tags', () => {
const input = 'Before\n<relevant_memories>\nSome data\n</relevant_memories>\nAfter'; const input = 'Before\n<relevant_memories>\nSome data\n</relevant_memories>\nAfter';
const expected = 'Before\n\nAfter'; expect(stripMemoryTags(input)).toBe('Before\n\nAfter');
const result = stripMemoryTags(input);
expect(result).toBe(expected);
}); });
it('should strip multiple hindsight_memories blocks', () => { it('strips multiple hindsight_memories blocks', () => {
const input = 'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd'; const input =
const expected = 'Start\n\nMiddle\n\nEnd'; 'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
const result = stripMemoryTags(input); expect(stripMemoryTags(input)).toBe('Start\n\nMiddle\n\nEnd');
expect(result).toBe(expected);
}); });
it('should handle multiline memory blocks with JSON', () => { it('handles multiline memory blocks with JSON', () => {
const input = 'User: What is the weather?\n<hindsight_memories>\nRelevant memories:\n{\n "memory": "User likes sunny weather"\n}\n</hindsight_memories>\nAssistant: Let me check'; const input =
const expected = 'User: What is the weather?\n\nAssistant: Let me check'; 'User: What is the weather?\n<hindsight_memories>\n[\n {"memory": "User likes sunny weather"}\n]\n</hindsight_memories>\nAssistant: Let me check';
const result = stripMemoryTags(input); const result = stripMemoryTags(input);
expect(result).toBe(expected); expect(result).toBe('User: What is the weather?\n\nAssistant: Let me check');
}); });
it('should preserve content without memory tags', () => { it('preserves content without memory tags', () => {
const input = 'User: Hello\nAssistant: Hi there!'; const input = 'User: Hello\nAssistant: Hi there!';
const expected = 'User: Hello\nAssistant: Hi there!'; expect(stripMemoryTags(input)).toBe(input);
const result = stripMemoryTags(input);
expect(result).toBe(expected);
}); });
it('should handle nested-like content without actual nesting', () => { it('strips both tag types in same content', () => {
const input = '<hindsight_memories>Outer start\n</hindsight_memories>\nSafe content\n<hindsight_memories>\nOuter end</hindsight_memories>'; const input =
const expected = '\nSafe content\n'; 'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
const result = stripMemoryTags(input); expect(stripMemoryTags(input)).toBe('A\n\nB\n\nC');
expect(result).toBe(expected);
}); });
it('should strip both tag types in same content', () => { it('strips tags from a real-world agent conversation with injected memories', () => {
const input = 'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC'; const input =
const expected = 'A\n\nB\n\nC'; '[role: system]\n<hindsight_memories>\nRelevant memories:\n[{"text": "User prefers dark mode"}]\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nLet me help you enable dark mode.\n[assistant:end]';
const result = stripMemoryTags(input);
expect(result).toBe(expected);
});
it('should handle real-world agent conversation with injected memories', () => {
const input = '[role: system]\n<hindsight_memories>\nRelevant memories from past conversations (score 1=highest, prioritize recent when conflicting):\n[\n {\n "content": "User prefers dark mode",\n "relevance_score": 0.95\n }\n]\n\nUser message: How do I enable dark mode?\n</hindsight_memories>\n[system:end]\n\n[role: user]\nHow do I enable dark mode?\n[user:end]\n\n[role: assistant]\nBased on your previous preference, let me help you enable dark mode.\n[assistant:end]';
const result = stripMemoryTags(input); const result = stripMemoryTags(input);
// Should not contain the memory tags
expect(result).not.toContain('<hindsight_memories>'); expect(result).not.toContain('<hindsight_memories>');
expect(result).not.toContain('</hindsight_memories>'); expect(result).not.toContain('</hindsight_memories>');
expect(result).not.toContain('Relevant memories from past conversations'); expect(result).not.toContain('User prefers dark mode');
// Should still contain the actual conversation
expect(result).toContain('[role: user]'); expect(result).toContain('[role: user]');
expect(result).toContain('How do I enable dark mode?'); expect(result).toContain('How do I enable dark mode?');
expect(result).toContain('[role: assistant]'); expect(result).toContain('[role: assistant]');
}); });
}); });
// ---------------------------------------------------------------------------
// extractRecallQuery
// ---------------------------------------------------------------------------
describe('extractRecallQuery', () => {
it('returns rawMessage when it is long enough', () => {
expect(extractRecallQuery('What is my favorite food?', undefined)).toBe(
'What is my favorite food?',
);
});
it('returns null when rawMessage is too short and prompt is absent', () => {
expect(extractRecallQuery('Hi', undefined)).toBeNull();
expect(extractRecallQuery('', '')).toBeNull();
expect(extractRecallQuery(undefined, undefined)).toBeNull();
});
it('returns null when both rawMessage and prompt are too short', () => {
expect(extractRecallQuery('Hey', 'Hey')).toBeNull();
});
it('falls back to prompt when rawMessage is absent', () => {
const result = extractRecallQuery(undefined, 'What programming language do I prefer?');
expect(result).toBe('What programming language do I prefer?');
});
it('strips leading System: lines from prompt', () => {
const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).toContain('What is my name?');
});
it('strips [Channel] envelope header and returns inner message', () => {
const prompt = '[Telegram Chat]\nWhat is my favorite hobby?';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBe('What is my favorite hobby?');
});
it('strips [from: SenderName] footer from group chat prompts', () => {
const prompt = '[Slack Channel #general]\nWhat should I eat for lunch?\n[from: Alice]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('[from: Alice]');
expect(result).toContain('What should I eat for lunch?');
});
it('handles full envelope with System lines, channel header, and from footer', () => {
const prompt =
'System: You are a helpful agent.\n\n[Discord Server]\nRemind me what I said about Python?\n[from: Bob]';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('System:');
expect(result).not.toContain('[Discord');
expect(result).not.toContain('[from: Bob]');
expect(result).toContain('Remind me what I said about Python?');
});
it('strips session abort hint from prompt', () => {
const prompt =
'Note: The previous agent run was aborted by the user\n\n[Telegram]\nWhat is my cat\'s name?';
const result = extractRecallQuery(undefined, prompt);
expect(result).not.toContain('Note: The previous agent run was aborted');
expect(result).toContain("What is my cat's name?");
});
it('returns null when prompt reduces to < 5 chars after stripping', () => {
// Envelope with almost-empty inner message
const prompt = '[Telegram Chat]\nHi';
const result = extractRecallQuery(undefined, prompt);
expect(result).toBeNull();
});
it('prefers rawMessage over prompt even when prompt is longer', () => {
const rawMessage = 'What do I like to eat?';
const prompt = '[Telegram]\nWhat do I like to eat?\n[from: Alice]';
const result = extractRecallQuery(rawMessage, prompt);
// Should return the clean rawMessage verbatim
expect(result).toBe(rawMessage);
expect(result).not.toContain('[from: Alice]');
});
it('trims whitespace from result', () => {
const result = extractRecallQuery(' What is my job? ', undefined);
expect(result).toBe('What is my job?');
});
});

View file

@ -138,6 +138,67 @@ const __dirname = dirname(__filename);
// Default bank name (fallback when channel context not available) // Default bank name (fallback when channel context not available)
const DEFAULT_BANK_NAME = 'openclaw'; const DEFAULT_BANK_NAME = 'openclaw';
/**
* Strip plugin-injected memory tags from content to prevent retain feedback loop.
* Removes <hindsight_memories> and <relevant_memories> blocks that were injected
* during before_agent_start so they don't get re-stored into the memory bank.
*/
export function stripMemoryTags(content: string): string {
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
/**
* Extract a recall query from a hook event's rawMessage or prompt.
*
* Prefers rawMessage (clean user text). Falls back to prompt, stripping
* envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
*
* Returns null when no usable query (< 5 chars) can be extracted.
*/
export function extractRecallQuery(
rawMessage: string | undefined,
prompt: string | undefined,
): string | null {
let recallQuery = rawMessage;
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) {
recallQuery = prompt;
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
return null;
}
// Strip envelope-formatted prompts from any channel
let cleaned = recallQuery;
// Remove leading "System: ..." lines (from prependSystemEvents)
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
// Remove session abort hint
cleaned = cleaned.replace(
/^Note: The previous agent run was aborted[^\n]*\n\n/,
'',
);
// Extract message after [ChannelName ...] envelope header
const envelopeMatch = cleaned.match(
/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/,
);
if (envelopeMatch) {
cleaned = envelopeMatch[1];
}
// Remove trailing [from: SenderName] metadata (group chats)
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
recallQuery = cleaned.trim() || recallQuery;
}
const trimmed = recallQuery.trim();
if (trimmed.length < 5) return null;
return trimmed;
}
/** /**
* Agent context passed to plugin hooks. * Agent context passed to plugin hooks.
* These fields are populated by OpenClaw when invoking hooks. * These fields are populated by OpenClaw when invoking hooks.
@ -671,44 +732,11 @@ export default function (api: MoltbotPluginAPI) {
// Get the user's latest message for recall — only the raw user text, not the full prompt // Get the user's latest message for recall — only the raw user text, not the full prompt
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc. // rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
let recallQuery = event.rawMessage; const extracted = extractRecallQuery(event.rawMessage, event.prompt);
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) { if (!extracted) {
// Fall back to prompt but strip envelope formatting
recallQuery = event.prompt;
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) {
return; return;
} }
let prompt = extracted;
// Strip envelope-formatted prompts from any channel
let cleaned = recallQuery;
// Remove leading "System: ..." lines (from prependSystemEvents)
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
// Remove session abort hint
cleaned = cleaned.replace(
/^Note: The previous agent run was aborted[^\n]*\n\n/,
'',
);
// Extract message after [ChannelName ...] envelope header
const envelopeMatch = cleaned.match(
/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/,
);
if (envelopeMatch) {
cleaned = envelopeMatch[1];
}
// Remove trailing [from: SenderName] metadata (group chats)
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
recallQuery = cleaned.trim() || recallQuery;
}
let prompt = recallQuery.trim();
if (prompt.length < 5) {
return; // Skip very short messages after extraction
}
// Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII // Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII
const MAX_RECALL_QUERY_CHARS = 800; const MAX_RECALL_QUERY_CHARS = 800;
@ -758,7 +786,7 @@ export default function (api: MoltbotPluginAPI) {
const memoriesJson = JSON.stringify(response.results, null, 2); const memoriesJson = JSON.stringify(response.results, null, 2);
const contextMessage = `<hindsight_memories> const contextMessage = `<hindsight_memories>
Relevant memories from past conversations (score 1=highest, prioritize recent when conflicting): Relevant memories from past conversations (prioritize recent when conflicting):
${memoriesJson} ${memoriesJson}
User message: ${prompt} User message: ${prompt}
@ -835,10 +863,7 @@ User message: ${prompt}
} }
// Strip plugin-injected memory tags to prevent feedback loop // Strip plugin-injected memory tags to prevent feedback loop
// Remove <hindsight_memories> blocks injected during before_agent_start content = stripMemoryTags(content);
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
// Remove any <relevant_memories> blocks (legacy/alternative format)
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return `[role: ${role}]\n${content}\n[${role}:end]`; return `[role: ${role}]\n${content}\n[${role}:end]`;
}) })

View file

@ -72,16 +72,24 @@ export interface RecallRequest {
export interface RecallResponse { export interface RecallResponse {
results: MemoryResult[]; results: MemoryResult[];
entities: Record<string, unknown> | null;
trace: unknown | null;
chunks: unknown | null;
} }
export interface MemoryResult { export interface MemoryResult {
content: string; id: string;
score: number; text: string;
metadata?: { type: string;
document_id?: string; entities: string[];
created_at?: string; context: string;
source?: string; occurred_start: string | null;
}; occurred_end: string | null;
mentioned_at: string | null;
document_id: string | null;
metadata: Record<string, unknown> | null;
chunk_id: string | null;
tags: string[];
} }
export interface CreateBankRequest { export interface CreateBankRequest {

View file

@ -0,0 +1,542 @@
/**
* Integration tests for the OpenClaw plugin hooks.
*
* Loads the plugin with a mock MoltbotPluginAPI in HTTP mode, then triggers
* `before_agent_start` and `agent_end` hooks with realistic event payloads.
* Client methods (recall / retain) are spied on to verify the plugin
* orchestrates them correctly without requiring a full LLM pipeline.
*
* Requirements:
* Running Hindsight API at HINDSIGHT_API_URL (default: http://localhost:8888)
*
* Run:
* npm run test:integration
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import type { HindsightClient } from '../src/client.js';
import type { MoltbotPluginAPI, PluginConfig } from '../src/types.js';
import type { RecallResponse, RetainResponse } from '../src/types.js';
const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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;
}
interface MockApiHandle {
api: MoltbotPluginAPI;
/** Trigger a registered hook and return the last handler's return value. */
trigger(event: string, eventData: unknown, ctx?: unknown): Promise<unknown>;
startServices(): Promise<void>;
stopServices(): Promise<void>;
}
function createMockApi(pluginConfig: Partial<PluginConfig> = {}): MockApiHandle {
const handlers = new Map<string, ((event: unknown, ctx?: unknown) => unknown)[]>();
const services: { id: string; start(): Promise<void>; stop(): Promise<void> }[] = [];
const api: MoltbotPluginAPI = {
config: {
plugins: {
entries: {
'hindsight-openclaw': { enabled: true, config: pluginConfig as PluginConfig },
},
},
},
registerService(svc: any) {
services.push(svc);
},
on(event: string, handler: any) {
const list = handlers.get(event) ?? [];
list.push(handler);
handlers.set(event, list);
},
};
return {
api,
async trigger(event, eventData, ctx) {
const list = handlers.get(event) ?? [];
let result: unknown;
for (const h of list) result = await h(eventData, ctx);
return result;
},
async startServices() {
for (const svc of services) await svc.start();
},
async stopServices() {
for (const svc of services) await svc.stop();
},
};
}
const EMPTY_RECALL: RecallResponse = { results: [], entities: null, trace: null, chunks: null };
const OK_RETAIN: RetainResponse = { message: 'queued', document_id: 'test', memory_unit_ids: [] };
function makeMemoryResult(text: string) {
return {
id: `mem-${Math.random().toString(36).slice(2)}`,
text,
type: 'fact',
entities: [],
context: '',
occurred_start: null,
occurred_end: null,
mentioned_at: null,
document_id: null,
metadata: null,
chunk_id: null,
tags: [],
};
}
// ---------------------------------------------------------------------------
// Module-level state shared across all hook describe blocks
// ---------------------------------------------------------------------------
let apiReachable = false;
let triggerHook: MockApiHandle['trigger'];
let stopServicesFn: () => Promise<void>;
let recallSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
let retainSpy: ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
beforeAll(async () => {
apiReachable = await waitForApi(HINDSIGHT_API_URL, 8000);
if (!apiReachable) {
console.warn(
`[Hooks Integration] Hindsight API not reachable at ${HINDSIGHT_API_URL} skipping hook tests.`,
);
return;
}
// Reset module registry so we get a fresh module with clean state.
vi.resetModules();
// Provide LLM config — used by plugin init even in HTTP mode.
process.env.HINDSIGHT_API_LLM_PROVIDER = 'openai';
process.env.HINDSIGHT_API_LLM_API_KEY = 'test-key-hooks';
// Point the plugin at the running test API.
process.env.HINDSIGHT_EMBED_API_URL = HINDSIGHT_API_URL;
const mod = await import('../src/index.js');
const pluginFn = mod.default;
const getClient = mod.getClient;
const handle = createMockApi({
dynamicBankId: true,
excludeProviders: ['slack'],
// No bankMission — keeps init lean
});
triggerHook = handle.trigger;
stopServicesFn = handle.stopServices;
// Load the plugin — registers hooks and starts background init.
pluginFn(handle.api);
// service.start() awaits initPromise and health-checks the external API.
await handle.startServices();
// After startServices the client must be ready.
const c = getClient();
if (!c) throw new Error('[Hooks Integration] Client not initialized after service start');
recallSpy = vi.spyOn(c, 'recall') as ReturnType<typeof vi.spyOn<HindsightClient, 'recall'>>;
retainSpy = vi.spyOn(c, 'retain') as ReturnType<typeof vi.spyOn<HindsightClient, 'retain'>>;
}, 30_000);
afterAll(async () => {
vi.restoreAllMocks();
delete process.env.HINDSIGHT_API_LLM_PROVIDER;
delete process.env.HINDSIGHT_API_LLM_API_KEY;
delete process.env.HINDSIGHT_EMBED_API_URL;
if (stopServicesFn) await stopServicesFn().catch(() => {});
}, 15_000);
afterEach(() => {
// Reset spy call history between tests; don't remove the implementation.
recallSpy?.mockReset();
retainSpy?.mockReset();
});
// ---------------------------------------------------------------------------
// before_agent_start
// ---------------------------------------------------------------------------
describe('before_agent_start hook', () => {
it('skips recall for excluded providers and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What are my preferences?', prompt: 'What are my preferences?' },
{ messageProvider: 'slack', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('skips recall when rawMessage is too short and returns undefined', async () => {
if (!apiReachable) return;
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'Hi', prompt: 'Hi' },
{ messageProvider: 'telegram', senderId: 'U001' },
);
expect(recallSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('returns undefined when recall finds no results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const result = await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I like?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U002' },
);
expect(recallSpy).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it('returns { prependContext } with <hindsight_memories> when recall returns results', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User likes Python')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What programming language do I prefer?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U003' },
)) as { prependContext: string };
expect(result).toBeDefined();
expect(result.prependContext).toContain('<hindsight_memories>');
expect(result.prependContext).toContain('User likes Python');
expect(result.prependContext).toContain('</hindsight_memories>');
});
it('injects all memory result fields in the prependContext JSON', async () => {
if (!apiReachable) return;
const mem = makeMemoryResult('User prefers dark mode');
mem.tags = ['preference'];
mem.entities = ['dark_mode'];
recallSpy.mockResolvedValue({
results: [mem],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'Do I prefer dark or light mode?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U004' },
)) as { prependContext: string };
// The prependContext should be valid JSON containing all MemoryResult fields
const jsonStart = result.prependContext.indexOf('[');
const jsonEnd = result.prependContext.lastIndexOf(']') + 1;
const parsed = JSON.parse(result.prependContext.slice(jsonStart, jsonEnd)) as unknown[];
expect(parsed).toHaveLength(1);
const first = parsed[0] as Record<string, unknown>;
expect(first.id).toBe(mem.id);
expect(first.text).toBe('User prefers dark mode');
expect(first.type).toBe('fact');
expect(first.tags).toEqual(['preference']);
expect(first.entities).toEqual(['dark_mode']);
});
it('extracts the inner query from an envelope-formatted prompt when rawMessage is absent', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
const envelopePrompt = '[Telegram Chat]\nWhat is my favorite food?\n[from: Alice]';
await triggerHook(
'before_agent_start',
{ rawMessage: '', prompt: envelopePrompt },
{ messageProvider: 'telegram', senderId: 'U005' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
// The query passed to recall must NOT contain envelope artifacts
expect(callArgs.query).not.toContain('[Telegram');
expect(callArgs.query).not.toContain('[from: Alice]');
expect(callArgs.query).toContain('What is my favorite food?');
});
it('passes max_tokens to recall', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue(EMPTY_RECALL);
await triggerHook(
'before_agent_start',
{ rawMessage: 'Tell me about my hobbies please.', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U006' },
);
expect(recallSpy).toHaveBeenCalledOnce();
const [callArgs] = recallSpy.mock.calls[0];
expect(callArgs.max_tokens).toBeGreaterThan(0);
});
it('includes the user message in the prependContext block', async () => {
if (!apiReachable) return;
recallSpy.mockResolvedValue({
results: [makeMemoryResult('User loves hiking')],
entities: null,
trace: null,
chunks: null,
});
const result = (await triggerHook(
'before_agent_start',
{ rawMessage: 'What outdoor activities do I enjoy?', prompt: '' },
{ messageProvider: 'telegram', senderId: 'U007' },
)) as { prependContext: string };
expect(result.prependContext).toContain('What outdoor activities do I enjoy?');
});
});
// ---------------------------------------------------------------------------
// agent_end hook
// ---------------------------------------------------------------------------
describe('agent_end hook', () => {
it('skips retain when success is false', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: false, messages: [{ role: 'user', content: 'Hello there world!' }] },
{ messageProvider: 'telegram', senderId: 'U010' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain when messages array is empty', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{ success: true, messages: [] },
{ messageProvider: 'telegram', senderId: 'U011' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('skips retain for excluded providers', async () => {
if (!apiReachable) return;
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'I work as a software engineer.' }],
},
{ messageProvider: 'slack', senderId: 'U012' },
);
expect(retainSpy).not.toHaveBeenCalled();
});
it('calls retain with correctly formatted transcript for string content', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'I love TypeScript.' },
{ role: 'assistant', content: 'TypeScript is great!' },
],
},
{ messageProvider: 'telegram', senderId: 'U013', sessionKey: 'sess-ts-test' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('[role: user]');
expect(req.content).toContain('I love TypeScript.');
expect(req.content).toContain('[user:end]');
expect(req.content).toContain('[role: assistant]');
expect(req.content).toContain('TypeScript is great!');
expect(req.content).toContain('[assistant:end]');
});
it('includes session key in document_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My favourite colour is blue.' }],
},
{ messageProvider: 'telegram', senderId: 'U014', sessionKey: 'sess-colour' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.document_id).toContain('sess-colour');
});
it('populates metadata with channel_type, channel_id, and sender_id', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: 'My cat is named Whiskers.' }],
},
{
messageProvider: 'telegram',
channelId: 'chat-999',
senderId: 'U015',
sessionKey: 'sess-cat',
},
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.metadata?.channel_type).toBe('telegram');
expect(req.metadata?.channel_id).toBe('chat-999');
expect(req.metadata?.sender_id).toBe('U015');
expect(req.metadata?.retained_at).toBeDefined();
expect(req.metadata?.message_count).toBe('1');
});
it('strips <hindsight_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithMemories =
'<hindsight_memories>\nRelevant memories:\n[{"text":"old fact"}]\n</hindsight_memories>\nI enjoy reading science fiction.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithMemories }],
},
{ messageProvider: 'telegram', senderId: 'U016', sessionKey: 'sess-strip' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<hindsight_memories>');
expect(req.content).not.toContain('</hindsight_memories>');
expect(req.content).not.toContain('old fact');
expect(req.content).toContain('I enjoy reading science fiction.');
});
it('strips <relevant_memories> tags from content before retaining', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
const contentWithLegacyTag =
'<relevant_memories>\nSome old memories\n</relevant_memories>\nI am learning Rust.';
await triggerHook(
'agent_end',
{
success: true,
messages: [{ role: 'user', content: contentWithLegacyTag }],
},
{ messageProvider: 'telegram', senderId: 'U017', sessionKey: 'sess-legacy' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).not.toContain('<relevant_memories>');
expect(req.content).toContain('I am learning Rust.');
});
it('handles array content blocks (structured message format)', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'I prefer dark mode in all my editors.' },
{ type: 'image', source: 'data:...' }, // non-text block — should be ignored
],
},
],
},
{ messageProvider: 'telegram', senderId: 'U018', sessionKey: 'sess-array' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
expect(req.content).toContain('I prefer dark mode in all my editors.');
// Image block text should not appear
expect(req.content).not.toContain('data:');
});
it('retains a multi-turn conversation in the correct transcript format', async () => {
if (!apiReachable) return;
retainSpy.mockResolvedValue(OK_RETAIN);
await triggerHook(
'agent_end',
{
success: true,
messages: [
{ role: 'user', content: 'My name is Carol.' },
{ role: 'assistant', content: 'Nice to meet you, Carol!' },
{ role: 'user', content: 'I work as a data scientist.' },
{ role: 'assistant', content: "That's a fascinating career!" },
],
},
{ messageProvider: 'telegram', senderId: 'U019', sessionKey: 'sess-multi' },
);
expect(retainSpy).toHaveBeenCalledOnce();
const [req] = retainSpy.mock.calls[0];
// Each message should appear in the correct envelope format
expect(req.content).toContain('[role: user]\nMy name is Carol.\n[user:end]');
expect(req.content).toContain('[role: assistant]\nNice to meet you, Carol!\n[assistant:end]');
expect(req.content).toContain('[role: user]\nI work as a data scientist.\n[user:end]');
expect(req.metadata?.message_count).toBe('4');
});
});

View file

@ -0,0 +1,385 @@
/**
* 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);
});

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
testTimeout: 120_000,
hookTimeout: 120_000,
reporters: ['verbose'],
},
});