diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f6104fc0..12681a4fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Menu bar: prevent duplicate provider items when usage updates re-enter initial status-item setup (#2162). Thanks @ss251! +- Codex cost usage: count restarted subagent token counters without subtracting the parent's unrelated cumulative baseline (#2193). Thanks @qiuruiyu and @harjothkhara! - Ollama: explain that API-key verification cannot show Cloud quota limits and direct users to browser-cookie mode (#2159). Thanks @kiranmagic7! - Claude: suppress duplicate all-model scoped quota rows that could appear as “All models only” beside Weekly. diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 085fda188b..b6726ad1c9 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "cdef6eb9658a43e2" + static let value = "f3142eea669758bf" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index fef88de04f..5d6567ec2c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1235,6 +1235,7 @@ enum CostUsageScanner { let forkedFromId: String? let forkTimestamp: String? let projectPath: String? + let isSubagentThread: Bool } private struct CodexTokenCountRecord { @@ -1266,6 +1267,8 @@ enum CostUsageScanner { private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) private static let codexJSONFieldPayload = Array("payload".utf8) + private static let codexJSONFieldSource = Array("source".utf8) + private static let codexJSONFieldSubagent = Array("subagent".utf8) private static let codexJSONFieldSessionId = Array("session_id".utf8) private static let codexJSONFieldSessionIdCamel = Array("sessionId".utf8) private static let codexJSONFieldTimestamp = Array("timestamp".utf8) @@ -1329,6 +1332,47 @@ enum CostUsageScanner { return nil } + private static func codexIsSubagentThread(from payload: [String: Any]?) -> Bool { + guard let payload else { return false } + if let source = payload["source"] as? String { + return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "subagent" + } + if let source = payload["source"] as? [String: Any] { + return source["subagent"] is String || source["subagent"] is [String: Any] + } + return false + } + + private static func codexIsSubagentThread( + from bytes: UnsafeBufferPointer, + in payloadRange: Range) -> Bool + { + if let source = extractJSONByteStringField( + self.codexJSONFieldSource, + from: bytes, + in: payloadRange, + atDepth: 1) + { + return source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "subagent" + } + guard let sourceRange = extractJSONByteObjectField( + self.codexJSONFieldSource, + from: bytes, + in: payloadRange, + atDepth: 1) + else { return false } + return extractJSONByteStringField( + self.codexJSONFieldSubagent, + from: bytes, + in: sourceRange, + atDepth: 1) != nil + || extractJSONByteObjectField( + self.codexJSONFieldSubagent, + from: bytes, + in: sourceRange, + atDepth: 1) != nil + } + private static func codexTurnID(from bytes: UnsafeBufferPointer, in payloadRange: Range) -> String? { for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), !value.isEmpty { @@ -1350,21 +1394,24 @@ enum CostUsageScanner { in rootRange: Range, payloadRange: Range?) -> String? { - if let payloadRange { - for key in [self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel, self.codexJSONFieldId] { - if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), - !value.isEmpty - { - return value - } - } - } - for key in [Self.codexJSONFieldSessionId, Self.codexJSONFieldSessionIdCamel, Self.codexJSONFieldId] { - if let value = Self.extractJSONByteStringField(key, from: bytes, in: rootRange, atDepth: 1), - !value.isEmpty - { - return value - } + // `session_id` identifies the shared multi-agent tree. `id` identifies this rollout/thread, + // and both fields have appeared at either metadata level. + let candidates: [String?] = [ + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldId, from: bytes, in: $0, atDepth: 1) + }, + Self.extractJSONByteStringField(Self.codexJSONFieldId, from: bytes, in: rootRange, atDepth: 1), + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldSessionId, from: bytes, in: $0, atDepth: 1) + }, + payloadRange.flatMap { + Self.extractJSONByteStringField(Self.codexJSONFieldSessionIdCamel, from: bytes, in: $0, atDepth: 1) + }, + Self.extractJSONByteStringField(Self.codexJSONFieldSessionId, from: bytes, in: rootRange, atDepth: 1), + Self.extractJSONByteStringField(Self.codexJSONFieldSessionIdCamel, from: bytes, in: rootRange, atDepth: 1), + ] + for value in candidates where value?.isEmpty == false { + return value } return nil } @@ -1445,7 +1492,10 @@ enum CostUsageScanner { from: rawBuffer, in: objectRange, atDepth: 1), - projectPath: Self.codexProjectPath(from: rawBuffer, payloadRange: payloadRange))) + projectPath: Self.codexProjectPath(from: rawBuffer, payloadRange: payloadRange), + isSubagentThread: payloadRange.map { + Self.codexIsSubagentThread(from: rawBuffer, in: $0) + } ?? false)) case "turn_context": guard let payloadRange = Self.extractJSONByteObjectField( @@ -1572,6 +1622,23 @@ enum CostUsageScanner { static let codexSessionMetadataMaxLineBytes = 256 * 1024 + private static func codexSessionMetadata(from obj: [String: Any]) -> CodexSessionMetadata? { + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["id"] as? String + ?? obj["id"] as? String + ?? payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String, + forkedFromId: Self.codexForkParentId(from: payload), + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String, + projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String), + isSubagentThread: Self.codexIsSubagentThread(from: payload)) + } + private static func parseCodexSessionMetadata( fileURL: URL, checkCancellation: CancellationCheck? = nil) throws -> CodexSessionMetadata? @@ -1598,19 +1665,7 @@ enum CostUsageScanner { return autoreleasepool { guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { return nil } - guard obj["type"] as? String == "session_meta" else { return nil } - let payload = obj["payload"] as? [String: Any] - return CodexSessionMetadata( - sessionId: payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String, - forkedFromId: Self.codexForkParentId(from: payload), - forkTimestamp: payload?["timestamp"] as? String - ?? obj["timestamp"] as? String, - projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String)) + return Self.codexSessionMetadata(from: obj) } } @@ -1856,6 +1911,7 @@ enum CostUsageScanner { var sessionId: String? var forkedFromId: String? var projectPath: String? + var isSubagentThread = false var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? var forkBaselineResolved = false @@ -1912,8 +1968,18 @@ enum CostUsageScanner { if projectPath == nil { projectPath = metadata.projectPath } + isSubagentThread = isSubagentThread || metadata.isSubagentThread if let forkedFromId { - try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") + if isSubagentThread { + // Codex subagent rollouts own an independent cumulative counter; their parent + // identifiers describe lineage, not a token baseline to subtract (#2193). + forkBaselineResolved = true + inheritedTotals = nil + remainingInheritedTotals = nil + hasUnresolvedForkBaseline = false + } else { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") + } } } @@ -1954,8 +2020,9 @@ enum CostUsageScanner { return adjusted } - // Fork children are measured against the parent-inherited baseline so every cumulative - // comparison in this file happens on a single scale. + // Generic fork children are measured against their parent-inherited baseline so every + // cumulative comparison in this file happens on a single scale. Subagents use their + // independent counter directly. let adjustedTotal: CostUsageCodexTotals? = total.map { rawTotals in guard let inheritedTotals, !hasUnresolvedForkBaseline else { return rawTotals } return CostUsageCodexTotals( @@ -2147,15 +2214,7 @@ enum CostUsageScanner { fileURL: fileURL, checkCancellation: checkCancellation) { - sessionId = metadata.sessionId - forkedFromId = metadata.forkedFromId - projectPath = metadata.projectPath - if let forkedFromId = metadata.forkedFromId, - inheritedTotals == nil - { - let forkedAt = metadata.forkTimestamp ?? "" - try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) - } + try handleSessionMetadata(metadata) } var parsedBytes: Int64 @@ -2208,31 +2267,11 @@ enum CostUsageScanner { else { return } if type == "session_meta" { - let payload = obj["payload"] as? [String: Any] - if sessionId == nil { - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String - } - if forkedFromId == nil { - forkedFromId = Self.codexForkParentId(from: payload) - } - if projectPath == nil { - projectPath = Self.normalizedCodexProjectPath(payload?["cwd"] as? String) - } - if let forkedFromId { - let forkedAt = payload?["timestamp"] as? String - ?? obj["timestamp"] as? String - ?? "" - do { - try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) - } catch { - deferredError = error - return - } + guard let metadata = Self.codexSessionMetadata(from: obj) else { return } + do { + try handleSessionMetadata(metadata) + } catch { + deferredError = error } return } diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 8a3b8ad4c3..5bead895db 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -4062,6 +4062,429 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.rows.first?.output == 10) } + @Test + func `codex subagent with restarted totals counts its full usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700), + last: (input: 14700, cached: 12000, output: 700)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, forkedAt in + #expect(parentSessionId == "parent-session") + #expect(forkedAt == forkTimestamp) + return .resolved(.init(input: 60_000_000, cached: 48_000_000, output: 3_000_000)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + let packed = parsed.days[dayKey]?[normalized] ?? [] + #expect(packed == [62200, 51000, 3200]) + } + + @Test + func `codex metadata lookahead recognizes a total-only explicit subagent counter`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-late-child-session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700)), + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, forkedAt in + #expect(parentSessionId == "parent-session") + #expect(forkedAt == forkTimestamp) + return .resolved(.init(input: 60_000_000, cached: 48_000_000, output: 3_000_000)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [62200, 51000, 3200]) + } + + @Test + func `codex bare parent thread id with continuing totals keeps fork baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-continued-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1001, cached: 900, output: 101), + last: (input: 1, cached: 0, output: 1)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { _, _ in + resolvedParentBaseline = true + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [1, 0, 1]) + #expect(resolvedParentBaseline) + } + + @Test + func `codex subagent provenance matrix preserves explicit source and parser parity`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + typealias ProvenanceCase = ( + input: (name: String, source: Any, parentThreadId: String?, forceFallback: Bool), + expected: (tokens: [Int], resolvesParent: Bool)) + let cases: [ProvenanceCase] = [ + (("explicit-cli", "cli", "parent-session", false), ([50, 10, 5], true)), + (("bare-subagent", "subagent", nil, false), ([1050, 910, 105], false)), + (("fast-unit-subagent", ["subagent": "review"], nil, false), ([1050, 910, 105], false)), + (("fallback-unit-subagent", ["subagent": "review"], nil, true), ([1050, 910, 105], false)), + ] + + for testCase in cases { + var payload: [String: Any] = [ + "id": "child-\(testCase.input.name)", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": testCase.input.source, + ] + if let parentThreadId = testCase.input.parentThreadId { + payload["parent_thread_id"] = parentThreadId + } + var metadata = try env.jsonl([[ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": payload, + ]]) + if testCase.input.forceFallback { + metadata = metadata.replacingOccurrences(of: "\"type\"", with: "\"ty\\u0070e\"") + } + let events = try env.jsonl([ + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1050, cached: 910, output: 105), + last: (input: 50, cached: 10, output: 5)), + ]) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-\(testCase.input.name).jsonl", + contents: metadata + "\n" + events) + + var resolvedParent = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionId, _ in + resolvedParent = true + #expect(parentSessionId == "parent-session") + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == testCase.expected.tokens) + #expect(resolvedParent == testCase.expected.resolvesParent) + } + } + + @Test + func `codex nested source subagent counts without resolving a missing parent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-nested-source-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "missing-parent", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "missing-parent"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 1000, cached: 900, output: 100)), + ])) + + var resolvedParentBaseline = false + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + resolvedParentBaseline = true + return .unresolved + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [1000, 900, 100]) + #expect(!resolvedParentBaseline) + } + + @Test + func `codex subagent with only last-token records counts full usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let forkTimestamp = env.isoString(for: day) + let model = "openai/gpt-5.4" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-last-only-child.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 10, cached: 2, output: 1)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in + .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == [10, 2, 1]) + } + + @Test + func `codex daily report sums parent and restarted subagent totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 14) + let parentTimestamp = env.isoString(for: day) + let forkDate = day.addingTimeInterval(3) + let forkTimestamp = env.isoString(for: forkDate) + let model = "openai/gpt-5.4" + let projectPath = "/tmp/codexbar-2193-project" + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(parentTimestamp)-parent-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "id": "parent-session", + "timestamp": parentTimestamp, + "payload": [ + "session_id": "shared-agent-tree", + "timestamp": parentTimestamp, + "cwd": projectPath, + ], + ], + self.codexTurnContext(timestamp: parentTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 60_000_000, cached: 48_000_000, output: 3_000_000), + last: (input: 60_000_000, cached: 48_000_000, output: 3_000_000)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(forkTimestamp)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "id": "child-session", + "timestamp": forkTimestamp, + "payload": [ + "session_id": "shared-agent-tree", + "forked_from_id": "parent-session", + "parent_thread_id": "parent-session", + "timestamp": forkTimestamp, + "cwd": projectPath, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": "parent-session"], + ], + ], + ], + ], + self.codexTurnContext(timestamp: forkTimestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: forkDate.addingTimeInterval(1)), + model: model, + total: (input: 14700, cached: 12000, output: 700), + last: (input: 14700, cached: 12000, output: 700)), + self.codexTokenCount( + timestamp: env.isoString(for: forkDate.addingTimeInterval(2)), + model: model, + total: (input: 62200, cached: 51000, output: 3200), + last: (input: 47500, cached: 39000, output: 2500)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: forkDate.addingTimeInterval(3), + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 60_062_200) + #expect(report.data[0].cacheReadTokens == 48_051_000) + #expect(report.data[0].outputTokens == 3_003_200) + #expect(report.data[0].totalTokens == 63_065_400) + let parentCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 60_000_000, + cachedInputTokens: 48_000_000, + outputTokens: 3_000_000) ?? 0 + let childCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 62200, + cachedInputTokens: 51000, + outputTokens: 3200) ?? 0 + let expectedCost = parentCost + childCost + #expect(abs((report.data[0].costUSD ?? 0) - expectedCost) < 0.000001) + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + let project = try #require(projects.first(where: { $0.path == projectPath })) + #expect(project.totalTokens == 63_065_400) + #expect(abs((project.totalCostUSD ?? 0) - expectedCost) < 0.000001) + } + @Test func `codex fork skips last usage when parent baseline is unresolved`() throws { let env = try CostUsageTestEnvironment()