Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/commands/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions src/commands/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
22 changes: 17 additions & 5 deletions src/lib/rollout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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-<uuid>/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) {
Expand Down
122 changes: 122 additions & 0 deletions test/qoder-new-parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down