230 lines
8.7 KiB
JavaScript
230 lines
8.7 KiB
JavaScript
/**
|
|
* Tests for the OpenClaw self-improvement hook.
|
|
*
|
|
* Run with: node --test hooks/openclaw/
|
|
* (no dependencies; uses the built-in node:test runner)
|
|
*/
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { test, beforeEach, afterEach } = require('node:test');
|
|
|
|
const handler = require('./handler.js');
|
|
|
|
let workspaceDir;
|
|
|
|
beforeEach(async () => {
|
|
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'self-improvement-hook-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function makeBootstrapEvent(overrides = {}) {
|
|
return {
|
|
type: 'agent',
|
|
action: 'bootstrap',
|
|
sessionKey: 'agent:main:whatsapp',
|
|
timestamp: new Date('2026-07-04T12:00:00Z'),
|
|
messages: [],
|
|
context: { workspaceDir, bootstrapFiles: [] },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeCommandEvent(action, sessionFile) {
|
|
return {
|
|
type: 'command',
|
|
action,
|
|
sessionKey: 'agent:main:whatsapp',
|
|
timestamp: new Date('2026-07-04T12:00:00Z'),
|
|
messages: [],
|
|
context: {
|
|
workspaceDir,
|
|
commandSource: 'whatsapp',
|
|
previousSessionEntry: { sessionId: 'abc123', sessionFile },
|
|
},
|
|
};
|
|
}
|
|
|
|
async function writeTranscript(lines) {
|
|
const sessionsDir = path.join(workspaceDir, 'sessions');
|
|
await fs.mkdir(sessionsDir, { recursive: true });
|
|
const sessionFile = path.join(sessionsDir, 'abc123.jsonl');
|
|
await fs.writeFile(sessionFile, lines.map((line) => JSON.stringify(line)).join('\n'));
|
|
return sessionFile;
|
|
}
|
|
|
|
function toolResultLine(text) {
|
|
return {
|
|
type: 'message',
|
|
message: { role: 'toolResult', content: [{ type: 'text', text }] },
|
|
};
|
|
}
|
|
|
|
const errorsFile = () => path.join(workspaceDir, '.learnings', 'ERRORS.md');
|
|
|
|
test('bootstrap injects the reminder as a virtual file', async () => {
|
|
const event = makeBootstrapEvent();
|
|
await handler(event);
|
|
|
|
assert.equal(event.context.bootstrapFiles.length, 1);
|
|
const injected = event.context.bootstrapFiles[0];
|
|
assert.equal(injected.path, 'SELF_IMPROVEMENT_REMINDER.md');
|
|
assert.equal(injected.virtual, true);
|
|
assert.match(injected.content, /## Self-Improvement Reminder/);
|
|
assert.doesNotMatch(injected.content, /Pending triage/);
|
|
});
|
|
|
|
test('bootstrap skips sub-agent sessions', async () => {
|
|
const event = makeBootstrapEvent({ sessionKey: 'agent:main:subagent:xyz' });
|
|
await handler(event);
|
|
assert.equal(event.context.bootstrapFiles.length, 0);
|
|
});
|
|
|
|
test('bootstrap deduplicates a previously injected reminder', async () => {
|
|
const event = makeBootstrapEvent();
|
|
event.context.bootstrapFiles.push({
|
|
name: 'SELF_IMPROVEMENT_REMINDER.md',
|
|
path: 'SELF_IMPROVEMENT_REMINDER.md',
|
|
content: '## Self-Improvement Reminder\n\nstale copy',
|
|
virtual: true,
|
|
});
|
|
await handler(event);
|
|
assert.equal(event.context.bootstrapFiles.length, 1);
|
|
});
|
|
|
|
test('session-end sweep appends detected errors to ERRORS.md', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const sessionFile = await writeTranscript([
|
|
{ type: 'message', message: { role: 'user', content: 'run the build' } },
|
|
toolResultLine('npm ERR! missing script: build\nbash: tsc: command not found'),
|
|
{ type: 'message', message: { role: 'assistant', content: 'Build is broken.' } },
|
|
]);
|
|
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
assert.match(content, /^# Errors/);
|
|
assert.match(content, /## \[ERR-20260704-[A-Z0-9]{3}\] openclaw_session_sweep/);
|
|
assert.match(content, /npm ERR! missing script: build/);
|
|
assert.match(content, /command not found/);
|
|
assert.match(content, /Source: openclaw-error-sweep/);
|
|
assert.match(content, /\*\*Status\*\*: pending/);
|
|
assert.match(content, /- Pattern-Key: deps\.npm-error/);
|
|
assert.match(content, /- Pattern-Key: shell\.command-not-found/);
|
|
});
|
|
|
|
test('sweep stamps the most specific Pattern-Key and dedupes keys', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const sessionFile = await writeTranscript([
|
|
// 'ModuleNotFoundError' must win over the generic 'Error:'/'Traceback' buckets
|
|
toolResultLine("ModuleNotFoundError: No module named 'requests'"),
|
|
toolResultLine('TypeError: cannot read properties of undefined (first)'),
|
|
toolResultLine('TypeError: cannot read properties of undefined (second)'),
|
|
]);
|
|
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
assert.match(content, /- Pattern-Key: deps\.module-not-found/);
|
|
const typeErrorKeys = content.split('- Pattern-Key: runtime.type-error').length - 1;
|
|
assert.equal(typeErrorKeys, 1);
|
|
assert.doesNotMatch(content, /- Pattern-Key: runtime\.error/);
|
|
});
|
|
|
|
test('sweep does nothing when .learnings/ does not exist (opt-in gate)', async () => {
|
|
const sessionFile = await writeTranscript([toolResultLine('fatal: not a git repository')]);
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
await assert.rejects(fs.access(errorsFile()));
|
|
});
|
|
|
|
test('sweep does nothing when the transcript has no errors', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const sessionFile = await writeTranscript([
|
|
{ type: 'message', message: { role: 'assistant', content: 'All good!' } },
|
|
]);
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
await assert.rejects(fs.access(errorsFile()));
|
|
});
|
|
|
|
test('sweep never overwrites an existing ERRORS.md', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
await fs.writeFile(errorsFile(), '# Errors\n\nExisting notes.\n\n---\n');
|
|
const sessionFile = await writeTranscript([toolResultLine('Error: connection refused')]);
|
|
|
|
await handler(makeCommandEvent('reset', sessionFile));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
assert.match(content, /Existing notes\./);
|
|
assert.match(content, /Error: connection refused/);
|
|
});
|
|
|
|
test('sweep is idempotent for already-logged excerpts', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const sessionFile = await writeTranscript([toolResultLine('Error: connection refused')]);
|
|
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
const occurrences = content.split('Error: connection refused').length - 1;
|
|
assert.equal(occurrences, 1);
|
|
});
|
|
|
|
test('sweep redacts secrets and truncates long lines', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const longTail = 'x'.repeat(300);
|
|
// Fixture secret is assembled at runtime so the literal never appears in
|
|
// this file and secret scanners don't flag it as an exposed credential.
|
|
const fakeKey = ['sk', 'live', '1234567890'].join('-');
|
|
const sessionFile = await writeTranscript([
|
|
toolResultLine(
|
|
`Error: request failed with api_key=${fakeKey} Bearer abc.def.ghi token: hunter2 ${longTail}`,
|
|
),
|
|
]);
|
|
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
assert.doesNotMatch(content, new RegExp(fakeKey));
|
|
assert.doesNotMatch(content, /hunter2/);
|
|
assert.match(content, /\[REDACTED\]/);
|
|
assert.doesNotMatch(content, /x{250}/);
|
|
});
|
|
|
|
test('sweep falls back to <workspace>/sessions/<sessionId>.jsonl', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
await writeTranscript([toolResultLine('Traceback (most recent call last):')]);
|
|
|
|
await handler(makeCommandEvent('new', undefined));
|
|
|
|
const content = await fs.readFile(errorsFile(), 'utf-8');
|
|
assert.match(content, /Traceback/);
|
|
});
|
|
|
|
test('bootstrap surfaces pending sweep entries for triage', async () => {
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
const sessionFile = await writeTranscript([toolResultLine('Permission denied (publickey)')]);
|
|
await handler(makeCommandEvent('new', sessionFile));
|
|
|
|
const event = makeBootstrapEvent();
|
|
await handler(event);
|
|
|
|
const injected = event.context.bootstrapFiles[0];
|
|
assert.match(injected.content, /\*\*Pending triage:\*\* 1 auto-detected error entry/);
|
|
});
|
|
|
|
test('handler ignores unrelated events and malformed input', async () => {
|
|
await handler(null);
|
|
await handler({ type: 'gateway', action: 'startup', context: {} });
|
|
await handler({ type: 'command', action: 'stop', context: { workspaceDir } });
|
|
// A sweep with a missing transcript must not throw.
|
|
await fs.mkdir(path.join(workspaceDir, '.learnings'), { recursive: true });
|
|
await handler(makeCommandEvent('new', path.join(workspaceDir, 'sessions', 'missing.jsonl')));
|
|
await assert.rejects(fs.access(errorsFile()));
|
|
});
|