feat: zcode thinking-mode 0.3.0(state历史+rollback+分写+校验收紧+34用例全绿+文档)

This commit is contained in:
2026-09-21 09:47:20 +08:00
commit 6ed5dda847
41 changed files with 6213 additions and 0 deletions
+632
View File
@@ -0,0 +1,632 @@
/**
* zcode-thinking-mode 测试:档位读取 / 切换 / 备份 / 边界与失败路径。
*
* 用 ZCODE_V2_CONFIG + ZCODE_SESSION_DB 把两个写入目标都重定向到临时目录,
* 全程不碰真实 ~/.zcode 与真实模型配置。
*
* 跑法: node --test tests/
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
const SCRIPT = path.resolve(import.meta.dirname, "..", "scripts", "thinking.mjs");
/* ---------- 沙箱 ---------- */
function sandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ztm-"));
const cfg = path.join(root, "v2", "config.json");
const db = path.join(root, "db.sqlite");
fs.mkdirSync(path.dirname(cfg), { recursive: true });
return { root, cfg, db, defaults: path.join(root, "thinking-defaults.json"), state: path.join(root, "thinking-state.json") };
}
/** 一份典型模型配置:两个 provider,三个带 reasoning 的模型 + 一个不带的 */
function configFixture() {
return {
provider: {
"builtin:bigmodel": {
name: "BigModel",
kind: "builtin",
enabled: true,
models: {
"GLM-5.3": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } },
"GLM-5.3-Flash": { reasoning: { enabled: true, variants: ["low", "max", "high"], defaultVariant: "max" } },
"qwen3-flash": {},
},
},
"custom-a": {
name: "CustomA",
kind: "custom",
models: {
"deepseek-v4": { reasoning: { enabled: true, variants: ["low", "high"], defaultVariant: "low" } },
},
},
},
};
}
function writeConfig(sb, doc) {
fs.mkdirSync(path.dirname(sb.cfg), { recursive: true });
fs.writeFileSync(sb.cfg, JSON.stringify(doc, null, 2));
}
function readConfig(sb) { return JSON.parse(fs.readFileSync(sb.cfg, "utf8")); }
/** 造 session 库;level 为 null 时建表但不插 reasoningLevel 行 */
function makeDb(dbPath, level = null) {
const db = new DatabaseSync(dbPath);
db.exec(`CREATE TABLE local_setting (
scope TEXT NOT NULL, scope_id TEXT NOT NULL, namespace TEXT NOT NULL, key TEXT NOT NULL,
value TEXT NOT NULL, schema_version INTEGER, time_created INTEGER, time_updated INTEGER,
PRIMARY KEY (scope, scope_id, namespace, key))`);
if (level !== null) {
const now = Date.now();
db.prepare("INSERT INTO local_setting VALUES ('user','default','model','reasoningLevel',?,1,?,?)")
.run(JSON.stringify({ level }), now, now);
}
db.close();
}
/** 只建一个无关表:模拟"这不是有效的 session 库" */
function makeAlienDb(dbPath) {
const db = new DatabaseSync(dbPath);
db.exec("CREATE TABLE whatever (id INTEGER)");
db.close();
}
function readHot(dbPath) {
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const row = db.prepare(
"SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'"
).get();
return row ? JSON.parse(row.value).level : null;
} finally { db.close(); }
}
function backups(sb) {
const dir = path.dirname(sb.cfg);
return fs.readdirSync(dir).filter((f) => f.startsWith(path.basename(sb.cfg) + ".bak-"));
}
function run(sb, args, env = {}) {
const r = spawnSync(process.execPath, [SCRIPT, ...args], {
env: {
...process.env,
ZCODE_V2_CONFIG: sb.cfg,
ZCODE_SESSION_DB: sb.db,
// 必须重定向:否则脚本会读真实 ~/.zcode/zcode-thinking-mode.json,测试就不隔离了
ZCODE_THINKING_CONFIG: sb.defaults,
ZCODE_THINKING_STATE: sb.state,
...env,
},
encoding: "utf8",
timeout: 60000,
});
return { code: r.status, out: r.stdout ?? "", err: r.stderr ?? "" };
}
/* ---------- status / variants ---------- */
test("status:列出带 reasoning 的模型与可选档位,并标出无 reasoning 的", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "high");
const r = run(sb, ["status"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /## 思考模式现状/);
assert.match(r.out, /全局热档位:`high`/, "应显示库里读到的热档位");
assert.match(r.out, /\*\*GLM-5\.3\*\* \(provider `builtin:bigmodel`\):持久默认 `max`,可选 `low \/ max \/ high`/);
assert.match(r.out, /\*\*deepseek-v4\*\* \(provider `custom-a`\):持久默认 `low`,可选 `low \/ high`/);
assert.match(r.out, /qwen3-flash.*无 reasoning 配置/, "不支持的模型也要列出并标注");
assert.match(r.out, /\/thinking:set high/, "应给出切换示例");
});
test("status --json:结构化输出,含热档位与每个模型的档位信息", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["status", "--json"]);
assert.equal(r.code, 0, r.err);
const j = JSON.parse(r.out);
assert.equal(j.hotLevel, "max");
assert.equal(j.v2config, sb.cfg);
assert.equal(j.sessionDb, sb.db);
const glm = j.models.find((m) => m.model === "GLM-5.3");
assert.deepEqual(glm.reasoning.variants, ["low", "max", "high"]);
assert.equal(glm.reasoning.defaultVariant, "max");
const qwen = j.models.find((m) => m.model === "qwen3-flash");
assert.equal(qwen.reasoning, null);
});
test("status:库里没有 reasonleLevel 行时如实说明,而不是假装读到档位", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, null);
const r = run(sb, ["status"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /全局热档位:读失败\(库里没有 reasoningLevel 行/);
const j = JSON.parse(run(sb, ["status", "--json"]).out);
assert.equal(j.hotLevel, null);
assert.match(String(j.hotError), /没有 reasoningLevel 行/);
});
test("variants:只列带 reasoning 的模型,不带的不出现", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const r = run(sb, ["variants"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /## 可选思考档位/);
assert.match(r.out, /GLM-5\.3/);
assert.match(r.out, /deepseek-v4/);
assert.doesNotMatch(r.out, /qwen3-flash/, "无 reasoning 的模型不该出现在可选档位表里");
const j = JSON.parse(run(sb, ["variants", "--json"]).out);
assert.equal(j.hotLevel, "low");
assert.deepEqual(j.models.map((m) => m.model).sort(), ["GLM-5.3", "GLM-5.3-Flash", "deepseek-v4"]);
});
test("status --model:名称不对时给明确提示,而不是静默空表", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["status", "--model", "不存在的模型"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /没找到模型\(或 --model 名称不对\)/);
});
/* ---------- set:正常路径 ---------- */
test("set high:热档位与持久默认同时改,并留备份", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const r = run(sb, ["set", "high"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /## 思考模式已切换\(热 \+ 持久\)/);
assert.match(r.out, /全局热档位:low → `high`/);
assert.match(r.out, /\/effort high/, "要保留当前会话热切入口提示");
assert.equal(readHot(sb.db), "high", "热档位库应被写成 high");
const doc = readConfig(sb);
for (const [pid, name] of [["builtin:bigmodel", "GLM-5.3"], ["builtin:bigmodel", "GLM-5.3-Flash"], ["custom-a", "deepseek-v4"]]) {
assert.equal(doc.provider[pid].models[name].reasoning.defaultVariant, "high", `${name} 持久默认应改成 high`);
}
assert.equal(backups(sb).length, 1, "改写模型配置前必须留一份备份");
});
test("set:本来就是目标档位 → 不改写配置、不产生多余备份", () => {
const sb = sandbox();
const doc = configFixture();
for (const p of Object.values(doc.provider)) for (const m of Object.values(p.models)) {
if (m.reasoning) m.reasoning.defaultVariant = "high";
}
writeConfig(sb, doc);
makeDb(sb.db, "low");
const r = run(sb, ["set", "high"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /模型配置持久默认:本来就是目标档位,未改写/);
assert.equal(backups(sb).length, 0, "没有改动就不该留备份");
assert.equal(readHot(sb.db), "high", "热档位仍应更新(它本来就是 low)");
});
test("set --model:只改指定模型,其它模型纹丝不动", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["set", "low", "--model", "GLM-5.3-Flash"]);
assert.equal(r.code, 0, r.err);
const doc = readConfig(sb);
assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "low");
assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "同 provider 的另一个模型不该被动");
assert.equal(doc.provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant, "low", "别的 provider 不该被动");
// --model 只影响持久默认;热档位是全局一格,仍按请求的档位写
assert.equal(readHot(sb.db), "low");
});
test("set:HIGH 大小写归一成模型自带写法,而不是写回大写", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["set", "HIGH"]);
assert.equal(r.code, 0, r.err);
assert.equal(readHot(sb.db), "high");
assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high");
});
test("set --json:给出旧档位与改动清单,便于脚本化与回滚", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
// fixture 里 deepseek-v4 本来就是 low,切 low 时它不该出现在改动清单里
const r = run(sb, ["set", "low", "--json"]);
assert.equal(r.code, 0, r.err);
const j = JSON.parse(r.out);
assert.equal(j.hotPrevious, "max");
assert.equal(j.hotLevel, "low");
assert.equal(j.changed.length, 2, "只有真正变了的模型才该进 changed");
assert.ok(j.changed.every((c) => c.defaultVariant === "low"));
assert.ok(!j.changed.some((c) => c.model === "deepseek-v4"), "原本就是 low 的模型不算改动");
assert.ok(j.backup && fs.existsSync(j.backup), "应给出真实存在的备份路径");
});
/* ---------- set:失败路径(关键:失败时不许留下半截改动) ---------- */
test("set:模型不支持该档位 → 拒绝,且配置与热档位都不动", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const before = fs.readFileSync(sb.cfg, "utf8");
// deepseek-v4 只支持 low/high,没有 max —— 收紧校验先拦下(非法档位,两边都不写)
const r = run(sb, ["set", "max", "--model", "deepseek-v4"]);
assert.equal(r.code, 1);
assert.match(r.out, /非法档位 max\(全部目标的可选:low\/high\)/);
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "校验失败不得改写配置");
assert.equal(readHot(sb.db), "low", "校验失败不得改热档位");
assert.equal(backups(sb).length, 0);
});
test("set:多个目标里有一个不支持 → 整批中止,不做部分切换", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const before = fs.readFileSync(sb.cfg, "utf8");
// 不带 --model:GLM 支持 max,deepseek-v4 不支持 —— 不能出现"改了一半"
const r = run(sb, ["set", "max"]);
assert.equal(r.code, 1);
assert.match(r.out, /模型 deepseek-v4 不支持档位 max/);
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "整批中止时配置必须原样");
assert.equal(readHot(sb.db), "low", "整批中止时热档位必须原样");
});
test("set:模型名不存在 → 明确报错并退出 1", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["set", "high", "--model", "压根没有"]);
assert.equal(r.code, 1);
assert.match(r.out, /没找到带 reasoning 的模型:压根没有/);
assert.equal(readHot(sb.db), "max");
});
test("session 库不是有效 session 库 → 热档位写失败,模型配置不动", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeAlienDb(sb.db);
const before = fs.readFileSync(sb.cfg, "utf8");
const r = run(sb, ["set", "high"]);
assert.equal(r.code, 1);
assert.match(r.out, /写热档位库失败/);
assert.match(r.out, /模型配置未动/, "报错要明确告诉用户配置没被改");
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before);
assert.equal(backups(sb).length, 0);
});
test("缺模型配置 → 退出 1,并指出配置路径", () => {
const sb = sandbox();
makeDb(sb.db, "max");
// 不写 config.json
for (const args of [["status"], ["variants"], ["set", "high"]]) {
const r = run(sb, args);
assert.equal(r.code, 1, `${args.join(" ")} 应失败`);
assert.match(r.out, /思考模式切换失败:读模型配置失败/);
assert.match(r.out, new RegExp(sb.cfg.replace(/\\/g, "\\\\")), "应指出配置路径");
}
});
test("set:缺档位参数 / 档位被当成选项 → 用法报错,不误改", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const before = fs.readFileSync(sb.cfg, "utf8");
for (const args of [["set"], ["set", "--json"], ["set", "--model", "GLM-5.3"]]) {
const r = run(sb, args);
assert.equal(r.code, 1, `${args.join(" ")} 应报用法错误`);
assert.match(r.out, /用法: node thinking\.mjs set/);
}
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before);
assert.equal(readHot(sb.db), "max");
});
test("set:覆盖本机已有热档位后,旧值可从输出读回(回滚入口)", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
// 用 high:fixture 里三个带 reasoning 的模型都支持(deepseek-v4 只有 low/high)
const up = run(sb, ["set", "high"]);
assert.equal(up.code, 0, `${up.out}\n${up.err}`);
assert.match(up.out, /全局热档位:low → `high`/);
const down = run(sb, ["set", "low"]);
assert.equal(down.code, 0, `${down.out}\n${down.err}`);
assert.match(down.out, /全局热档位:high → `low`/, "第二次切换应报出上一次的值");
assert.equal(readHot(sb.db), "low");
});
test("set:反复切换不留垃圾,备份按时间戳递增", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
assert.equal(run(sb, ["set", "high"]).code, 0);
assert.equal(run(sb, ["set", "low"]).code, 0);
assert.equal(run(sb, ["set", "high"]).code, 0);
const bks = backups(sb);
assert.ok(bks.length >= 2, `每次改写配置都该留备份,实际 ${bks.length} 个(同一秒内连切也不许撞名覆盖)`);
for (const b of bks) assert.match(b, /\.bak-\d{8}-\d{6}(\.\d+)?$/, `备份名应带时间戳:${b}`);
// 备份内容应是改写前的原文,可据此回滚
assert.equal(JSON.parse(fs.readFileSync(path.join(path.dirname(sb.cfg), bks[0]), "utf8")).provider["custom-a"].models["deepseek-v4"].reasoning.defaultVariant !== undefined, true);
});
/* ---------- 默认值文件(userConfig 的落地形态) ---------- */
test("defaults:未设时如实报未设,并说明优先级", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["defaults"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /默认档位:`\(未设\)`/);
assert.match(r.out, /默认模型:`\(全部\)`/);
assert.match(r.out, /尚未创建/);
assert.match(r.out, /命令行参数 > 环境变量.*> 默认值文件 > 内置兜底/);
const j = JSON.parse(run(sb, ["defaults", "--json"]).out);
assert.equal(j.exists, false);
assert.equal(j.effective.variant, null);
assert.equal(j.effective.model, null);
});
test("defaults set:写入后可读回,且默认模型会作用于 status/set", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const w = run(sb, ["defaults", "set", "--variant", "high", "--model", "GLM-5.3-Flash"]);
assert.equal(w.code, 0, w.err);
assert.match(w.out, /## 默认值已写入/);
assert.ok(fs.existsSync(sb.defaults), "应真的落盘");
assert.deepEqual(JSON.parse(fs.readFileSync(sb.defaults, "utf8")), { variant: "high", model: "GLM-5.3-Flash" });
// set 不带档位 → 用默认档位;不带 --model → 用默认模型
const r = run(sb, ["set"]);
assert.equal(r.code, 0, `${r.out}\n${r.err}`);
assert.match(r.out, /档位来自默认值.*`high`/);
assert.match(r.out, /作用模型来自默认值.*`GLM-5\.3-Flash`/);
const doc = readConfig(sb);
assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3-Flash"].reasoning.defaultVariant, "high");
assert.equal(doc.provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max", "默认模型之外的模型不该被动");
assert.equal(readHot(sb.db), "high");
});
test("defaults:环境变量优先于文件,命令行又优先于环境变量", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "low", model: "GLM-5.3" }));
const envRun = run(sb, ["set"], { ZCODE_THINKING_VARIANT: "high" });
assert.equal(envRun.code, 0, `${envRun.out}\n${envRun.err}`);
assert.match(envRun.out, /档位来自默认值\(环境变量\):`high`/);
assert.equal(readHot(sb.db), "high");
const cliRun = run(sb, ["set", "low"], { ZCODE_THINKING_VARIANT: "high" });
assert.equal(cliRun.code, 0, `${cliRun.out}\n${cliRun.err}`);
assert.doesNotMatch(cliRun.out, /档位来自默认值/, "命令行给了档位就不该再提默认值");
assert.equal(readHot(sb.db), "low");
});
test("defaults clear:删除文件后回到内置兜底", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
fs.writeFileSync(sb.defaults, JSON.stringify({ variant: "high" }));
const c = run(sb, ["defaults", "clear"]);
assert.equal(c.code, 0, c.err);
assert.match(c.out, /默认值已清空/);
assert.equal(fs.existsSync(sb.defaults), false, "文件应被删掉");
// 兜底:没档位可用必须明确报错,不能猜一个档位就改配置
const before = fs.readFileSync(sb.cfg, "utf8");
const r = run(sb, ["set"]);
assert.equal(r.code, 1);
assert.match(r.out, /没给档位,默认值文件里也没设/);
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before);
});
test("defaults set:不给任何键时报用法错,不写空文件", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["defaults", "set"]);
assert.equal(r.code, 1);
assert.match(r.out, /用法: node thinking\.mjs defaults set/);
assert.equal(fs.existsSync(sb.defaults), false, "参数不合法不该留下文件");
});
test("defaults:文件坏了(非法 JSON)按未设处理,不崩", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
fs.writeFileSync(sb.defaults, "{ 这不是 json");
const r = run(sb, ["defaults"]);
assert.equal(r.code, 0, r.err);
assert.match(r.out, /默认档位:`\(未设\)`/);
});
/* ---------- doctor ---------- */
test("doctor:环境就绪时无 fail", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "max");
const r = run(sb, ["doctor"]);
assert.equal(r.code, 0, `${r.out}\n${r.err}`);
assert.match(r.out, /# zcode-thinking-mode 自检/);
assert.match(r.out, /✓ 可切换模型:3 个带 reasoning/);
assert.match(r.out, /✓ 配置可写/);
assert.doesNotMatch(r.out, /✗/, "就绪环境不该有 fail 项");
const j = JSON.parse(run(sb, ["doctor", "--json"]).out);
assert.equal(j.fails, 0);
assert.equal(j.v2config, sb.cfg);
assert.equal(j.defaultsFile, sb.defaults);
});
test("doctor:缺模型配置 → fail 并给修复建议,退出码 1", () => {
const sb = sandbox();
makeDb(sb.db, "max");
const r = run(sb, ["doctor"]);
assert.equal(r.code, 1);
assert.match(r.out, /✗ 模型配置:读不到/);
assert.match(r.out, /ZCODE_V2_CONFIG/);
assert.match(r.out, /结论:有 .* 项必须先修/);
});
test("doctor:热档位库缺 local_setting 表 → fail", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeAlienDb(sb.db);
const r = run(sb, ["doctor"]);
assert.equal(r.code, 1);
assert.match(r.out, /✗ 热档位库结构:缺 local_setting 表/);
});
test("doctor:没有模型声明 reasoning → warn 但不算失败", () => {
const sb = sandbox();
writeConfig(sb, { provider: { p1: { models: { "plain-model": {} } } } });
makeDb(sb.db, "max");
const r = run(sb, ["doctor"]);
assert.equal(r.code, 0, `只有 warn 时不该失败\n${r.out}`);
assert.match(r.out, /⚠ 可切换模型:0 个带 reasoning/);
assert.match(r.out, /没有任何模型声明 reasoning\.variants/);
});
/* ---------- 0.3.0:分写 / 历史 / 回滚 / 校验收紧 ---------- */
function readState(sb) {
if (!fs.existsSync(sb.state)) return [];
return JSON.parse(fs.readFileSync(sb.state, "utf8")).entries ?? [];
}
test("set --hot-only:只写热档位,模型配置不动但仍记历史", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const before = fs.readFileSync(sb.cfg, "utf8");
const r = run(sb, ["set", "high", "--hot-only"]);
assert.equal(r.code, 0, `${r.out}\n${r.err}`);
assert.match(r.out, /仅热/);
assert.equal(readHot(sb.db), "high");
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before, "仅热时配置必须原样");
assert.equal(backups(sb).length, 0);
const st = readState(sb);
assert.equal(st.length, 1);
assert.equal(st[0].hotOnly, true);
assert.equal(st[0].hotLevel, "high");
});
test("set --persist-only:只写模型配置,热档位不动", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const r = run(sb, ["set", "high", "--persist-only"]);
assert.equal(r.code, 0, `${r.out}\n${r.err}`);
assert.match(r.out, /仅持久/);
assert.equal(readHot(sb.db), "low", "仅持久时热档位必须原样");
assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "high");
const st = readState(sb);
assert.equal(st[0].persistOnly, true);
});
test("set:两个分写开关同时用 → 用法报错,两边都不动", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const before = fs.readFileSync(sb.cfg, "utf8");
const r = run(sb, ["set", "high", "--hot-only", "--persist-only"]);
assert.equal(r.code, 1);
assert.match(r.out, /不能同时用/);
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before);
assert.equal(readHot(sb.db), "low");
});
test("set:非法档位 ultra → 收紧校验拒绝,两边都不写", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const before = fs.readFileSync(sb.cfg, "utf8");
const r = run(sb, ["set", "ultra"]);
assert.equal(r.code, 1);
assert.match(r.out, /非法档位 ultra/);
assert.equal(fs.readFileSync(sb.cfg, "utf8"), before);
assert.equal(readHot(sb.db), "low");
assert.equal(backups(sb).length, 0);
});
test("history:列出切换记录;rollback:热+持久都写回去", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
assert.equal(run(sb, ["set", "high"]).code, 0);
const h = run(sb, ["history"]);
assert.equal(h.code, 0, h.err);
assert.match(h.out, /## 思考模式切换历史/);
assert.match(h.out, /low → `high`/);
const hj = JSON.parse(run(sb, ["history", "--json"]).out);
assert.equal(hj.count, 1);
assert.equal(hj.entries[0].hotLevel, "high");
const rb = run(sb, ["rollback"]);
assert.equal(rb.code, 0, `${rb.out}\n${rb.err}`);
assert.match(rb.out, /已回滚/);
assert.equal(readHot(sb.db), "low", "热档位应回到 low");
assert.equal(readConfig(sb).provider["builtin:bigmodel"].models["GLM-5.3"].reasoning.defaultVariant, "max",
"持久默认应回到 fixture 的 max");
});
test("rollback:无历史时明确报错退出 1", () => {
const sb = sandbox();
writeConfig(sb, configFixture());
makeDb(sb.db, "low");
const r = run(sb, ["rollback"]);
assert.equal(r.code, 1);
assert.match(r.out, /没有切换历史可回滚/);
});