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:
parent
7eafba661e
commit
6c695eb9f8
9 changed files with 1240 additions and 135 deletions
101
.github/workflows/test.yml
vendored
101
.github/workflows/test.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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]`;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
542
hindsight-integrations/openclaw/tests/hooks.integration.test.ts
Normal file
542
hindsight-integrations/openclaw/tests/hooks.integration.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
385
hindsight-integrations/openclaw/tests/integration.test.ts
Normal file
385
hindsight-integrations/openclaw/tests/integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
10
hindsight-integrations/openclaw/vitest.integration.config.ts
Normal file
10
hindsight-integrations/openclaw/vitest.integration.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue