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
490 changes: 489 additions & 1 deletion apps/desktop/src/main/services/chat/agentChatService.test.ts

Large diffs are not rendered by default.

313 changes: 249 additions & 64 deletions apps/desktop/src/main/services/chat/agentChatService.ts

Large diffs are not rendered by default.

283 changes: 234 additions & 49 deletions apps/desktop/src/main/services/usage/ledgers/localUsageLedgers.ts

Large diffs are not rendered by default.

284 changes: 284 additions & 0 deletions apps/desktop/src/main/services/usage/usageTrackingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { createUsageTrackingService, _testing } from "./usageTrackingService";

const {
aggregateCosts,
bucketDaily7d,
localDayKey,
makeDailySkeleton,
dateIntersectsRange,
Expand Down Expand Up @@ -72,6 +73,7 @@ const {
scanDroidLogs,
scanCopilotLogs,
scanGeminiLogs,
findRecentFiles,
} = _testing;

// ── Helpers ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -326,6 +328,53 @@ describe("aggregateCosts", () => {
expect(result.todayCostUsd).toBe(0);
});

it("keeps lifetime-only reconciliation out of recent and daily buckets", () => {
const now = Date.now();
const result = aggregateCosts([
{
messageId: "current:1",
model: "gpt-5.5",
inputTokens: 10,
outputTokens: 0,
cachedTokens: 0,
timestamp: now,
},
{
messageId: "codex-state:lifetime-total-remainder",
model: "codex",
lifetimeOnly: true as const,
inputTokens: 50,
outputTokens: 0,
cachedTokens: 0,
timestamp: 0,
costOverrideUsd: 0,
},
], "codex");

expect(result.tokenBreakdownByPreset?.all?.codex?.input).toBe(50);
expect(result.tokenBreakdownByPreset?.today?.codex).toBeUndefined();
expect(Object.values(result.dailyTokensByPreset?.all ?? {}).reduce((sum, value) => sum + value, 0)).toBe(10);
expect(bucketDaily7d([
{
messageId: "current:1",
model: "gpt-5.5",
inputTokens: 10,
outputTokens: 0,
cachedTokens: 0,
timestamp: now,
},
{
messageId: "codex-state:lifetime-total-remainder",
model: "codex",
lifetimeOnly: true,
inputTokens: 50,
outputTokens: 0,
cachedTokens: 0,
timestamp: 0,
},
], now).reduce((sum, value) => sum + value, 0)).toBe(10);
});

it("separates today cost from 30d cost", () => {
const now = Date.now();
const yesterdayMs = now - 25 * 60 * 60 * 1000; // 25h ago
Expand Down Expand Up @@ -2440,6 +2489,129 @@ describe("scanClaudeLogs (via aggregateCosts)", () => {
});

describe("scanCodexLogs", () => {
it("skips an oversized record and processes the following token record", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
try {
process.env.CODEX_HOME = tmpDir;
const sessionDir = path.join(tmpDir, "sessions", "2026", "07", "12");
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, "rollout-test.jsonl"),
[
JSON.stringify({
timestamp: "2026-07-12T12:00:00.000Z",
type: "session_meta",
payload: { id: "session-1", originator: "codex_cli_rs", model: "gpt-5.5" },
}),
JSON.stringify({
timestamp: "2026-07-12T12:00:01.000Z",
type: "response_item",
payload: { type: "function_call_output", output: "x".repeat(2_048) },
}),
JSON.stringify({
timestamp: "2026-07-12T12:00:02.000Z",
type: "event_msg",
payload: {
type: "token_count",
info: {
total_token_usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 },
last_token_usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 },
},
},
}),
"",
].join("\n"),
);

const entries = await scanCodexLogs({ maxJsonlLineBytes: 1_024 });

expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
messageId: "session-1:2026-07-12T12:00:02.000Z:15",
model: "gpt-5.5",
inputTokens: 12,
outputTokens: 3,
});
} finally {
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("coalesces concurrent production history scans", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
try {
process.env.CODEX_HOME = tmpDir;
const sessionDir = path.join(tmpDir, "sessions", "2026", "07", "12");
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, "rollout-test.jsonl"),
[
JSON.stringify({
timestamp: "2026-07-12T12:00:00.000Z",
type: "session_meta",
payload: { id: "session-coalesced", originator: "codex_cli_rs", model: "gpt-5.5" },
}),
JSON.stringify({
timestamp: "2026-07-12T12:00:01.000Z",
type: "event_msg",
payload: {
type: "token_count",
info: {
total_token_usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 },
last_token_usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 },
},
},
}),
"",
].join("\n"),
);

