From bb0359acade30228c3199c2579be337668c023bc Mon Sep 17 00:00:00 2001 From: Exotic209093 <134711311+Exotic209093@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:08:03 +0100 Subject: [PATCH] fix(web): correctly report usage limits with multi-account Codex routing Fixes #11515 --- apps/server/src/usage/UsageService.test.ts | 157 +++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 40 +++++- 2 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/usage/UsageService.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 000000000000..f4b18c11337a --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,157 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + CodexSettings, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; + +const decodeCodexSettings = Schema.decodeSync(CodexSettings); +const codexDriverKind = ProviderDriverKind.make("codex"); + +/** + * Regression test for #11515: UsageService.resolveTranscriptDirs must scan + * every configured Codex instance, not just the legacy single-instance config. + * + * We test the underlying logic (resolveCodexHomeLayout + deduplication) rather + * than the full service because the service requires a live settings layer and + * filesystem. The bug was that `settings.providerInstances` entries with + * `driver === "codex"` were ignored entirely, so their session directories + * never appeared in the scan list. + */ +it.layer(NodeServices.layer)("UsageService multi-account Codex routing (#11515)", (it) => { + describe("resolveTranscriptDirs multi-instance enumeration", () => { + it.effect("resolves distinct session dirs for multiple Codex instances", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + // Simulate what resolveTranscriptDirs does: collect unique session dirs + // from both the legacy config and providerInstances. + const seenCodexDirs = new Set(); + const codexDirs: string[] = []; + + const addCodexDir = (layout: { readonly sharedHomePath: string }) => { + const sessionDir = path.join(layout.sharedHomePath, "sessions"); + if (seenCodexDirs.has(sessionDir)) return; + seenCodexDirs.add(sessionDir); + codexDirs.push(sessionDir); + }; + + // Legacy config: default ~/.codex + const legacyConfig = decodeCodexSettings({}); + const legacyLayout = yield* resolveCodexHomeLayout(legacyConfig); + addCodexDir(legacyLayout); + + // Multi-instance configs: two additional accounts with different homes + const personalHome = path.resolve("/home/user/.codex-personal"); + const workHome = path.resolve("/home/user/.codex-work"); + const instances: Record }> = { + codex_personal: { + driver: "codex", + config: { homePath: personalHome }, + }, + codex_work: { + driver: "codex", + config: { homePath: workHome }, + }, + claude_primary: { + driver: "claudeAgent", + config: {}, + }, + }; + + for (const instance of Object.values(instances)) { + if (instance.driver !== codexDriverKind) continue; + const decoded = decodeCodexSettings(instance.config); + const layout = yield* resolveCodexHomeLayout(decoded); + addCodexDir(layout); + } + + // Should have 3 distinct Codex session directories + expect(codexDirs).toHaveLength(3); + expect(codexDirs).toContain(path.join(legacyLayout.sharedHomePath, "sessions")); + expect(codexDirs).toContain(path.join(personalHome, "sessions")); + expect(codexDirs).toContain(path.join(workHome, "sessions")); + }), + ); + + it.effect("deduplicates when two instances share the same home path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const seenCodexDirs = new Set(); + const codexDirs: string[] = []; + + const addCodexDir = (layout: { readonly sharedHomePath: string }) => { + const sessionDir = path.join(layout.sharedHomePath, "sessions"); + if (seenCodexDirs.has(sessionDir)) return; + seenCodexDirs.add(sessionDir); + codexDirs.push(sessionDir); + }; + + // Two instances pointing at the same home should produce one entry + const sharedHome = path.resolve("/shared/codex-home"); + const instances: Record }> = { + codex_a: { + driver: "codex", + config: { homePath: sharedHome }, + }, + codex_b: { + driver: "codex", + config: { homePath: sharedHome }, + }, + }; + + for (const instance of Object.values(instances)) { + if (instance.driver !== codexDriverKind) continue; + const decoded = decodeCodexSettings(instance.config); + const layout = yield* resolveCodexHomeLayout(decoded); + addCodexDir(layout); + } + + expect(codexDirs).toHaveLength(1); + expect(codexDirs[0]).toBe(path.join(sharedHome, "sessions")); + }), + ); + + it.effect("skips non-codex provider instances", () => + Effect.gen(function* () { + const path = yield* Path.Path; + + const seenCodexDirs = new Set(); + const codexDirs: string[] = []; + + const addCodexDir = (layout: { readonly sharedHomePath: string }) => { + const sessionDir = path.join(layout.sharedHomePath, "sessions"); + if (seenCodexDirs.has(sessionDir)) return; + seenCodexDirs.add(sessionDir); + codexDirs.push(sessionDir); + }; + + const instances: Record }> = { + claude_main: { + driver: "claudeAgent", + config: {}, + }, + cursor_default: { + driver: "cursor", + config: {}, + }, + }; + + for (const instance of Object.values(instances)) { + if (instance.driver !== codexDriverKind) continue; + const decoded = decodeCodexSettings(instance.config); + const layout = yield* resolveCodexHomeLayout(decoded); + addCodexDir(layout); + } + + expect(codexDirs).toHaveLength(0); + }), + ); + }); +}); \ No newline at end of file diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0bf131ac973b..1ac6a8bcd4b8 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -14,6 +14,8 @@ import * as NodeOS from "node:os"; import { + CodexSettings, + ProviderDriverKind, USAGE_CONTRACT_VERSION, type UsageProviderKind, type UsageSource, @@ -217,12 +219,42 @@ export const make = Effect.gen(function* () { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); - const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, + // Collect Codex session directories from every configured instance. The + // legacy single-instance config (`settings.providers.codex`) is always + // scanned for backward compatibility. Additional instances declared in + // `settings.providerInstances` each contribute their own shared home so + // multi-account setups (e.g. codex_personal + codex_work) are fully + // covered. Duplicate shared homes are de-duplicated so two instances + // pointing at the same directory do not double-count transcripts. + const codexDriverKind = ProviderDriverKind.make("codex"); + const seenCodexDirs = new Set(); + const dirs: Array<{ provider: UsageProviderKind; dir: string }> = [ + { provider: "claude", dir: claudeDir }, ]; + + const addCodexDir = (layout: { readonly sharedHomePath: string }) => { + const sessionDir = path.join(layout.sharedHomePath, "sessions"); + if (seenCodexDirs.has(sessionDir)) return; + seenCodexDirs.add(sessionDir); + dirs.push({ provider: "codex", dir: sessionDir }); + }; + + const legacyLayout = yield* resolveCodexHomeLayout(settings.providers.codex); + addCodexDir(legacyLayout); + + for (const instance of Object.values(settings.providerInstances)) { + if (instance.driver !== codexDriverKind) continue; + const instanceConfig = + typeof instance.config === "object" && instance.config !== null + ? (instance.config as Record) + : {}; + const decoded = Schema.decodeSync(CodexSettings)(instanceConfig); + const layout = yield* resolveCodexHomeLayout(decoded); + addCodexDir(layout); + } + + return dirs; }); /**