Files
pepa-pi-bot/runtime/coach/reflect.test.js
T
e84148d189 v0.2.0-rc.2: P0 hardening — Pi headless, test state isolation, advice fixes (#21)
P0 (correctness):

1. PEPA_HEADLESS=1 guard in extensions/mineflayer-bridge.ts. When `pi -p`
   spawns a subprocess (banter, coach, planner, reflect, auto-patch), the
   bridge no longer attempts a second MC connect — the hybrid runtime
   already owns the nickname. runtime/pi-bridge.js sets the env var on
   every spawn. Root cause of the "two pepa_bot's racing for the slot"
   bug seen in reply-pi stderr.

2. Test state isolation in runtime/config.js. When running under the node
   test runner (detected via execArgv/argv) — or when PEPA_STATE_DIR is
   set — stateDir redirects to /tmp/pepa-test-state-<pid>/. log.js,
   scenario-memory, world-journal, and knowledge.db all follow.
   `npm test` no longer pollutes live scenarios.jsonl, world-journal.jsonl,
   or daily log files. Verified empirically: post-fix run added 0 test
   rows to the live scenarios file. Cleaned ~550 historical test rows
   from live state in the same change.

3. defendReflex outcome reporting (runtime/reflex.js). Previously a
   creeper-rule override marked the lesson succeeded=false BEFORE the
   flee skill returned. Now dispatchDefendFlee accepts {lessonId} and
   the onComplete fires reportAdviceOutcome with the actual flee result.

4. Mode-name → skill-id translation in runtime/coach/advice.js. Pi-coach
   occasionally returns prefer_skill values that are mode names
   ("night_shelter", "self_preservation", "hunger"). normalisePreferSkill
   maps these to SAFE_OVERRIDES entries before dispatch. Also handles
   "tunnel-out", "survive_flee", "survive flee" shapes.

New behavior:

5. Self-reflection loop (runtime/coach/reflect.js). Every 30 min, the
   bot asks Pi: "Are you making progress, or stuck in a loop? What
   should you do differently?" Pi answers with a verdict
   (progress/loop/recovering/idle/emergency), summary, next-action, and
   0-N new lessons. The reflection is written to
   state/<host>/reflections/<ts>.md and lessons land in the DB with
   source="pi-reflect". Rate-limited to 2 calls/hour. Wired through
   bot.js with the existing askPi + lastSnapshot accessor.

Tests: 246/246 green (+9 new: 6 advice mode-name + 4 reflect).

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:10:28 +03:00

113 lines
4.2 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, readdirSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { initKnowledge, recall } from "../knowledge/index.js";
import { closeStore, __resetForTests, isAvailable } from "../knowledge/store.js";
import { runOnce, __testing } from "./reflect.js";
const { buildPrompt, parseReply } = __testing;
test("buildPrompt: includes runtime state + plan + diary", () => {
const p = buildPrompt({
snap: {
position: { x: 600, y: 64, z: 200 },
health: 4, food: 6, isDay: false,
runtimeState: "emergency",
activeSkill: "explore.far",
currentMilestone: "wood.16",
noProgressReason: "no_reachable_target",
lastResult: { ok: false, code: "wedged" },
inventory: { dirt: 12 },
},
journal: ['{"kind":"chopped"}'],
scenarios: ['{"skillId":"explore.far","code":"wedged"}'],
diary: "13:00 spawned\n13:05 died",
plan: "1. Gather 16 logs\n2. Craft pickaxe",
});
assert.match(p, /position: \(600, 64, 200\)/);
assert.match(p, /hp: 4 food: 6/);
assert.match(p, /emergency/);
assert.match(p, /Gather 16 logs/);
assert.match(p, /Reply with ONE JSON object/);
});
test("parseReply: extracts JSON from various Pi outputs", () => {
assert.deepEqual(parseReply('{"verdict":"loop","summary":"stuck"}'), { verdict: "loop", summary: "stuck" });
assert.deepEqual(parseReply('```json\n{"verdict":"progress"}\n```'), { verdict: "progress" });
const longReply = 'I see... your situation. Here is my JSON:\n{"verdict":"emergency","summary":"hp critical","lessons":[]}\nDone.';
assert.deepEqual(parseReply(longReply), { verdict: "emergency", summary: "hp critical", lessons: [] });
assert.equal(parseReply("no json here"), null);
assert.equal(parseReply(""), null);
});
test("runOnce: writes reflection file + records lessons", async () => {
const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-"));
__resetForTests();
await initKnowledge({ stateDir: tmp });
if (!isAvailable()) {
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
return;
}
const fakeReply = JSON.stringify({
verdict: "loop",
summary: "Бот ходит по кругу, ничего не добывает.",
next_action: "выбрать новое место под базу",
lessons: [{
lesson: "В этой точке постоянные смерти — искать новое место.",
category: "survival",
prefer_skill: "village.choose-base",
confidence: 0.7,
}],
});
const askPi = ({ onChunk, onDone }) => {
onChunk({ stream: "stdout", text: fakeReply });
onDone({ code: 0 });
};
const getSnapshot = () => ({
position: { x: 0, y: 64, z: 0 },
health: 8, food: 10, isDay: true,
runtimeState: "working",
inventory: {},
});
const result = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
assert.equal(result.ok, true);
assert.equal(result.verdict, "loop");
const reflectionsDir = join(tmp, "reflections");
assert.ok(existsSync(reflectionsDir));
const files = readdirSync(reflectionsDir);
assert.ok(files.length >= 1, `expected ≥1 reflection file, got ${files.length}`);
const lessons = recall({ category: "survival" });
assert.ok(lessons.some((l) => l.source === "pi-reflect"), "lesson recorded with source=pi-reflect");
closeStore();
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
});
test("runOnce: budget exhausted → ok=false", async () => {
const tmp = mkdtempSync(join(tmpdir(), "pepa-reflect-test-"));
__resetForTests();
await initKnowledge({ stateDir: tmp });
if (!isAvailable()) {
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
return;
}
const askPi = ({ onDone }) => onDone({ code: 0 });
const getSnapshot = () => ({});
// Fire 2 forced calls to exhaust budget; 3rd without force should fail.
await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
await runOnce({ stateDir: tmp, askPi, getSnapshot, force: true });
const res = await runOnce({ stateDir: tmp, askPi, getSnapshot, force: false });
assert.equal(res.ok, false);
assert.match(res.reason ?? "", /budget|reply/);
closeStore();
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
});