const first = scanCodexLogs();
const second = scanCodexLogs();

expect(second).toBe(first);
const [firstEntries, secondEntries] = await Promise.all([first, second]);
expect(secondEntries).toBe(firstEntries);
expect(firstEntries).toHaveLength(1);
} finally {
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("selects newest ledger files within per-file and aggregate byte budgets", async () => {
const tmpDir = makeTmpDir();
try {
const writeCandidate = (name: string, bytes: number, ageSeconds: number) => {
const filePath = path.join(tmpDir, name);
fs.writeFileSync(filePath, Buffer.alloc(bytes, 0x78));
const modifiedAt = new Date(Date.now() - ageSeconds * 1_000);
fs.utimesSync(filePath, modifiedAt, modifiedAt);
return filePath;
};
writeCandidate("old.jsonl", 700, 30);
const middle = writeCandidate("middle.jsonl", 700, 20);
const newest = writeCandidate("newest.jsonl", 700, 10);
writeCandidate("too-large.jsonl", 1_500, 1);

const selected = await findRecentFiles(tmpDir, 3650, [".jsonl"], {
maxFiles: 10,
maxFileBytes: 1_000,
maxTotalBytes: 1_400,
});

expect(selected).toEqual([newest, middle]);
expect(selected).not.toContain(path.join(tmpDir, "old.jsonl"));
expect(selected).not.toContain(path.join(tmpDir, "too-large.jsonl"));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("parses modern token_count events from Codex session logs", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
Expand Down Expand Up @@ -2582,6 +2754,118 @@ describe("scanCodexLogs", () => {
}
});

it("preserves the exact Codex lifetime total within the remaining entry budget", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
const { DatabaseSync } = requireForTest("node:sqlite") as { DatabaseSync: new (dbPath: string) => any };
try {
process.env.CODEX_HOME = tmpDir;
const db = new DatabaseSync(path.join(tmpDir, "state_5.sqlite"));
db.exec(`
create table threads (
id text primary key,
tokens_used integer not null,
model text,
cwd text,
source text,
thread_source text,
created_at integer,
updated_at integer
);
insert into threads values
('oldest', 10, 'gpt-5.5', '/repo', 'Codex Desktop', 'Codex Desktop', 100, 100),
('middle', 20, 'gpt-5.5', '/repo', 'Codex Desktop', 'Codex Desktop', 200, 200),
('newest', 30, 'gpt-5.5', '/repo', 'Codex Desktop', 'Codex Desktop', 300, 300);
`);
db.close();

const entries = await scanCodexLogs({ maxEntries: 2 });

expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.messageId)).toEqual([
"newest:state-total-remainder",
"codex-state:lifetime-total-remainder",
]);
expect(entries.reduce(
(total, entry) => total + entry.inputTokens + entry.outputTokens + entry.cachedTokens,
0,
)).toBe(60);
expect(entries[1]).toMatchObject({ lifetimeOnly: true, inputTokens: 30, costOverrideUsd: 0 });
} finally {
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("unions disjoint JSONL and SQLite threads within the bounded lifetime summary", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
const { DatabaseSync } = requireForTest("node:sqlite") as { DatabaseSync: new (dbPath: string) => any };
try {
process.env.CODEX_HOME = tmpDir;
const sessionDir = path.join(tmpDir, "sessions", "2026", "07", "12");
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, "json-only.jsonl"),
[
JSON.stringify({
timestamp: "2026-07-12T12:00:00.000Z",
type: "session_meta",
payload: { id: "json-only", originator: "codex_cli_rs", model: "gpt-5.5" },
}),
JSON.stringify({
timestamp: "2026-07-12T12:00:01.000Z",
type: "event_msg",
payload: {
type: "token_count",
info: {
total_token_usage: { input_tokens: 80, output_tokens: 0, total_tokens: 80 },
last_token_usage: { input_tokens: 80, output_tokens: 0, total_tokens: 80 },
},
},
}),
"",
].join("\n"),
);
const db = new DatabaseSync(path.join(tmpDir, "state_5.sqlite"));
db.exec(`
create table threads (
id text primary key,
tokens_used integer not null,
model text,
cwd text,
source text,
thread_source text,
created_at integer,
updated_at integer
);
insert into threads values (
'state-only', 100, 'gpt-5.5', '/repo', 'Codex Desktop',
'Codex Desktop', 100, 100
);
`);
db.close();

const entries = await scanCodexLogs({ maxEntries: 2 });
const total = entries.reduce((sum, entry) => (
sum + entry.inputTokens + entry.outputTokens + entry.cachedTokens + (entry.cacheWriteTokens ?? 0)
), 0);

expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.messageId)).toEqual([
"json-only:2026-07-12T12:00:01.000Z:80",
"codex-state:lifetime-total-remainder",
]);
expect(total).toBe(180);
expect(entries[1]).toMatchObject({ lifetimeOnly: true, inputTokens: 100, costOverrideUsd: 0 });
} finally {
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = originalCodexHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it("includes Codex archived session ledgers in lifetime usage", async () => {
const tmpDir = makeTmpDir();
const originalCodexHome = process.env.CODEX_HOME;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,7 @@ function bucketDaily7d(entries: TokenEntry[], nowMs: number): number[] {
bucketByDay.set(localDayKey(day), index);
}
for (const entry of entries) {
if (entry.lifetimeOnly) continue;
if (entry.timestamp > nowMs) continue;
const bucketIndex = bucketByDay.get(localDayKey(entry.timestamp));
if (bucketIndex == null) continue;
Expand Down Expand Up @@ -1217,6 +1218,12 @@ function aggregateCosts(

for (const entry of entries) {
const cost = calculateTokenEntryCost(entry);
if (entry.lifetimeOnly) {
const allTime = accumulators.all;
allTime.costUsd += cost;
addTokenBreakdownEntry(allTime.tokenBreakdown, entry);
continue;
}
for (const preset of ADE_USAGE_RANGE_PRESETS) {
const startMs = starts[preset];
if (startMs != null && entry.timestamp < startMs) continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
type AgentChatSessionCreatedOptions,
} from "./AgentChatPane";
import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard";
import { findUserMessageForTurn, isParentUserMessage } from "./chatTurnState";

vi.mock("../terminals/TerminalView", () => {
const ReactMod = require("react") as typeof React;
Expand Down Expand Up @@ -7380,6 +7381,48 @@ describe("AgentChatPane submit recovery", () => {
// Pure function unit tests (consolidated from AgentChatPane.test.ts)
// ---------------------------------------------------------------------------

describe("correlated parent turn messages", () => {
it("keeps a fresh idle-steer parent retryable without promoting child steers", () => {
const parent = {
type: "user_message" as const,
text: "Retry this parent turn",
steerId: "idle-steer-correlation",
messageId: "durable-parent-message",
deliveryState: "delivered" as const,
turnId: "turn-parent",
};
const childSteer = {
type: "user_message" as const,
text: "Adjust the active turn",
steerId: "active-steer-correlation",
deliveryState: "delivered" as const,
turnId: "turn-parent",
};
const events: AgentChatEventEnvelope[] = [
{
sessionId: "session-parent",
timestamp: "2026-07-12T12:00:00.000Z",
sequence: 1,
event: parent,
},
{
sessionId: "session-parent",
timestamp: "2026-07-12T12:00:01.000Z",
sequence: 2,
event: childSteer,
},
];

expect(isParentUserMessage(parent)).toBe(true);
expect(isParentUserMessage(childSteer)).toBe(false);
expect(findUserMessageForTurn(events, "turn-parent")).toMatchObject({
text: "Retry this parent turn",
steerId: "idle-steer-correlation",
messageId: "durable-parent-message",
});
});
});

describe("resolveNextSelectedSessionId", () => {
function buildMinimalSession(sessionId: string): AgentChatSessionSummary {
return {
Expand Down
Loading