feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空
This commit is contained in:
+107
-1
@@ -26,7 +26,7 @@ function loadModules(userData) {
|
||||
function writeRollout(root, id, title) {
|
||||
const dir = path.join(root, "sessions", "2026", "08", "06");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "rollout-test.jsonl"), [
|
||||
fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [
|
||||
JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "/work/demo", timestamp: "2026-08-06T10:00:00.000Z" } }),
|
||||
JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: title } }),
|
||||
].join("\n") + "\n");
|
||||
@@ -59,3 +59,109 @@ test("会话数据目录切换后列表读取新目录,配置可持久化", ()
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("会话列表默认限量,避免大量历史记录一次性阻塞 IPC", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-"));
|
||||
const userData = path.join(root, "user-data");
|
||||
const dataDir = path.join(root, "many-codex");
|
||||
fs.mkdirSync(userData, { recursive: true });
|
||||
for (let i = 0; i < 520; i++) {
|
||||
const id = `${String(i).padStart(8, "0")}-1111-4111-8111-111111111111`;
|
||||
writeRollout(dataDir, id, `第 ${i} 份记录`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { dataDir: codexDir, sessions } = loadModules(userData);
|
||||
codexDir.set(dataDir);
|
||||
assert.equal(sessions.listConversations().length, 500);
|
||||
assert.equal(sessions.listConversations({ limit: 12 }).length, 12);
|
||||
assert.equal(sessions.listConversations({ limit: 9999 }).length, 520);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("subagent 同时含 id/session_id 时使用自身 id,不误绑定父线程", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-"));
|
||||
const userData = path.join(root, "user-data");
|
||||
const dataDir = path.join(root, "subagent-codex");
|
||||
const parentId = "33333333-3333-4333-8333-333333333333";
|
||||
const childId = "44444444-4444-4444-8444-444444444444";
|
||||
const dir = path.join(dataDir, "sessions", "2026", "08", "17");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, `rollout-${childId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: childId,
|
||||
session_id: parentId,
|
||||
cwd: "/work/subagent",
|
||||
timestamp: "2026-08-17T10:00:00.000Z",
|
||||
thread_source: "subagent",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "子任务" } }),
|
||||
].join("\n") + "\n");
|
||||
|
||||
try {
|
||||
const { dataDir: codexDir, sessions } = loadModules(userData);
|
||||
codexDir.set(dataDir);
|
||||
const items = sessions.listConversations({ includeSubagent: true, limit: 10 });
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].id, childId);
|
||||
const conversation = sessions.readConversation(items[0].path);
|
||||
assert.equal(conversation.meta.id, childId);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("一键清空会话会清理正文、索引、状态库和侧栏目录并保留备份", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-clear-sessions-"));
|
||||
const userData = path.join(root, "user-data");
|
||||
const dataDir = path.join(root, "codex");
|
||||
const id = "55555555-5555-4555-8555-555555555555";
|
||||
fs.mkdirSync(userData, { recursive: true });
|
||||
writeRollout(dataDir, id, "待清除会话");
|
||||
fs.mkdirSync(path.join(dataDir, "archived_sessions", "2026", "08"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, "archived_sessions", "2026", "08", `rollout-${id}.jsonl`), "archived\n");
|
||||
fs.writeFileSync(path.join(dataDir, "session_index.jsonl"), JSON.stringify({ id, thread_name: "待清除会话" }) + "\n");
|
||||
const SQL = require("sql.js");
|
||||
const init = await SQL({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) });
|
||||
const state = new init.Database();
|
||||
state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)");
|
||||
state.run("CREATE TABLE thread_artifacts (thread_id TEXT, payload TEXT)");
|
||||
state.run("INSERT INTO threads VALUES (?, ?)", [id, "待清除会话"]);
|
||||
state.run("INSERT INTO thread_artifacts VALUES (?, ?)", [id, "artifact"]);
|
||||
fs.writeFileSync(path.join(dataDir, "state_5.sqlite"), Buffer.from(state.export()));
|
||||
state.close();
|
||||
fs.mkdirSync(path.join(dataDir, "sqlite"), { recursive: true });
|
||||
const catalog = new init.Database();
|
||||
catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT, display_title TEXT)");
|
||||
catalog.run("CREATE TABLE automations (id TEXT PRIMARY KEY, name TEXT)");
|
||||
catalog.run("INSERT INTO local_thread_catalog VALUES (?, ?)", [id, "待清除会话"]);
|
||||
catalog.run("INSERT INTO automations VALUES (?, ?)", ["keep", "保留自动化"]);
|
||||
fs.writeFileSync(path.join(dataDir, "sqlite", "codex-dev.db"), Buffer.from(catalog.export()));
|
||||
catalog.close();
|
||||
|
||||
try {
|
||||
const { dataDir: codexDir, sessions } = loadModules(userData);
|
||||
codexDir.set(dataDir);
|
||||
const result = await sessions.clearAllConversations();
|
||||
assert.equal(result.filesRemoved, 2);
|
||||
assert.equal(sessions.listConversations({ includeArchived: true, includeSubagent: true }).length, 0);
|
||||
assert.equal(fs.readFileSync(path.join(dataDir, "session_index.jsonl"), "utf-8"), "");
|
||||
assert.equal(fs.existsSync(result.backup), true);
|
||||
const stateAfter = new init.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite")));
|
||||
assert.equal(stateAfter.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 0);
|
||||
assert.equal(stateAfter.exec("SELECT COUNT(*) FROM thread_artifacts")[0].values[0][0], 0);
|
||||
stateAfter.close();
|
||||
const catalogAfter = new init.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db")));
|
||||
assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 0);
|
||||
assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM automations")[0].values[0][0], 1);
|
||||
catalogAfter.close();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const codexDesktop = require("../electron/lib/codex_desktop");
|
||||
|
||||
test("native dictation cleanup moves only the bounded Codex dialog off-screen", async () => {
|
||||
let command = null;
|
||||
const result = await codexDesktop.hideGlobalDictationWindows({
|
||||
execFile(file, args, options, callback) {
|
||||
command = { file, args, options };
|
||||
callback(null, "2\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "hidden");
|
||||
assert.equal(result.hidden, 2);
|
||||
assert.equal(command.file, "osascript");
|
||||
assert.deepEqual(command.args.slice(0, 1), ["-e"]);
|
||||
assert.match(command.args[1], /titleText is "Codex"/);
|
||||
assert.match(command.args[1], /subroleText is "AXDialog"/);
|
||||
assert.match(command.args[1], /set position of w to \{-10000, -10000\}/);
|
||||
} else {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "unsupported-platform");
|
||||
assert.equal(result.hidden, 0);
|
||||
assert.equal(command, null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { parseTdToolboxImport } = require("../electron/lib/deeplink");
|
||||
|
||||
const validUrl =
|
||||
"tdtoolbox://v1/import?resource=provider&app=codex&name=Sub2API&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=gpt-5.5&source=sub2api&sourceGroupId=42";
|
||||
|
||||
test("parses a valid Sub2API provider deep link", () => {
|
||||
const result = parseTdToolboxImport(validUrl);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.input, {
|
||||
resource: "provider",
|
||||
app: "codex",
|
||||
name: "Sub2API",
|
||||
endpoint: "https://api.example.com",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-5.5",
|
||||
source: "sub2api",
|
||||
sourceGroupId: "42",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsupported schemes, endpoints, and sources", () => {
|
||||
for (const url of [
|
||||
"ccswitch://v1/import?resource=provider",
|
||||
validUrl.replace("endpoint=https%3A%2F%2Fapi.example.com", "endpoint=ftp%3A%2F%2Fapi.example.com"),
|
||||
validUrl.replace("source=sub2api", "source=unknown"),
|
||||
]) {
|
||||
const result = parseTdToolboxImport(url);
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.error);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const globalDictation = require("../electron/lib/global_dictation");
|
||||
|
||||
function fixture(state = {}) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
||||
return { home, file };
|
||||
}
|
||||
|
||||
test("disables persistent dictation bar without changing unrelated state", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
globalDictationHotkey: "LeftCommand",
|
||||
"global-dictation-keep-visible": true,
|
||||
"project-order": ["/work/a"],
|
||||
nested: { enabled: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home });
|
||||
const saved = JSON.parse(fs.readFileSync(fx.file, "utf8"));
|
||||
const backup = JSON.parse(fs.readFileSync(fx.file + ".td-before-global-dictation.bak", "utf8"));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(saved["global-dictation-keep-visible"], false);
|
||||
assert.equal(saved.globalDictationHotkey, "LeftCommand");
|
||||
assert.deepEqual(saved["project-order"], ["/work/a"]);
|
||||
assert.deepEqual(saved.nested, { enabled: true });
|
||||
assert.equal(backup["global-dictation-keep-visible"], true);
|
||||
assert.equal(fs.readdirSync(fx.home).some((name) => name.endsWith(".td-dictation.tmp")), false);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("changes only the keep-visible token instead of rewriting the state file", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
before: "保留",
|
||||
"global-dictation-keep-visible": true,
|
||||
after: { nested: 1 },
|
||||
});
|
||||
|
||||
try {
|
||||
const original = fs.readFileSync(fx.file);
|
||||
const tokenOffset = original.indexOf(Buffer.from(" true"));
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home });
|
||||
const saved = fs.readFileSync(fx.file);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(saved.subarray(0, tokenOffset).equals(original.subarray(0, tokenOffset)), true);
|
||||
assert.equal(saved.subarray(tokenOffset, tokenOffset + 5).toString("ascii"), "false");
|
||||
assert.equal(saved.subarray(tokenOffset + 5).equals(original.subarray(tokenOffset + 5)), true);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("atomically rewrites minified state while preserving unrelated state", { concurrency: false }, () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-minified-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
const original = Buffer.from('{"global-dictation-keep-visible":true,"untouched":1,"nested":{"enabled":true}}');
|
||||
fs.writeFileSync(file, original);
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: home });
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.reason, "disabled-atomic");
|
||||
assert.equal(saved["global-dictation-keep-visible"], false);
|
||||
assert.equal(saved.untouched, 1);
|
||||
assert.deepEqual(saved.nested, { enabled: true });
|
||||
assert.equal(fs.readFileSync(file).equals(original), false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("persistent lock prevents an unsafe concurrent write", { concurrency: false }, () => {
|
||||
const fx = fixture({ "global-dictation-keep-visible": true, untouched: 1 });
|
||||
const lock = fx.file + ".td-dictation.lock";
|
||||
fs.writeFileSync(lock, "busy");
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: fx.home, lockWaitMs: 0 });
|
||||
const saved = JSON.parse(fs.readFileSync(fx.file, "utf8"));
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, "locked");
|
||||
assert.equal(saved["global-dictation-keep-visible"], true);
|
||||
assert.equal(saved.untouched, 1);
|
||||
} finally {
|
||||
fs.rmSync(fx.home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("creates an explicit disabled state when the state file is absent", { concurrency: false }, () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-missing-"));
|
||||
const file = path.join(home, ".codex-global-state.json");
|
||||
|
||||
try {
|
||||
const result = globalDictation.disableKeepVisible({ codexHome: home });
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.reason, "created-disabled");
|
||||
assert.deepEqual(saved, { "global-dictation-keep-visible": false });
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("recognizes only the exact global dictation page route", () => {
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=%2Fglobal-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=global-dictation",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/avatar-overlay",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "worker",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "https://example.com/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://other/index.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isGlobalDictationTarget({
|
||||
type: "page",
|
||||
url: "app://-/other.html?initialRoute=/global-dictation",
|
||||
}), false);
|
||||
assert.equal(globalDictation.isCodexMainTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?theme=light",
|
||||
}), true);
|
||||
assert.equal(globalDictation.isCodexMainTarget({
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/avatar-overlay",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("closes only global dictation targets through CDP HTTP", async () => {
|
||||
const calls = [];
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "avatar", type: "page", url: "app://-/index.html?initialRoute=/avatar-overlay" },
|
||||
{ id: "dictation", type: "page", url: "app://-/index.html?initialRoute=%2Fglobal-dictation" },
|
||||
];
|
||||
const remainingTargets = initialTargets.filter((target) => target.id !== "dictation");
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = listCount++ === 0 ? initialTargets : remainingTargets;
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.available, true);
|
||||
assert.equal(result.matched, 1);
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, 0);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.deepEqual(calls, [
|
||||
"http://127.0.0.1:19222/json",
|
||||
"http://127.0.0.1:19222/json/close/dictation",
|
||||
"http://127.0.0.1:19222/json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("CDP unavailable is reported as unavailable instead of success", async () => {
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl: async () => {
|
||||
throw new Error("connection refused");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.available, false);
|
||||
assert.equal(result.status, "port-unavailable");
|
||||
assert.equal(result.matched, 0);
|
||||
assert.equal(result.closed, 0);
|
||||
assert.equal(result.remaining, null);
|
||||
});
|
||||
|
||||
test("does not close a matching route on a foreign debug endpoint", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
id: "foreign-dictation",
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier: async () => ({ verified: true, owned: false, reason: "other app" }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "foreign-endpoint");
|
||||
assert.equal(result.closed, 0);
|
||||
assert.deepEqual(calls, ["http://127.0.0.1:19222/json"]);
|
||||
});
|
||||
|
||||
test("closes an orphaned dictation target only when the port owner is Codex", async () => {
|
||||
const calls = [];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = listCount++ === 0
|
||||
? [{ id: "orphan", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }]
|
||||
: [];
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier: async () => ({ verified: true, owned: true, reason: "Codex" }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.deepEqual(calls, [
|
||||
"http://127.0.0.1:19222/json",
|
||||
"http://127.0.0.1:19222/json/close/orphan",
|
||||
"http://127.0.0.1:19222/json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("aborts a slow orphan owner check at the shared deadline", async () => {
|
||||
let verifierSignal;
|
||||
const fetchImpl = async (url) => {
|
||||
assert.equal(url, "http://127.0.0.1:19222/json");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [{
|
||||
id: "orphan",
|
||||
type: "page",
|
||||
url: "app://-/index.html?initialRoute=/global-dictation",
|
||||
}],
|
||||
};
|
||||
};
|
||||
const endpointOwnerVerifier = async (_port, options) => {
|
||||
verifierSignal = options.signal;
|
||||
await new Promise((resolve) => {
|
||||
if (options.signal.aborted) resolve();
|
||||
else options.signal.addEventListener("abort", resolve, { once: true });
|
||||
});
|
||||
return { verified: false, owned: false, reason: "timed out" };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
endpointOwnerVerifier,
|
||||
ownerTimeoutMs: 20,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "unverified-endpoint");
|
||||
assert.equal(verifierSignal.aborted, true);
|
||||
});
|
||||
|
||||
test("reports when keep-visible immediately recreates the target", async () => {
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const recreatedTargets = [
|
||||
initialTargets[0],
|
||||
{ id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => listCount++ === 0 ? initialTargets : recreatedTargets,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
retries: 0,
|
||||
settleMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "still-present");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, 1);
|
||||
assert.deepEqual(result.targetIds, ["dictation-b"]);
|
||||
});
|
||||
|
||||
test("retries when Codex immediately recreates the dictation target", async () => {
|
||||
const main = { id: "main", type: "page", url: "app://-/index.html" };
|
||||
const targetA = { id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" };
|
||||
const targetB = { id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" };
|
||||
const targetLists = [
|
||||
[main, targetA],
|
||||
[main, targetB],
|
||||
[main, targetB],
|
||||
[main],
|
||||
];
|
||||
let listCount = 0;
|
||||
const closed = [];
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
const targets = targetLists[Math.min(listCount, targetLists.length - 1)];
|
||||
listCount += 1;
|
||||
return { ok: true, json: async () => targets };
|
||||
}
|
||||
closed.push(url.split("/").pop());
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
settleMs: 0,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, "closed");
|
||||
assert.equal(result.closed, 2);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.deepEqual(closed, ["dictation-a", "dictation-b"]);
|
||||
});
|
||||
|
||||
test("target response body parsing cannot outlive the configured timeout", async () => {
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, {
|
||||
timeoutMs: 20,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => new Promise(() => {}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.available, false);
|
||||
assert.equal(result.status, "timeout");
|
||||
assert.equal(result.closed, 0);
|
||||
});
|
||||
|
||||
test("failed post-close verification is not reported as success", async () => {
|
||||
const initialTargets = [
|
||||
{ id: "main", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
let listCount = 0;
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.endsWith("/json")) {
|
||||
listCount += 1;
|
||||
if (listCount > 1) throw new Error("verification endpoint disconnected");
|
||||
return { ok: true, json: async () => initialTargets };
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl });
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "verification-failed");
|
||||
assert.equal(result.closed, 1);
|
||||
assert.equal(result.remaining, null);
|
||||
assert.match(result.errors.join(" "), /verification endpoint disconnected/);
|
||||
});
|
||||
|
||||
test("custom cleanup calls do not share stale targets or abort signals", async () => {
|
||||
const firstTargets = [
|
||||
{ id: "main-a", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const secondTargets = [
|
||||
{ id: "main-b", type: "page", url: "app://-/index.html" },
|
||||
{ id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" },
|
||||
];
|
||||
const closed = [];
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes("/json/close/")) {
|
||||
closed.push(url.split("/").pop());
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true, json: async () => [] };
|
||||
};
|
||||
|
||||
const first = globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
targets: firstTargets,
|
||||
});
|
||||
const second = globalDictation.closeGlobalDictationTargets(19222, {
|
||||
fetchImpl,
|
||||
targets: secondTargets,
|
||||
});
|
||||
|
||||
assert.notEqual(first, second);
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(closed.sort(), ["dictation-a", "dictation-b"]);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const hotupdate = require("../electron/lib/hotupdate");
|
||||
|
||||
test("旧壳先热更,再提示安装完整包", () => {
|
||||
const result = hotupdate.evaluate(
|
||||
{
|
||||
contentVersion: "1.5.86",
|
||||
minAppVersion: "1.5.0",
|
||||
requiresFull: false,
|
||||
requiresFullAfterHot: true,
|
||||
fullAppVersion: "1.5.86",
|
||||
},
|
||||
"1.5.22",
|
||||
"1.5.85",
|
||||
"/tmp/codex-market-hot-test",
|
||||
);
|
||||
|
||||
assert.equal(result.hasHot, true);
|
||||
assert.equal(result.canHot, true);
|
||||
assert.equal(result.needFull, false);
|
||||
assert.equal(result.needFullAfterHot, true);
|
||||
assert.equal(result.fullAppVersion, "1.5.86");
|
||||
});
|
||||
|
||||
test("完整包版本一致后不再重复提示", () => {
|
||||
const result = hotupdate.evaluate(
|
||||
{
|
||||
contentVersion: "1.5.86",
|
||||
minAppVersion: "1.5.0",
|
||||
requiresFull: false,
|
||||
requiresFullAfterHot: true,
|
||||
fullAppVersion: "1.5.86",
|
||||
},
|
||||
"1.5.86",
|
||||
"1.5.86",
|
||||
"/tmp/codex-market-hot-test",
|
||||
);
|
||||
|
||||
assert.equal(result.hasHot, false);
|
||||
assert.equal(result.needFullAfterHot, false);
|
||||
});
|
||||
+374
-6
@@ -264,6 +264,77 @@ test("small explicit context windows are honored instead of silently ignored", {
|
||||
}
|
||||
});
|
||||
|
||||
test("GPT groups expose only GPT text models and hide stale mixed-provider inventory", { concurrency: false }, async () => {
|
||||
const fx = fixture({
|
||||
activeId: "gpt",
|
||||
configToml: [
|
||||
'model_catalog_json = "cc-switch-model-catalog.json"',
|
||||
'model_provider = "td-direct"',
|
||||
'model = "gpt-5.6-sol"',
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
"",
|
||||
"[model_providers.td-direct]",
|
||||
'name = "GPT relay"',
|
||||
'base_url = "https://proxy.example.test/v1"',
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
"[model_providers.local-router]",
|
||||
'name = "GPT relay"',
|
||||
'base_url = "https://proxy.example.test/v1"',
|
||||
'wire_api = "responses"',
|
||||
"",
|
||||
].join("\n"),
|
||||
groups: [{
|
||||
id: "gpt",
|
||||
name: "GPT relay",
|
||||
baseUrl: "https://proxy.example.test",
|
||||
key: "gpt-key",
|
||||
model: "gpt-5.5",
|
||||
mode: "responses",
|
||||
}],
|
||||
});
|
||||
const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json");
|
||||
fs.writeFileSync(catalogFile, JSON.stringify({
|
||||
models: [
|
||||
{ slug: "gpt-5.5", context_window: 128000, visibility: "list" },
|
||||
{ slug: "gpt-5.6-sol", context_window: 128000, visibility: "list" },
|
||||
{ slug: "deepseek-v4-pro", context_window: 128000, visibility: "list" },
|
||||
{ slug: "qwen3.8-max-preview", context_window: 128000, visibility: "list" },
|
||||
{ slug: "claude-opus-4-8", context_window: 128000, visibility: "list" },
|
||||
{ slug: "gpt-image-2", context_window: 128000, visibility: "list" },
|
||||
{ slug: "seedance2.0", context_window: 128000, visibility: "list" },
|
||||
],
|
||||
}));
|
||||
const previousFetch = global.fetch;
|
||||
global.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.5" },
|
||||
{ id: "gpt-5.6-sol" },
|
||||
{ id: "gpt-5.4-mini" },
|
||||
{ id: "deepseek-v4-pro" },
|
||||
{ id: "qwen3.8-max-preview" },
|
||||
{ id: "claude-opus-4-8" },
|
||||
{ id: "gpt-image-2" },
|
||||
{ id: "seedance2.0" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const result = await router.syncCatalogFromProvider(router.llmGroup());
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.hidden, 5);
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogFile, "utf-8"));
|
||||
const visible = catalog.models.filter((model) => model.visibility === "list").map((model) => model.slug).sort();
|
||||
assert.deepEqual(visible, ["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol"]);
|
||||
} finally {
|
||||
global.fetch = previousFetch;
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("running router writes the newly selected model immediately", { concurrency: false }, async () => {
|
||||
const probe = http.createServer();
|
||||
const port = await listen(probe);
|
||||
@@ -361,9 +432,11 @@ test("stopped router writes the selected group directly to Codex immediately", {
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^model_provider = "td-direct"$/m);
|
||||
assert.match(configToml, /^model = "gpt-model-b"$/m);
|
||||
assert.match(configToml, /^model_reasoning_effort = "high"$/m);
|
||||
assert.match(configToml, /^disable_response_storage = true$/m);
|
||||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||||
assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m);
|
||||
assert.doesNotMatch(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
assert.match(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
|
||||
const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"));
|
||||
assert.equal(auth.OPENAI_API_KEY, "key-b");
|
||||
@@ -377,6 +450,267 @@ test("stopped router writes the selected group directly to Codex immediately", {
|
||||
}
|
||||
});
|
||||
|
||||
test("status repairs an empty Codex config when router auth is still active", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "model-a",
|
||||
configToml: " \n",
|
||||
auth: {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "rk-test-router-key",
|
||||
tokens: { access_token: "official-access-token" },
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: "model-a",
|
||||
name: "Model A",
|
||||
baseUrl: "https://proxy.example.test",
|
||||
key: "key-a",
|
||||
model: "gpt-model-a",
|
||||
mode: "responses",
|
||||
},
|
||||
],
|
||||
});
|
||||
const previousFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("offline test");
|
||||
};
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const status = router.status();
|
||||
assert.equal(status.repair.repaired, true);
|
||||
assert.equal(status.repair.mode, "proxy");
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^model_provider = "local-router"$/m);
|
||||
assert.match(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||||
assert.match(configToml, /^base_url = "http:\/\/127\.0\.0\.1:8787"$/m);
|
||||
assert.match(configToml, /^model = "gpt-model-a"$/m);
|
||||
} finally {
|
||||
global.fetch = previousFetch;
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status repairs a managed provider that is missing its provider block", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "model-a",
|
||||
configToml: [
|
||||
'model_provider = "local-router"',
|
||||
'model = "gpt-model-a"',
|
||||
"",
|
||||
].join("\n"),
|
||||
auth: {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "rk-test-router-key",
|
||||
tokens: { access_token: "official-access-token" },
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: "model-a",
|
||||
name: "Model A",
|
||||
baseUrl: "https://proxy.example.test",
|
||||
key: "key-a",
|
||||
model: "gpt-model-a",
|
||||
mode: "responses",
|
||||
},
|
||||
],
|
||||
});
|
||||
const previousFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("offline test");
|
||||
};
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const status = router.status();
|
||||
assert.equal(status.repair.repaired, true);
|
||||
assert.equal(status.repair.mode, "proxy");
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^model_provider = "local-router"$/m);
|
||||
assert.match(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||||
assert.match(configToml, /^wire_api = "responses"$/m);
|
||||
} finally {
|
||||
global.fetch = previousFetch;
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status adds a missing compatibility provider without resetting the current model", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "model-b",
|
||||
configToml: [
|
||||
'model_provider = "td-direct"',
|
||||
'model = "gpt-5.6-sol"',
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
"",
|
||||
"[model_providers.td-direct]",
|
||||
'name = "Model B"',
|
||||
'base_url = "https://proxy.example.test/v1"',
|
||||
'wire_api = "responses"',
|
||||
"requires_openai_auth = true",
|
||||
"",
|
||||
].join("\n"),
|
||||
auth: {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "key-b",
|
||||
tokens: { access_token: "official-access-token" },
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: "model-b",
|
||||
name: "Model B",
|
||||
baseUrl: "https://proxy.example.test",
|
||||
key: "key-b",
|
||||
model: "gpt-model-b",
|
||||
mode: "responses",
|
||||
},
|
||||
],
|
||||
});
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const status = router.status();
|
||||
assert.equal(status.repair.repaired, true);
|
||||
assert.equal(status.repair.mode, "direct");
|
||||
assert.equal(status.repair.preservedModel, "gpt-5.6-sol");
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^model_provider = "td-direct"$/m);
|
||||
assert.match(configToml, /^model = "gpt-5.6-sol"$/m);
|
||||
assert.match(configToml, /^model_reasoning_effort = "xhigh"$/m);
|
||||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||||
assert.match(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
assert.equal((configToml.match(/^model\s*=/gm) || []).length, 1);
|
||||
} finally {
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("status preserves the legacy default provider for historical sessions after switching relay", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
officialAuth: { OPENAI_API_KEY: "official-key" },
|
||||
activeId: "model-b",
|
||||
configToml: [
|
||||
'model_provider = "td-direct"',
|
||||
'model = "gpt-5.6-sol"',
|
||||
"",
|
||||
"[model_providers.td-direct]",
|
||||
'name = "Model B"',
|
||||
'base_url = "https://proxy.example.test/v1"',
|
||||
'wire_api = "responses"',
|
||||
"requires_openai_auth = true",
|
||||
"",
|
||||
].join("\n"),
|
||||
auth: { OPENAI_API_KEY: "key-b" },
|
||||
groups: [{ id: "model-b", name: "Model B", baseUrl: "https://proxy.example.test", key: "key-b", model: "gpt-model-b", mode: "responses" }],
|
||||
});
|
||||
const routerFile = path.join(fx.userData, "router.json");
|
||||
const routerConfig = JSON.parse(fs.readFileSync(routerFile, "utf-8"));
|
||||
routerConfig.groups[0].modelProvider = "custom";
|
||||
fs.writeFileSync(routerFile, JSON.stringify(routerConfig, null, 2));
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const result = router.setActive("model-b");
|
||||
assert.equal(result._mode, "direct");
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^\[model_providers\.custom\]$/m);
|
||||
assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m);
|
||||
assert.match(configToml, /^wire_api = "responses"$/m);
|
||||
} finally {
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status repairs an empty Codex config when direct provider auth is still active", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "model-b",
|
||||
configToml: " \n",
|
||||
auth: {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: "key-b",
|
||||
tokens: { access_token: "official-access-token" },
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
id: "model-b",
|
||||
name: "Model B",
|
||||
baseUrl: "https://proxy.example.test",
|
||||
key: "key-b",
|
||||
model: "gpt-model-b",
|
||||
mode: "responses",
|
||||
},
|
||||
],
|
||||
});
|
||||
const previousFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("offline test");
|
||||
};
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const status = router.status();
|
||||
assert.equal(status.repair.repaired, true);
|
||||
assert.equal(status.repair.mode, "direct");
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^model_provider = "td-direct"$/m);
|
||||
assert.match(configToml, /^model = "gpt-model-b"$/m);
|
||||
assert.match(configToml, /^model_reasoning_effort = "high"$/m);
|
||||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||||
assert.match(configToml, /^\[model_providers\.local-router\]$/m);
|
||||
assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m);
|
||||
} finally {
|
||||
global.fetch = previousFetch;
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Sub2API provider import is idempotent and does not touch Codex config", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "default",
|
||||
groups: [],
|
||||
});
|
||||
const router = loadRouter(fx);
|
||||
const beforeConfig = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
const beforeAuth = fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8");
|
||||
try {
|
||||
const first = router.importSub2apiProvider({
|
||||
name: "Sub2API · Codex",
|
||||
endpoint: "https://proxy.example.test",
|
||||
apiKey: "sk-first",
|
||||
model: "gpt-5.5",
|
||||
sourceGroupId: "group-42",
|
||||
});
|
||||
assert.equal(first._imported, true);
|
||||
assert.equal(first._updated, false);
|
||||
assert.equal(first.activeId, "default");
|
||||
assert.equal(first.groups.filter((g) => g.source === "sub2api").length, 1);
|
||||
|
||||
const second = router.importSub2apiProvider({
|
||||
name: "Sub2API · Codex Pro",
|
||||
endpoint: "https://proxy.example.test/v1",
|
||||
apiKey: "sk-second",
|
||||
model: "gpt-5.6",
|
||||
sourceGroupId: "group-42",
|
||||
});
|
||||
assert.equal(second._updated, true);
|
||||
assert.equal(second.activeId, "default");
|
||||
assert.equal(second.groups.filter((g) => g.source === "sub2api").length, 1);
|
||||
|
||||
const imported = second.groups.find((g) => g.source === "sub2api");
|
||||
assert.equal(imported.name, "Sub2API · Codex Pro");
|
||||
assert.equal(imported.baseUrl, "https://proxy.example.test/v1");
|
||||
assert.equal(imported.model, "gpt-5.6");
|
||||
assert.equal(imported.mode, "responses");
|
||||
assert.equal(imported.sourceGroupId, "group-42");
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8"));
|
||||
assert.equal(persisted.groups.filter((g) => g.source === "sub2api").length, 1);
|
||||
assert.equal(persisted.groups[1].key, b64("sk-second"));
|
||||
assert.equal(fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"), beforeConfig);
|
||||
assert.equal(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"), beforeAuth);
|
||||
} finally {
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("direct switch retries a transient Windows-style auth.json lock", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: "default",
|
||||
@@ -791,11 +1125,14 @@ test("large chunked requests are forwarded with one content-length and no client
|
||||
const chunks = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
observed = {
|
||||
headers: req.headers,
|
||||
rawHeaders: req.rawHeaders,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
if (/\/responses\b/.test(req.url || "")) {
|
||||
observed = {
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
rawHeaders: req.rawHeaders,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
@@ -875,6 +1212,7 @@ test("large chunked requests are forwarded with one content-length and no client
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body, '{"ok":true}');
|
||||
assert.ok(observed);
|
||||
assert.equal(observed.url, "/v1/responses");
|
||||
assert.equal(observed.body.length, payload.length);
|
||||
assert.deepEqual(JSON.parse(observed.body.toString("utf-8")), JSON.parse(payload.toString("utf-8")));
|
||||
assert.equal(observed.headers["content-length"], String(payload.length));
|
||||
@@ -895,6 +1233,36 @@ test("large chunked requests are forwarded with one content-length and no client
|
||||
}
|
||||
});
|
||||
|
||||
test("responses direct switch upgrades a site root base_url for Codex", { concurrency: false }, () => {
|
||||
const fx = fixture({
|
||||
activeId: null,
|
||||
auth: {},
|
||||
groups: [
|
||||
{
|
||||
id: "first-relay",
|
||||
name: "First \\ Relay",
|
||||
baseUrl: "https://proxy.example.test/",
|
||||
key: "first-key",
|
||||
model: "gpt-first",
|
||||
mode: "responses",
|
||||
},
|
||||
],
|
||||
});
|
||||
const router = loadRouter(fx);
|
||||
try {
|
||||
const result = router.setActive("first-relay");
|
||||
assert.equal(result._mode, "direct");
|
||||
assert.equal(result._needsRestart, true);
|
||||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||||
assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m);
|
||||
assert.match(configToml, /^name = "First \\\\ Relay"$/m);
|
||||
const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"));
|
||||
assert.equal(auth.OPENAI_API_KEY, "first-key");
|
||||
} finally {
|
||||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("chat multimodal group injects MiniMax evidence before forwarding to DeepSeek", { concurrency: false }, async () => {
|
||||
let upstreamBody;
|
||||
const upstream = http.createServer((req, res) => {
|
||||
|
||||
@@ -41,6 +41,26 @@ function writeSubagentRollout(root, id, parentId) {
|
||||
].join("\n") + "\n");
|
||||
}
|
||||
|
||||
async function writeLegacyPollutedState(root, id, subId) {
|
||||
const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) });
|
||||
const state = new SQL.Database();
|
||||
state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, source TEXT, thread_source TEXT, has_user_event INTEGER)");
|
||||
state.run("INSERT INTO threads VALUES (?,?,?,?)", [id, "vscode", "user", 1]);
|
||||
state.run("INSERT INTO threads VALUES (?,?,?,?)", [subId, "[object Object]", "subagent", 1]);
|
||||
fs.writeFileSync(path.join(root, "state_5.sqlite"), Buffer.from(state.export()));
|
||||
state.close();
|
||||
|
||||
fs.mkdirSync(path.join(root, "sqlite"), { recursive: true });
|
||||
const catalog = new SQL.Database();
|
||||
catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT PRIMARY KEY, thread_source TEXT)");
|
||||
catalog.run("CREATE TABLE local_thread_catalog_metadata (id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL DEFAULT 0)");
|
||||
catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [id, "user"]);
|
||||
catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [subId, "subagent"]);
|
||||
catalog.run("INSERT INTO local_thread_catalog_metadata VALUES (1,0)");
|
||||
fs.writeFileSync(path.join(root, "sqlite", "codex-dev.db"), Buffer.from(catalog.export()));
|
||||
catalog.close();
|
||||
}
|
||||
|
||||
test("会话恢复在没有 sqlite3 时插入线程并创建侧栏目录", { concurrency: false }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-"));
|
||||
const dataDir = path.join(root, "codex");
|
||||
@@ -146,6 +166,7 @@ test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且
|
||||
const subSource = state.exec(`SELECT source FROM threads WHERE id='${subId}'`)[0].values[0][0];
|
||||
assert.ok(subSource.startsWith('{"subagent"'), `source 应为 JSON 字符串,实际: ${subSource}`);
|
||||
assert.ok(subSource.includes("Dirac"));
|
||||
assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0);
|
||||
state.close();
|
||||
|
||||
// 自建 codex-dev.db 必须带 user_version(对齐 Codex 迁移链 v30),
|
||||
@@ -153,7 +174,61 @@ test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且
|
||||
const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db")));
|
||||
const uv = catalog.exec("PRAGMA user_version")[0].values[0][0];
|
||||
assert.equal(uv, 30);
|
||||
assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 2);
|
||||
// 子任务仍进 state 库,但不再作为 Codex 顶层侧栏任务展示;
|
||||
// 否则大量 subagent 会话会污染侧栏,点击派生线程容易一直转圈。
|
||||
assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1);
|
||||
catalog.close();
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
childProcess.spawnSync = originalSpawnSync;
|
||||
global.__BUNDLE_ROOT__ = originalBundleRoot;
|
||||
delete require.cache[PROVIDER_MODULE];
|
||||
delete require.cache[DESKTOP_MODULE];
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("启动轻量迁移清理旧 subagent 顶层目录污染", { concurrency: false }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-repair-"));
|
||||
const dataDir = path.join(root, "codex");
|
||||
const userData = path.join(root, "app-data");
|
||||
const id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
||||
const subId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
const originalLoad = Module._load;
|
||||
const originalSpawnSync = childProcess.spawnSync;
|
||||
const originalBundleRoot = global.__BUNDLE_ROOT__;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(userData, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
await writeLegacyPollutedState(dataDir, id, subId);
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === "electron") return { app: { getPath: () => userData } };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
childProcess.spawnSync = function (command, args, options) {
|
||||
if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" };
|
||||
return originalSpawnSync.call(this, command, args, options);
|
||||
};
|
||||
delete require.cache[PROVIDER_MODULE];
|
||||
delete require.cache[DESKTOP_MODULE];
|
||||
global.__BUNDLE_ROOT__ = path.resolve(__dirname, "..");
|
||||
const provider = require(PROVIDER_MODULE);
|
||||
const desktop = require(DESKTOP_MODULE);
|
||||
desktop.codexRunning = () => false;
|
||||
provider.setCodexDataDir(dataDir);
|
||||
|
||||
const result = await provider.repairLegacySessionVisibility(() => {});
|
||||
assert.equal(result.stateRows, 1);
|
||||
assert.equal(result.catalogRows, 1);
|
||||
|
||||
const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) });
|
||||
const state = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite")));
|
||||
assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0);
|
||||
state.close();
|
||||
const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db")));
|
||||
assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1);
|
||||
assert.equal(catalog.exec(`SELECT COUNT(*) FROM local_thread_catalog WHERE thread_id='${subId}'`)[0].values[0][0], 0);
|
||||
catalog.close();
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
|
||||
Reference in New Issue
Block a user