Skip to content

Commit 6c695eb

Browse files
authored
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
1 parent 7eafba6 commit 6c695eb

9 files changed

Lines changed: 1241 additions & 136 deletions

File tree

.github/workflows/test.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,107 @@ jobs:
726726
echo "=== API Server Logs ==="
727727
cat /tmp/api-server.log || echo "No API server log found"
728728
729+
test-openclaw-integration:
730+
runs-on: ubuntu-latest
731+
env:
732+
HINDSIGHT_API_LLM_PROVIDER: groq
733+
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
734+
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
735+
HINDSIGHT_API_URL: http://localhost:8888
736+
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
737+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
738+
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
739+
740+
steps:
741+
- uses: actions/checkout@v4
742+
743+
- name: Install uv
744+
uses: astral-sh/setup-uv@v5
745+
with:
746+
enable-cache: true
747+
prune-cache: false
748+
749+
- name: Set up Python
750+
uses: actions/setup-python@v5
751+
with:
752+
python-version-file: ".python-version"
753+
754+
- name: Set up Node.js
755+
uses: actions/setup-node@v4
756+
with:
757+
node-version: '22'
758+
759+
- name: Build API
760+
working-directory: ./hindsight-api
761+
run: uv build
762+
763+
- name: Install API dependencies
764+
working-directory: ./hindsight-api
765+
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
766+
767+
- name: Install embed dependencies
768+
working-directory: ./hindsight-embed
769+
run: uv sync --frozen --index-strategy unsafe-best-match
770+
771+
- name: Cache HuggingFace models
772+
uses: actions/cache@v4
773+
with:
774+
path: ~/.cache/huggingface
775+
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
776+
restore-keys: |
777+
${{ runner.os }}-huggingface-
778+
779+
- name: Pre-download models
780+
working-directory: ./hindsight-api
781+
run: |
782+
uv run python -c "
783+
from sentence_transformers import SentenceTransformer, CrossEncoder
784+
print('Downloading embedding model...')
785+
SentenceTransformer('BAAI/bge-small-en-v1.5')
786+
print('Downloading cross-encoder model...')
787+
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
788+
print('Models downloaded successfully')
789+
"
790+
791+
- name: Install openclaw integration dependencies
792+
working-directory: ./hindsight-integrations/openclaw
793+
run: npm ci
794+
795+
- name: Create .env file
796+
run: |
797+
cat > .env << EOF
798+
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
799+
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
800+
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
801+
EOF
802+
803+
- name: Start API server
804+
run: |
805+
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
806+
echo "Waiting for API server to be ready..."
807+
for i in {1..60}; do
808+
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
809+
echo "API server is ready after ${i}s"
810+
break
811+
fi
812+
if [ $i -eq 60 ]; then
813+
echo "API server failed to start after 60s"
814+
cat /tmp/api-server.log
815+
exit 1
816+
fi
817+
sleep 1
818+
done
819+
820+
- name: Run openclaw integration tests
821+
working-directory: ./hindsight-integrations/openclaw
822+
run: npm run test:integration
823+
824+
- name: Show API server logs
825+
if: always()
826+
run: |
827+
echo "=== API Server Logs ==="
828+
cat /tmp/api-server.log || echo "No API server log found"
829+
729830
test-integration:
730831
runs-on: ubuntu-latest
731832
env:

