diff --git a/src/commands/status.js b/src/commands/status.js index 8d73de765..103fa9c04 100644 --- a/src/commands/status.js +++ b/src/commands/status.js @@ -531,10 +531,10 @@ async function cmdStatus(argv = []) { platform: process.platform, }); const qoderCnActive = formatResolvedPaths(qoderCnPaths); - // CN new-JSONL mirrors sync.js: CN and international currently share - // ~/.qoder/projects, and sync only parses the CN dir when it diverges from - // the international one (same files must not count under two sources). Only - // report CN JSONL when the dirs diverge. + // CN new-JSONL mirrors sync.js: the new CN app writes ~/.qoder-cn/projects, + // but sync only parses the CN dir when it diverges from the international + // one (same files must not count under two sources). Only report CN JSONL + // when the dirs diverge. const qoderCnProjectsDirResolved = resolveQoderCnProjectsDir({ home, env: process.env }); const qoderCnSharesIntlDir = path.normalize(qoderCnProjectsDirResolved) === path.normalize(qoderProjectsDirResolved) || (process.platform === "win32" diff --git a/src/commands/sync.js b/src/commands/sync.js index a93a10afa..1c7c29c22 100644 --- a/src/commands/sync.js +++ b/src/commands/sync.js @@ -1357,9 +1357,11 @@ async function cmdSync(argv, context = {}) { } } - // Qoder CN new (JSONL) — currently shares ~/.qoder/projects with international; - // keep distinct parsing only when CN projects dir diverges (future CN split) - // to avoid double-counting the same JSONL under two sources. + // Qoder CN new (JSONL) — the new CN app (com.qodercn.app.stable) writes + // ~/.qoder-cn/projects, a sibling of the international ~/.qoder/projects. + // Keep the divergence guard: if a user (or a future app build) points both + // resolvers at the same directory, the same JSONL files must not count + // under two sources. if (sourceAllowed("qoder-cn")) { try { const cnProjectsDir = resolveQoderCnProjectsDir({ home, env: process.env }); diff --git a/src/lib/rollout.js b/src/lib/rollout.js index 57ab7e00d..b7a788883 100644 --- a/src/lib/rollout.js +++ b/src/lib/rollout.js @@ -5841,17 +5841,21 @@ function resolveQoderCnProjectsDir({ home = os.homedir(), env = process.env, pla ? path.resolve(env.QODER_CN_PROJECTS_DIR.trim()) : null; if (override) return override; - // CN and international currently share ~/.qoder; keep a distinct override for - // future split without double-counting by default. + // The new CN app (com.qodercn.app.stable, 2026-08+) keeps its sessions in + // ~/.qoder-cn/projects — a sibling of the international ~/.qoder, not a + // shared directory. Pointing CN at ~/.qoder/projects made the "CN dir + // diverges from international" guards in sync.js/status.js always false, + // so new-version CN JSONL usage was silently never parsed (and on + // international-only installs would have double-counted under qoder-cn). if (platform === "win32" && !env.QODER_CN_PROJECTS_DIR) { const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome; - const wslRoot = wsl.shouldProbeWsl(env) ? discoverWslHome(".qoder", { ...deps, env }) : null; + const wslRoot = wsl.shouldProbeWsl(env) ? discoverWslHome(".qoder-cn", { ...deps, env }) : null; if (wslRoot) { const wslProjects = path.join(wslRoot, "projects"); if ((deps.existsSync || fssync.existsSync)(wslProjects)) return wslProjects; } } - return path.join(home, ".qoder", "projects"); + return path.join(home, ".qoder-cn", "projects"); } async function listQoderNewSessionFiles(projectsDir) { @@ -5876,7 +5880,15 @@ async function listQoderNewSessionFiles(projectsDir) { function qoderNewModelFromRecord(record) { const msgModel = record?.message?.model; const direct = typeof msgModel === "string" ? msgModel.trim() : ""; - return normalizeModelInput(direct) || "qoder-agent"; + // CN BYOK routes embed an install-local provider UUID in the model id + // ("qoder-custom-/glm-5.3-flash"). Keep the bare model id so bucket + // keys stay stable across reinstalls and don't fragment per user; official + // ids (e.g. "qmodel_38max") have no prefix and pass through unchanged. + const stripped = direct.replace( + /^qoder-custom-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\//i, + "", + ); + return normalizeModelInput(stripped) || "qoder-agent"; } function qoderNewMessageKey(record, filePath, lineIndex = 0) { diff --git a/test/qoder-new-parser.test.js b/test/qoder-new-parser.test.js index 9103fbb0f..7d0a91a0a 100644 --- a/test/qoder-new-parser.test.js +++ b/test/qoder-new-parser.test.js @@ -276,6 +276,128 @@ test("resolveQoderProjectsDir windows case-insensitive guard", () => { assert.notEqual(intl, cn); }); +test("resolveQoderCnProjectsDir defaults to ~/.qoder-cn/projects, not the international dir", () => { + // The new CN app (com.qodercn.app.stable) writes ~/.qoder-cn/projects — a + // sibling of ~/.qoder, never a shared directory. Defaulting CN onto + // ~/.qoder/projects made the "CN diverges from international" guards in + // sync.js/status.js always false, so CN JSONL was silently never parsed. + const cn = resolveQoderCnProjectsDir({ home: "/Users/test", env: {}, platform: "darwin" }); + const intl = resolveQoderProjectsDir({ home: "/Users/test", env: {}, platform: "darwin" }); + assert.equal(cn, path.join("/Users/test", ".qoder-cn", "projects")); + assert.equal(intl, path.join("/Users/test", ".qoder", "projects")); + // The sync.js/status.js divergence guard depends on the defaults differing. + assert.notEqual(path.normalize(cn), path.normalize(intl)); +}); + +test("resolveQoderCnProjectsDir honors QODER_CN_PROJECTS_DIR and ignores QODER/QODER_PROJECTS_DIR", () => { + // QODER_HOME / QODER_PROJECTS_DIR belong to the international install and + // must never redirect the CN resolver onto it (that would double-count the + // same JSONL under both sources). + assert.equal( + resolveQoderCnProjectsDir({ + home: "/Users/test", + env: { QODER_HOME: "/intl-home", QODER_PROJECTS_DIR: "/intl-projects" }, + platform: "darwin", + }), + path.join("/Users/test", ".qoder-cn", "projects"), + ); + assert.equal( + resolveQoderCnProjectsDir({ + home: "/Users/test", + env: { QODER_CN_PROJECTS_DIR: "/cn-sessions/projects" }, + platform: "darwin", + }), + "/cn-sessions/projects", + ); +}); + +test("parseQoderNewIncremental tracks new Qoder CN usage with BYOK model ids and streamed duplicates", async (t) => { + const tmp = tempDir(); + const { dir, queuePath } = tempQueue(); + t.after(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + // Real CN record shape (com.qodercn.app.stable, app 0.1.8 / agent 1.1.44): + // Anthropic-style precise usage on the main transcript and on subagent + // transcripts, BYOK model ids embedding an install-local provider UUID, + // streamed rewrites of the same message id (usage identical), and 'auto' + // streaming rows without usage. + const usage = (input, cacheRead, cacheCreation, output) => ({ + input_tokens: input, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, + output_tokens: output, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + request_id: "b93bd9a7-760f-4838-9641-13762bf33ae4", + }); + const rec = (msgId, model, u, ts) => ({ + type: "assistant", + timestamp: ts, + uuid: `row-${msgId}`, + sessionId: "cn-sess-1", + cwd: "/Users/test/proj", + message: { id: msgId, role: "assistant", model, content: [{ type: "text", text: "ok" }], usage: u }, + }); + const byokModel = "qoder-custom-e60342d2-d1f7-4203-8fe4-7d0d29bbfaee/glm-5.3-flash"; + const mainFile = path.join(tmp, "proj-slug", "cn-sess-1.jsonl"); + writeJsonl(mainFile, [ + rec("m1", byokModel, usage(27_566, 4_928, 0, 1_963), "2026-09-06T02:03:35.000Z"), + // Streamed rewrite of the same message id: must not double count. + rec("m1", byokModel, usage(27_566, 4_928, 0, 1_963), "2026-09-06T02:03:36.000Z"), + rec("m2", byokModel, usage(30_000, 1_000, 200, 400), "2026-09-06T02:10:00.000Z"), + // Streaming 'auto' rows carry no usage — skipped entirely. + rec("m3", "auto", null, "2026-09-06T02:11:00.000Z"), + ]); + const subagentFile = path.join(tmp, "proj-slug", "cn-sess-1", "subagents", "agent-aExplore-1.jsonl"); + writeJsonl(subagentFile, [ + rec("sub-1", byokModel, usage(11_347, 0, 0, 108), "2026-09-06T02:05:00.000Z"), + ]); + const cursors = {}; + const first = await parseQoderNewIncremental({ + sessionFiles: [mainFile, subagentFile], + cursors, + queuePath, + sourceKey: "qoder-cn", + cursorKey: "qoderCnNew", + }); + assert.equal(first.messagesProcessed, 3, "three distinct message ids (m1, m2, sub-1)"); + assert.equal(first.eventsAggregated, 3); + assert.ok(first.bucketsQueued >= 1); + + const cnRows = queueRows(queuePath).filter((row) => row.source === "qoder-cn" && row.model === "glm-5.3-flash"); + assert.equal(cnRows.length, 1, "one half-hour bucket per (model, bucket)"); + assert.equal( + cnRows[0].input_tokens, + 27_566 + 30_000 + 11_347, + "input_tokens excludes cache reads (Anthropic semantics)", + ); + assert.equal(cnRows[0].cached_input_tokens, 4_928 + 1_000 + 0); + assert.equal(cnRows[0].cache_creation_input_tokens, 200); + assert.equal(cnRows[0].output_tokens, 1_963 + 400 + 108); + assert.equal(cnRows[0].total_tokens, 27_566 + 4_928 + 1_963 + 30_000 + 1_200 + 400 + 11_347 + 108); + assert.equal(cnRows[0].usage_precision, undefined, "precise CN usage must not be marked as a credits estimate"); + + // The BYOK UUID must be stripped from bucket keys so model rows don't + // fragment per install, but the raw id stays in the cursor for reference. + assert.equal(cursors.qoderCnNew.messages["jsonl:cn-sess-1|m1"]?.model, "glm-5.3-flash"); + + // Second sync over the same files is a no-op. + const before = fs.readFileSync(queuePath, "utf8"); + const second = await parseQoderNewIncremental({ + sessionFiles: [mainFile, subagentFile], + cursors, + queuePath, + sourceKey: "qoder-cn", + cursorKey: "qoderCnNew", + }); + assert.equal(second.eventsAggregated, 0); + assert.equal(second.bucketsQueued, 0); + assert.equal(fs.readFileSync(queuePath, "utf8"), before); +}); + + test("parseQoderNewIncremental uses isolated cursor namespace", async (t) => { const tmp = tempDir(); const { dir, queuePath } = tempQueue();