feat(agent): unify models and validate OpenCode providers

Generate chat, task and learning models from agent-models.json.
Use WaterNetwork fast/expert models with 272k context and validate
upstream names before readiness. Add an isolated model check command
with signal-safe cleanup and plugin, Skill and MCP management docs.

Verified in isolation: type checks, 26 focused tests and both model
catalog checks; no model inference requests.
This commit is contained in:
2026-10-09 11:28:37 +08:00
parent 75d8d526f9
commit d58bd19039
30 changed files with 1125 additions and 469 deletions
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test";
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
const alive = (pid: number) => {
try { process.kill(pid, 0); return true; } catch { return false; }
};
describe("model checker cancellation", () => {
for (const signal of ["SIGTERM", "SIGINT"] as const) {
it(`cleans its own OpenCode process and workspace when receiving ${signal} during startup`, async () => {
const root = await mkdtemp(join(tmpdir(), "model-check-command-test-"));
const statePath = join(root, "state.json");
const command = join(root, "opencode-test");
let state: { pid: number; xdg: string } | undefined;
let checker: ReturnType<typeof spawn> | undefined;
try {
await writeFile(command, `#!${process.execPath}\nimport { writeFileSync } from "node:fs";\nwriteFileSync(process.env.MODEL_CHECK_TEST_STATE, JSON.stringify({ pid: process.pid, xdg: process.env.XDG_DATA_HOME }));\nsetInterval(() => {}, 1000);\n`, { mode: 0o700 });
checker = spawn(process.execPath, ["scripts/check-models.ts"], {
cwd: resolve("."), env: { ...process.env, OPENCODE_COMMAND: command, MODEL_CHECK_TEST_STATE: statePath },
stdio: ["ignore", "ignore", "ignore"],
});
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
checker!.once("error", reject);
checker!.once("close", (code, signal) => resolve({ code, signal }));
});
const deadline = Date.now() + 5000;
while (!state && Date.now() < deadline) {
try { state = JSON.parse(await readFile(statePath, "utf8")); } catch { await Bun.sleep(10); }
}
expect(state).toBeDefined();
checker.kill(signal);
const result = await Promise.race([exited, Bun.sleep(5000).then(() => { throw new Error("checker did not finish cancellation"); })]);
expect(alive(state!.pid)).toBe(false);
await expect(stat(dirname(state!.xdg))).rejects.toThrow();
expect(result).toEqual({ code: signal === "SIGTERM" ? 143 : 130, signal: null });
} finally {
checker?.kill("SIGKILL");
if (state && alive(state.pid)) process.kill(state.pid, "SIGKILL");
if (state) await rm(dirname(state.xdg), { recursive: true, force: true });
await rm(root, { recursive: true, force: true });
}
}, 10_000);
}
});
+19 -2
View File
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { modelCheckTargets } from "../../src/runtime/model-check.js";
import {
OpencodeRuntimeAdapter,
resolveEmbeddedOpencodePort,
@@ -141,7 +142,7 @@ describe("resolveEmbeddedOpencodePort", () => {
describe("OpencodeRuntimeAdapter.warmup", () => {
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
async function createWarmupRuntime(missing: string[] = []) {
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string) {
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
config.CONVERSATION_WORKSPACE_ROOT = root;
@@ -155,6 +156,14 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
},
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
rpc: { call: async (input: { method: string }) => {
if (input.method === "check_models") {
calls.push("model.check");
const targets = modelCheckTargets(config.MODEL_CONFIG).models;
return { output: {
models: targets.map((model) => ({ id: model.id, model_id: model.wire_id, status: model.id === invalidModel ? "not_advertised" : "valid" })),
providers: [...new Set(targets.map((model) => model.provider_id))].map((id) => ({ id, status: "checked" })),
} };
}
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
calls.push("tool.list");
return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) };
@@ -166,13 +175,21 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime();
try { await runtime.warmup();
expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.list", "session.remove"]); } finally { await cleanup(); }
expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
});
it("fails readiness if a business tool was lost and still cleans up", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
});
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
try {
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised");
expect(calls).not.toContain("model.list");
expect(calls.at(-1)).toBe("session.remove");
} finally { await cleanup(); }
});
});
describe("conversation workspace binding", () => {