moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
IdleSessionWatcher,
countTurns,
readRecentTranscript,
buildReviewInstruction,
NOTHING_TO_DO,
DEFAULT_CONFIG,
type SelfEvolutionConfig,
type ReviewTurnResult,
} from '../../src/local/idle-watcher.js';
import { getNotificationGate } from '../../src/local/notification-gate.js';
// A fixed synthetic "now" so idle math is deterministic.
const NOW = 2_000_000_000_000;
const MIN = 60_000;
describe('idle-watcher', () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-idle-'));
});
afterEach(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
// ── Fixtures ─────────────────────────────────────────────────────────
function writeConfig(cfg: Partial<SelfEvolutionConfig> | string): void {
const body = typeof cfg === 'string' ? cfg : JSON.stringify(cfg);
fs.writeFileSync(path.join(dataDir, 'self-evolution.json'), body);
}
/** Create a session .jsonl with `turns` message lines and a given mtime (ms). */
function writeSession(workspaceId: string, sessionId: string, turns: number, mtimeMs: number): void {
const dir = path.join(dataDir, 'workspaces', workspaceId, 'sessions');
fs.mkdirSync(dir, { recursive: true });
const lines = [JSON.stringify({ type: 'meta', title: sessionId })];
for (let i = 0; i < turns; i++) {
lines.push(JSON.stringify({ role: i % 2 === 0 ? 'user' : 'assistant', content: `msg ${i}` }));
}
const filePath = path.join(dir, `${sessionId}.jsonl`);
fs.writeFileSync(filePath, lines.join('\n') + '\n');
fs.utimesSync(filePath, new Date(mtimeMs), new Date(mtimeMs));
}
function makeWatcher(over?: {
runReviewTurn?: (i: { sessionId: string; workspaceId: string }) => Promise<ReviewTurnResult>;
}) {
const reviewed: Array<{ sessionId: string; workspaceId: string }> = [];
const emitCalls: Array<{ suppressed: boolean; dedupeKey?: string }> = [];
const runReviewTurn =
over?.runReviewTurn ??
(async (i: { sessionId: string; workspaceId: string }) => {
reviewed.push(i);
return { content: NOTHING_TO_DO };
});
const runSpy = vi.fn(runReviewTurn);
const watcher = new IdleSessionWatcher({
dataDir,
now: () => NOW,
runReviewTurn: runSpy,
// Mirror production emitNotification's gate so suppression is genuinely tested.
emitNotification: (_event, options) => {
let suppressed = false;
if (options?.dedupeKey && options.materialHash) {
suppressed = !getNotificationGate(dataDir).shouldNotify(options.dedupeKey, options.materialHash);
}
emitCalls.push({ suppressed, dedupeKey: options?.dedupeKey });
return { suppressed };
},
log: { info: () => {}, warn: () => {} },
});
return { watcher, reviewed, emitCalls, runSpy };
}
// ── Fire-condition matrix ────────────────────────────────────────────
describe('fire condition', () => {
beforeEach(() => writeConfig({ enabled: true })); // rest of fields default
it('reviews an idle session with enough turns', async () => {
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, reviewed } = makeWatcher();
const n = await watcher.tick();
expect(n).toBe(1);
expect(reviewed).toEqual([{ sessionId: 'sess-a', workspaceId: 'w1' }]);
});
it('does NOT review a still-active session (mtime too recent)', async () => {
writeSession('w1', 'sess-active', 10, NOW - 5 * MIN); // < 15 min idle
const { watcher, reviewed } = makeWatcher();
expect(await watcher.tick()).toBe(0);
expect(reviewed).toEqual([]);
});
it('does NOT review a session below the turn threshold', async () => {
writeSession('w1', 'sess-short', 3, NOW - 30 * MIN); // idle but only 3 turns
const { watcher, reviewed } = makeWatcher();
expect(await watcher.tick()).toBe(0);
expect(reviewed).toEqual([]);
});
});
// ── Fired-set: no re-fire until mtime advances ───────────────────────
it('does not re-review the same session until its file advances', async () => {
writeConfig({ enabled: true });
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, runSpy } = makeWatcher();
expect(await watcher.tick()).toBe(1);
expect(await watcher.tick()).toBe(0); // same mtime β€” already fired
expect(runSpy).toHaveBeenCalledTimes(1);
// File advances (a new message lands) β†’ new mtime key β†’ re-fires once.
writeSession('w1', 'sess-a', 7, NOW - 18 * MIN);
expect(await watcher.tick()).toBe(1);
expect(runSpy).toHaveBeenCalledTimes(2);
});
// ── Disabled / config handling ───────────────────────────────────────
it('is a no-op when disabled (default)', async () => {
writeConfig({ enabled: false });
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, runSpy } = makeWatcher();
expect(await watcher.tick()).toBe(0);
expect(runSpy).not.toHaveBeenCalled();
});
it('is a no-op when the config file is absent (defaults β‡’ disabled)', async () => {
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, runSpy } = makeWatcher();
expect(await watcher.tick()).toBe(0);
expect(runSpy).not.toHaveBeenCalled();
});
it('falls back to defaults on a corrupt config (β‡’ disabled)', async () => {
writeConfig('{ this is not json');
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, runSpy } = makeWatcher();
expect(await watcher.tick()).toBe(0);
expect(runSpy).not.toHaveBeenCalled();
expect(DEFAULT_CONFIG.enabled).toBe(false);
});
it('applies partial config over defaults (enabled + custom idle threshold)', async () => {
writeConfig({ enabled: true, idleMinutes: 60 }); // minTurns/cap default
writeSession('w1', 'fresh', 6, NOW - 30 * MIN); // idle 30m < 60m β‡’ skipped
writeSession('w1', 'old', 6, NOW - 90 * MIN); // idle 90m β‡’ reviewed
const { watcher, reviewed } = makeWatcher();
expect(await watcher.tick()).toBe(1);
expect(reviewed).toEqual([{ sessionId: 'old', workspaceId: 'w1' }]);
});
// ── Daily cap ────────────────────────────────────────────────────────
it('enforces maxReviewsPerDay', async () => {
writeConfig({ enabled: true, maxReviewsPerDay: 2 });
writeSession('w1', 's1', 6, NOW - 20 * MIN);
writeSession('w1', 's2', 6, NOW - 21 * MIN);
writeSession('w1', 's3', 6, NOW - 22 * MIN);
const { watcher, runSpy } = makeWatcher();
const n = await watcher.tick();
expect(n).toBe(2); // capped at 2 even though 3 are eligible
expect(runSpy).toHaveBeenCalledTimes(2);
// A later tick in the same day stays capped (the 3rd never runs).
expect(await watcher.tick()).toBe(0);
expect(runSpy).toHaveBeenCalledTimes(2);
});
// ── Enumeration skips channel-/evolve- prefixes ──────────────────────
it('skips channel-* and evolve-* sessions', async () => {
writeConfig({ enabled: true });
writeSession('w1', 'channel-telegram-123', 6, NOW - 20 * MIN);
writeSession('w1', 'evolve-sess-a', 6, NOW - 20 * MIN);
writeSession('w1', 'sess-real', 6, NOW - 20 * MIN);
const { watcher, reviewed } = makeWatcher();
expect(await watcher.tick()).toBe(1);
expect(reviewed).toEqual([{ sessionId: 'sess-real', workspaceId: 'w1' }]);
});
// ── Notification gating ──────────────────────────────────────────────
it('does NOT notify on a NOTHING_TO_DO / empty / error result', async () => {
writeConfig({ enabled: true });
writeSession('w1', 'ntd', 6, NOW - 20 * MIN);
writeSession('w1', 'empty', 6, NOW - 21 * MIN);
writeSession('w1', 'err', 6, NOW - 22 * MIN);
const results: Record<string, ReviewTurnResult> = {
ntd: { content: NOTHING_TO_DO },
empty: { content: ' ' },
err: { content: 'partial', error: 'boom' },
};
const { watcher, emitCalls } = makeWatcher({
runReviewTurn: async ({ sessionId }) => results[sessionId],
});
await watcher.tick();
expect(emitCalls).toHaveLength(0);
});
it('notifies once on a material finding and suppresses an identical re-finding', async () => {
writeConfig({ enabled: true });
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
const { watcher, emitCalls } = makeWatcher({
runReviewTurn: async () => ({ content: 'Finding: the assistant promised a report and never produced it.' }),
});
await watcher.tick();
expect(emitCalls).toHaveLength(1);
expect(emitCalls[0].suppressed).toBe(false);
expect(emitCalls[0].dedupeKey).toBe('self-evolution:sess-a');
// Session advances, same finding recurs β†’ notification suppressed (anti-nag).
writeSession('w1', 'sess-a', 7, NOW - 18 * MIN);
await watcher.tick();
expect(emitCalls).toHaveLength(2);
expect(emitCalls[1].suppressed).toBe(true);
});
// ── Single-flight guard ──────────────────────────────────────────────
it('tick is single-flight (a re-entrant tick returns 0)', async () => {
writeConfig({ enabled: true });
writeSession('w1', 'sess-a', 6, NOW - 20 * MIN);
let secondResult = -1;
const { watcher } = makeWatcher({
runReviewTurn: async () => {
// Re-enter while the first tick is still in-flight.
secondResult = await watcher.tick();
return { content: NOTHING_TO_DO };
},
});
await watcher.tick();
expect(secondResult).toBe(0);
});
});
// ── Pure helpers ───────────────────────────────────────────────────────
describe('idle-watcher pure helpers', () => {
it('countTurns excludes the meta line', () => {
const raw = [
JSON.stringify({ type: 'meta', title: 't' }),
JSON.stringify({ role: 'user', content: 'hi' }),
JSON.stringify({ role: 'assistant', content: 'yo' }),
].join('\n');
expect(countTurns(raw)).toBe(2);
});
it('countTurns counts every non-empty line when there is no meta', () => {
const raw = [
JSON.stringify({ role: 'user', content: 'hi' }),
JSON.stringify({ role: 'assistant', content: 'yo' }),
].join('\n');
expect(countTurns(raw)).toBe(2);
expect(countTurns('')).toBe(0);
});
it('readRecentTranscript formats messages and skips meta; null when unreadable', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-tr-'));
try {
const sessionsDir = path.join(dir, 'workspaces', 'w1', 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, 'sess-a.jsonl'),
[
JSON.stringify({ type: 'meta', title: 't' }),
JSON.stringify({ role: 'user', content: 'build me a report' }),
JSON.stringify({ role: 'assistant', content: 'on it' }),
].join('\n'),
);
const t = readRecentTranscript(dir, 'w1', 'sess-a');
expect(t).toContain('USER: build me a report');
expect(t).toContain('ASSISTANT: on it');
expect(t).not.toContain('meta');
expect(readRecentTranscript(dir, 'w1', 'missing')).toBeNull();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('buildReviewInstruction embeds the transcript and the NOTHING_TO_DO contract', () => {
const msg = buildReviewInstruction('sess-a', 'USER: hi');
expect(msg).toContain('sess-a');
expect(msg).toContain('USER: hi');
expect(msg).toContain(NOTHING_TO_DO);
expect(msg).toContain('create_skill');
});
});