Roots out the v0.2.x failure mode: Pi-extracted lessons routinely named
hallucinated skill ids (relocate.surface, choose.safe.surface,
survive.shelter, gather.visible_log, …). All 47 Pi-lessons in the live DB
had applied_count=0 because normalisePreferSkill couldn't find them.
Fix:
1. runtime/skill-registry.js — single source of truth derived from
skills/index.js. Exports listSkillIds, isRegistered, and a
prompt-ready block (skillRegistryPrompt) grouped by namespace.
2. Pi prompts (coach/postmortem, coach/reflect) embed the live registry
with a "USE ONLY THESE, never invent" instruction. Lessons are
filtered at write-time too — anything not in the registry and not a
known mode name gets dropped.
3. coach/advice.js — normalisePreferSkill now returns null for unknown
ids, hardening consult() against any hallucinations that slip
through. Warn-logged for visibility.
Also lays the LLM substrate for the rest of v0.3.0:
- runtime/llm/provider.js — OpenAI-compatible chat client. Configured
via PEPA_FAST_LLM_{BASE_URL,API_KEY,MODEL,TIMEOUT_MS}. Safe no-op
unless API_KEY is set. Supports JSON-mode.
- runtime/coach/fast-advisor.js — tactical advisor tier (scaffold).
Exposes advise() that asks the fast LLM what to do RIGHT NOW when
the reflex is wedged/stuck. Rejects hallucinated skill ids using the
registry. Rate-limited 6/h, 30s cooldown. Not auto-triggered yet —
wired into reflex in rc.3 (awareness layer).
Tests: 279 green (+24 vs rc.3): 5 registry, 9 provider, 10 advisor.
See dev/v0.3.0/PLAN.md for the full iteration design (manifesto needs
ladder, event-driven awareness, skill pre-emption) and STATUS.md for
shipped/pending tracking.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
165 lines
5.7 KiB
JavaScript
165 lines
5.7 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { isAvailable, getConfig, complete, __testing } from "./provider.js";
|
|
|
|
const { tryParseJson, ENV } = __testing;
|
|
|
|
test("isAvailable: false when no API key in env", () => {
|
|
const prev = process.env[ENV.API_KEY];
|
|
delete process.env[ENV.API_KEY];
|
|
try {
|
|
assert.equal(isAvailable(), false);
|
|
} finally {
|
|
if (prev !== undefined) process.env[ENV.API_KEY] = prev;
|
|
}
|
|
});
|
|
|
|
test("isAvailable: true when API key set", () => {
|
|
const prev = process.env[ENV.API_KEY];
|
|
process.env[ENV.API_KEY] = "test-key";
|
|
try {
|
|
assert.equal(isAvailable(), true);
|
|
} finally {
|
|
if (prev === undefined) delete process.env[ENV.API_KEY];
|
|
else process.env[ENV.API_KEY] = prev;
|
|
}
|
|
});
|
|
|
|
test("getConfig: reflects env overrides and strips trailing slash", () => {
|
|
const prev = {
|
|
base: process.env[ENV.BASE_URL],
|
|
key: process.env[ENV.API_KEY],
|
|
model: process.env[ENV.MODEL],
|
|
};
|
|
process.env[ENV.BASE_URL] = "https://api.example.com/v1/";
|
|
process.env[ENV.API_KEY] = "abc";
|
|
process.env[ENV.MODEL] = "gpt-fast";
|
|
try {
|
|
const cfg = getConfig();
|
|
assert.equal(cfg.baseUrl, "https://api.example.com/v1");
|
|
assert.equal(cfg.apiKey, "abc");
|
|
assert.equal(cfg.model, "gpt-fast");
|
|
assert.ok(cfg.timeoutMs > 0);
|
|
} finally {
|
|
for (const [k, v] of [[ENV.BASE_URL, prev.base], [ENV.API_KEY, prev.key], [ENV.MODEL, prev.model]]) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("complete: not_configured when no API key", async () => {
|
|
const prev = process.env[ENV.API_KEY];
|
|
delete process.env[ENV.API_KEY];
|
|
try {
|
|
const res = await complete({ system: "hi", user: "hi" });
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.code, "not_configured");
|
|
} finally {
|
|
if (prev !== undefined) process.env[ENV.API_KEY] = prev;
|
|
}
|
|
});
|
|
|
|
test("complete: no_model when key is set but model isn't", async () => {
|
|
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
|
process.env[ENV.API_KEY] = "x";
|
|
delete process.env[ENV.MODEL];
|
|
try {
|
|
const res = await complete({ system: "s", user: "u" });
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.code, "no_model");
|
|
} finally {
|
|
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
|
else process.env[ENV.API_KEY] = prev.key;
|
|
if (prev.model !== undefined) process.env[ENV.MODEL] = prev.model;
|
|
}
|
|
});
|
|
|
|
test("tryParseJson: parses naked, fenced, and embedded JSON", () => {
|
|
assert.deepEqual(tryParseJson('{"a":1}'), { a: 1 });
|
|
assert.deepEqual(tryParseJson('```json\n{"a":2}\n```'), { a: 2 });
|
|
assert.deepEqual(tryParseJson('prose before {"a":3} prose after'), { a: 3 });
|
|
assert.equal(tryParseJson("nope"), null);
|
|
assert.equal(tryParseJson(""), null);
|
|
});
|
|
|
|
test("complete: real fetch path uses Bearer header and POSTs JSON", async () => {
|
|
// Stub global fetch to capture the request.
|
|
const calls = [];
|
|
const stub = async (url, opts) => {
|
|
calls.push({ url, opts });
|
|
return {
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: JSON.stringify({ verdict: "loop", action: "wander" }) } }],
|
|
}),
|
|
};
|
|
};
|
|
const origFetch = globalThis.fetch;
|
|
globalThis.fetch = stub;
|
|
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL], base: process.env[ENV.BASE_URL] };
|
|
process.env[ENV.API_KEY] = "secret-123";
|
|
process.env[ENV.MODEL] = "gpt-fast";
|
|
process.env[ENV.BASE_URL] = "https://example/v1";
|
|
try {
|
|
const res = await complete({ system: "be terse", user: "what now?", json: true });
|
|
assert.equal(res.ok, true);
|
|
assert.deepEqual(res.text, { verdict: "loop", action: "wander" });
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].url, "https://example/v1/chat/completions");
|
|
assert.equal(calls[0].opts.method, "POST");
|
|
assert.equal(calls[0].opts.headers["Authorization"], "Bearer secret-123");
|
|
const sent = JSON.parse(calls[0].opts.body);
|
|
assert.equal(sent.model, "gpt-fast");
|
|
assert.equal(sent.messages[0].role, "system");
|
|
assert.equal(sent.messages[1].role, "user");
|
|
assert.equal(sent.response_format.type, "json_object");
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
for (const [k, v] of [[ENV.API_KEY, prev.key], [ENV.MODEL, prev.model], [ENV.BASE_URL, prev.base]]) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("complete: network error surfaces as code=network_error", async () => {
|
|
const origFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => { throw new Error("boom"); };
|
|
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
|
process.env[ENV.API_KEY] = "x";
|
|
process.env[ENV.MODEL] = "m";
|
|
try {
|
|
const res = await complete({ system: "s", user: "u" });
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.code, "network_error");
|
|
assert.match(res.detail, /boom/);
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
|
else process.env[ENV.API_KEY] = prev.key;
|
|
if (prev.model === undefined) delete process.env[ENV.MODEL];
|
|
else process.env[ENV.MODEL] = prev.model;
|
|
}
|
|
});
|
|
|
|
test("complete: http error surfaces as http_<status>", async () => {
|
|
const origFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => ({ ok: false, status: 401, text: async () => "bad key" });
|
|
const prev = { key: process.env[ENV.API_KEY], model: process.env[ENV.MODEL] };
|
|
process.env[ENV.API_KEY] = "x";
|
|
process.env[ENV.MODEL] = "m";
|
|
try {
|
|
const res = await complete({ system: "s", user: "u" });
|
|
assert.equal(res.ok, false);
|
|
assert.equal(res.code, "http_401");
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
if (prev.key === undefined) delete process.env[ENV.API_KEY];
|
|
else process.env[ENV.API_KEY] = prev.key;
|
|
if (prev.model === undefined) delete process.env[ENV.MODEL];
|
|
else process.env[ENV.MODEL] = prev.model;
|
|
}
|
|
});
|