hindsight-integrations/openclaw/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
"build": "tsc",
3535
"dev": "tsc --watch",
3636
"clean": "rm -rf dist",
37-
"test": "vitest run",
38-
"test:watch": "vitest",
37+
"test": "vitest run src",
38+
"test:watch": "vitest src",
39+
"test:integration": "vitest run --config vitest.integration.config.ts",
3940
"prepublishOnly": "npm run clean && npm run build"
4041
},
4142
"dependencies": {

hindsight-integrations/openclaw/src/client.ts

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -237,20 +237,7 @@ export class HindsightClient {
237237
throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`);
238238
}
239239

240-
const response = await res.json() as { results?: any[] };
241-
const results = response.results || [];
242-
243-
return {
244-
results: results.map((r: any) => ({
245-
content: r.text || r.content || '',
246-
score: r.score ?? 1.0,
247-
metadata: {
248-
document_id: r.document_id,
249-
chunk_id: r.chunk_id,
250-
...r.metadata,
251-
},
252-
})),
253-
};
240+
return res.json() as Promise<RecallResponse>;
254241
}
255242

256243
private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
@@ -265,21 +252,7 @@ export class HindsightClient {
265252
timeout: timeoutMs ?? 30_000, // subprocess gets a longer default
266253
});
267254

268-
// Parse JSON output - returns { entities: {...}, results: [...] }
269-
const response = JSON.parse(stdout);
270-
const results = response.results || [];
271-
272-
return {
273-
results: results.map((r: any) => ({
274-
content: r.text || r.content || '',
275-
score: 1.0, // CLI doesn't return scores
276-
metadata: {
277-
document_id: r.document_id,
278-
chunk_id: r.chunk_id,
279-
...r.metadata,
280-
},
281-
})),
282-
};
255+
return JSON.parse(stdout) as RecallResponse;
283256
} catch (error) {
284257
throw new Error(`Failed to recall memories: ${error}`, { cause: error });
285258
}
Lines changed: 116 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,143 @@
11
import { describe, it, expect } from 'vitest';
2+
import { stripMemoryTags, extractRecallQuery } from './index.js';
23

3-
/**
4-
* Unit tests for the memory feedback loop fix.
5-
* Verifies that <hindsight_memories> and <relevant_memories> tags
6-
* are stripped from content before RETAIN to prevent duplicates.
7-
*/
8-
describe('Memory Tag Stripping', () => {
9-
/**
10-
* Simulates the tag stripping logic from agent_end hook
11-
*/
12-
function stripMemoryTags(content: string): string {
13-
// Strip plugin-injected memory tags to prevent feedback loop
14-
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
15-
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
16-
return content;
17-
}
18-
19-
it('should strip simple hindsight_memories tags', () => {
20-
const input = 'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
21-
const expected = 'User: Hello\n\nAssistant: How can I help?';
22-
const result = stripMemoryTags(input);
23-
expect(result).toBe(expected);
4+
// ---------------------------------------------------------------------------
5+
// stripMemoryTags
6+
// ---------------------------------------------------------------------------
7+
8+
describe('stripMemoryTags', () => {
9+
it('strips simple hindsight_memories tags', () => {
10+
const input =
11+
'User: Hello\n<hindsight_memories>\nRelevant memories here...\n</hindsight_memories>\nAssistant: How can I help?';
12+
expect(stripMemoryTags(input)).toBe('User: Hello\n\nAssistant: How can I help?');
2413
});
2514

26-
it('should strip relevant_memories tags', () => {
15+
it('strips relevant_memories tags', () => {
2716
const input = 'Before\n<relevant_memories>\nSome data\n</relevant_memories>\nAfter';
28-
const expected = 'Before\n\nAfter';
29-
const result = stripMemoryTags(input);
30-
expect(result).toBe(expected);
17+
expect(stripMemoryTags(input)).toBe('Before\n\nAfter');
3118
});
3219

33-
it('should strip multiple hindsight_memories blocks', () => {
34-
const input = 'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
35-
const expected = 'Start\n\nMiddle\n\nEnd';
36-
const result = stripMemoryTags(input);
37-
expect(result).toBe(expected);
20+
it('strips multiple hindsight_memories blocks', () => {
21+
const input =
22+
'Start\n<hindsight_memories>\nBlock 1\n</hindsight_memories>\nMiddle\n<hindsight_memories>\nBlock 2\n</hindsight_memories>\nEnd';
23+
expect(stripMemoryTags(input)).toBe('Start\n\nMiddle\n\nEnd');
3824
});
3925

40-
it('should handle multiline memory blocks with JSON', () => {
41-
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';
42-
const expected = 'User: What is the weather?\n\nAssistant: Let me check';
26+
it('handles multiline memory blocks with JSON', () => {
27+
const input =
28+
'User: What is the weather?\n<hindsight_memories>\n[\n {"memory": "User likes sunny weather"}\n]\n</hindsight_memories>\nAssistant: Let me check';
4329
const result = stripMemoryTags(input);
44-
expect(result).toBe(expected);
30+
expect(result).toBe('User: What is the weather?\n\nAssistant: Let me check');
4531
});
4632

47-
it('should preserve content without memory tags', () => {
33+
it('preserves content without memory tags', () => {
4834
const input = 'User: Hello\nAssistant: Hi there!';
49-
const expected = 'User: Hello\nAssistant: Hi there!';
50-
const result = stripMemoryTags(input);
51-
expect(result).toBe(expected);
35+
expect(stripMemoryTags(input)).toBe(input);
5236
});
5337

54-
it('should handle nested-like content without actual nesting', () => {
55-
const input = '<hindsight_memories>Outer start\n</hindsight_memories>\nSafe content\n<hindsight_memories>\nOuter end</hindsight_memories>';
56-
const expected = '\nSafe content\n';
57-
const result = stripMemoryTags(input);
58-
expect(result).toBe(expected);
59-
});
60-
61-
it('should strip both tag types in same content', () => {
62-
const input = 'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
63-
const expected = 'A\n\nB\n\nC';
64-
const result = stripMemoryTags(input);
65-
expect(result).toBe(expected);
38+
it('strips both tag types in same content', () => {
39+
const input =
40+
'A\n<hindsight_memories>\nH mem\n</hindsight_memories>\nB\n<relevant_memories>\nR mem\n</relevant_memories>\nC';
41+
expect(stripMemoryTags(input)).toBe('A\n\nB\n\nC');
6642
});
6743

68-
it('should handle real-world agent conversation with injected memories', () => {
69-
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]';
44+
it('strips tags from a real-world agent conversation with injected memories', () => {
45+
const input =
46+
'[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]';
7047

7148
const result = stripMemoryTags(input);
7249

73-
// Should not contain the memory tags
7450
expect(result).not.toContain('<hindsight_memories>');
7551
expect(result).not.toContain('</hindsight_memories>');
76-
expect(result).not.toContain('Relevant memories from past conversations');
77-
78-
// Should still contain the actual conversation
52+
expect(result).not.toContain('User prefers dark mode');
7953
expect(result).toContain('[role: user]');
8054
expect(result).toContain('How do I enable dark mode?');
8155
expect(result).toContain('[role: assistant]');
8256
});
8357
});
58+
59+
// ---------------------------------------------------------------------------
60+
// extractRecallQuery
61+
// ---------------------------------------------------------------------------
62+
63+
describe('extractRecallQuery', () => {
64+
it('returns rawMessage when it is long enough', () => {
65+
expect(extractRecallQuery('What is my favorite food?', undefined)).toBe(
66+
'What is my favorite food?',
67+
);
68+
});
69+
70+
it('returns null when rawMessage is too short and prompt is absent', () => {
71+
expect(extractRecallQuery('Hi', undefined)).toBeNull();
72+
expect(extractRecallQuery('', '')).toBeNull();
73+
expect(extractRecallQuery(undefined, undefined)).toBeNull();
74+
});
75+
76+
it('returns null when both rawMessage and prompt are too short', () => {
77+
expect(extractRecallQuery('Hey', 'Hey')).toBeNull();
78+
});
79+
80+
it('falls back to prompt when rawMessage is absent', () => {
81+
const result = extractRecallQuery(undefined, 'What programming language do I prefer?');
82+
expect(result).toBe('What programming language do I prefer?');
83+
});
84+
85+
it('strips leading System: lines from prompt', () => {
86+
const prompt = 'System: You are an agent.\nSystem: Use tools wisely.\n\nWhat is my name?';
87+
const result = extractRecallQuery(undefined, prompt);
88+
expect(result).not.toContain('System:');
89+
expect(result).toContain('What is my name?');
90+
});
91+
92+
it('strips [Channel] envelope header and returns inner message', () => {
93+
const prompt = '[Telegram Chat]\nWhat is my favorite hobby?';
94+
const result = extractRecallQuery(undefined, prompt);
95+
expect(result).toBe('What is my favorite hobby?');
96+
});
97+
98+
it('strips [from: SenderName] footer from group chat prompts', () => {
99+
const prompt = '[Slack Channel #general]\nWhat should I eat for lunch?\n[from: Alice]';
100+
const result = extractRecallQuery(undefined, prompt);
101+
expect(result).not.toContain('[from: Alice]');
102+
expect(result).toContain('What should I eat for lunch?');
103+
});
104+
105+
it('handles full envelope with System lines, channel header, and from footer', () => {
106+
const prompt =
107+
'System: You are a helpful agent.\n\n[Discord Server]\nRemind me what I said about Python?\n[from: Bob]';
108+
const result = extractRecallQuery(undefined, prompt);
109+
expect(result).not.toContain('System:');
110+
expect(result).not.toContain('[Discord');
111+
expect(result).not.toContain('[from: Bob]');
112+
expect(result).toContain('Remind me what I said about Python?');
113+
});
114+
115+
it('strips session abort hint from prompt', () => {
116+
const prompt =
117+
'Note: The previous agent run was aborted by the user\n\n[Telegram]\nWhat is my cat\'s name?';
118+
const result = extractRecallQuery(undefined, prompt);
119+
expect(result).not.toContain('Note: The previous agent run was aborted');
120+
expect(result).toContain("What is my cat's name?");
121+
});
122+
123+
it('returns null when prompt reduces to < 5 chars after stripping', () => {
124+
// Envelope with almost-empty inner message
125+
const prompt = '[Telegram Chat]\nHi';
126+
const result = extractRecallQuery(undefined, prompt);
127+
expect(result).toBeNull();
128+
});
129+
130+
it('prefers rawMessage over prompt even when prompt is longer', () => {
131+
const rawMessage = 'What do I like to eat?';
132+
const prompt = '[Telegram]\nWhat do I like to eat?\n[from: Alice]';
133+
const result = extractRecallQuery(rawMessage, prompt);
134+
// Should return the clean rawMessage verbatim
135+
expect(result).toBe(rawMessage);
136+
expect(result).not.toContain('[from: Alice]');
137+
});
138+
139+
it('trims whitespace from result', () => {
140+
const result = extractRecallQuery(' What is my job? ', undefined);
141+
expect(result).toBe('What is my job?');
142+
});
143+
});

0 commit comments

Comments
 (0)