Files
pepa-pi-bot/runtime/coach/fast-advisor.test.js
T
mayatnikovandClaude Opus 4.7 ecbdd05058 chore(v0.3.0): rename fast-LLM env vars to TIMEWEB_* (match other projects)
Aligns with the user's other repos (proso) which use TIMEWEB_API_GROK /
TIMEWEB_URL_GROK. Single naming convention across projects avoids the
'which env var was it for this repo' mental tax.

  PEPA_FAST_LLM_BASE_URL → TIMEWEB_BASE_URL
  PEPA_FAST_LLM_API_KEY  → TIMEWEB_API_KEY
  PEPA_FAST_LLM_MODEL    → TIMEWEB_MODEL
  PEPA_FAST_LLM_TIMEOUT_MS → TIMEWEB_TIMEOUT_MS

Provider still works with any OpenAI-compatible endpoint — TimeWeb is
the default but the variable name doesn't lock us in. Tests + docs +
.env / .env.example updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:09:04 +03:00

165 lines
5.4 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { advise, isAvailable, _resetForTest, __testing } from "./fast-advisor.js";
const API_KEY = "TIMEWEB_API_KEY";
const MODEL = "TIMEWEB_MODEL";
const BASE = "TIMEWEB_BASE_URL";
function withEnv(env, fn) {
const prev = {};
for (const k of Object.keys(env)) {
prev[k] = process.env[k];
if (env[k] === undefined) delete process.env[k];
else process.env[k] = env[k];
}
return Promise.resolve(fn()).finally(() => {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
}
function stubFetch(reply) {
const calls = [];
const orig = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return {
ok: true,
json: async () => ({
choices: [{ message: { content: typeof reply === "string" ? reply : JSON.stringify(reply) } }],
}),
};
};
return { calls, restore() { globalThis.fetch = orig; } };
}
test("advise: not_configured without API key", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
_resetForTest();
const res = await advise({ reason: "stuck" });
assert.equal(res.ok, false);
assert.equal(res.code, "not_configured");
assert.match(res.detail, /TIMEWEB_API_KEY/);
});
});
test("advise: accepts a registered skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "survive.flee", rationale: "creeper close" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m", [BASE]: "https://x/v1" }, async () => {
_resetForTest();
const res = await advise({
snapshot: { health: 10, food: 18, isDay: true, position: { x: 1, y: 64, z: 1 } },
reason: "wedged_60s",
recentSkillIds: ["explore.far", "explore.far", "explore.far"],
force: true,
});
assert.equal(res.ok, true);
assert.equal(res.action, "switch_skill");
assert.equal(res.skillId, "survive.flee");
assert.match(res.rationale, /creeper/);
// system prompt should mention the live registry
const sent = JSON.parse(f.calls[0].opts.body);
assert.match(sent.messages[0].content, /Valid skill ids/);
assert.match(sent.messages[0].content, /survive\.flee/);
assert.match(sent.messages[1].content, /wedged_60s/);
});
} finally { f.restore(); }
});
test("advise: rejects hallucinated skill id with code=hallucinated_skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "relocate.surface", rationale: "fresh spot" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "loop", force: true });
assert.equal(res.ok, false);
assert.equal(res.code, "hallucinated_skill");
assert.equal(res.detail, "relocate.surface");
});
} finally { f.restore(); }
});
test("advise: accepts 'continue' and 'wait' without skill_id", async () => {
const f = stubFetch({ action: "continue", rationale: "skill is making slow progress" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "tick", force: true });
assert.equal(res.ok, true);
assert.equal(res.action, "continue");
});
} finally { f.restore(); }
});
test("advise: rate-limit cooldown blocks rapid calls", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const r1 = await advise({ reason: "x" });
assert.equal(r1.ok, true);
const r2 = await advise({ reason: "y" });
assert.equal(r2.ok, false);
assert.equal(r2.code, "cooldown");
});
} finally { f.restore(); }
});
test("advise: hourly budget enforced with force=true override", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
for (let i = 0; i < __testing.HOURLY_BUDGET; i++) {
await advise({ reason: `t${i}`, force: true });
}
const over = await advise({ reason: "over" });
assert.equal(over.ok, false);
assert.equal(over.code, "budget_exhausted");
});
} finally { f.restore(); }
});
test("buildSystemPrompt: contains registry block and JSON schema", () => {
const sys = __testing.buildSystemPrompt();
assert.match(sys, /switch_skill/);
assert.match(sys, /Valid skill ids/);
assert.match(sys, /survive\.flee/);
});
test("buildUserPrompt: includes trigger and recent skills", () => {
const u = __testing.buildUserPrompt({
snapshot: { health: 4, food: 3, isDay: false, position: { x: 10, y: 65, z: 10 }, activeSkill: "explore.far" },
reason: "hp_plunge",
recentSkillIds: ["explore.far", "explore.far"],
lessonsTail: [{ text: "do not fight at night" }],
});
assert.match(u, /hp_plunge/);
assert.match(u, /HP: 4/);
assert.match(u, /Recent dispatches: explore\.far → explore\.far/);
assert.match(u, /do not fight at night/);
});
test("formatThreats: empty / formatted", () => {
assert.equal(__testing.formatThreats(undefined), "(none)");
assert.equal(__testing.formatThreats([]), "(none)");
assert.equal(
__testing.formatThreats([{ name: "zombie", distance: 4.3 }, { name: "creeper", distance: 7 }]),
"zombie@4m, creeper@7m",
);
});
test("isAvailable mirrors provider availability", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
assert.equal(isAvailable(), false);
});
await withEnv({ [API_KEY]: "k" }, async () => {
assert.equal(isAvailable(), true);
});
});