diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4e8cd8a4..c6841d2e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.43.1 — Unreleased ### Fixed +- Codex cost usage: invalidate cached fork totals when the parent session appears, changes, or resolves to a different file, preventing stale inherited baselines. Thanks @xx205! - Cursor: bind interactive account login to one readable browser, preserve the active session on cancellation or failure, and prevent background refreshes from replacing the selected account. Thanks @chapati23! - 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! diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index b6726ad1c9..94e105ad9e 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 = "f3142eea669758bf" + static let value = "b7dd32352b439226" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index ce5cad9cf9..c1575c0ec2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -6,6 +6,8 @@ enum CostUsageCacheIO { /// totals are counted, so every earlier cache must be rebuilt. private static let compatibleCodexProducerKeys: Set = [] + /// Parsing and attribution changes rotate the Codex parser producer key. + /// Increment this artifact version only when the stored schema or cache layout becomes incompatible. private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: @@ -141,6 +143,7 @@ struct CostUsageFileUsage: Codable { var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var forkBaselineDependencyKey: String? var projectPath: String? var canonicalProjectPath: String? var codexCostCacheComplete: Bool? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index bcc11d0b6f..27ee4b0e21 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -18,7 +18,9 @@ extension CostUsageScanner { } func load(_ loader: (URL?) -> ModelsDevCatalog?) -> ModelsDevCatalog { - if let catalog { return catalog } + if let catalog { + return catalog + } let loaded = loader(self.cacheRoot) ?? ModelsDevCatalog(providers: [:]) self.catalog = loaded return loaded @@ -285,6 +287,7 @@ extension CostUsageScanner { lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + forkBaselineDependencyKey: String? = nil, projectPath: String? = nil, canonicalProjectPath: String? = nil, codexCostCacheComplete: Bool? = true, @@ -314,6 +317,7 @@ extension CostUsageScanner { lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, codexCostCacheComplete: codexCostCacheComplete, @@ -705,6 +709,7 @@ extension CostUsageScanner { lastCodexTurnID: usage.lastCodexTurnID, sessionId: usage.sessionId, forkedFromId: usage.forkedFromId, + forkBaselineDependencyKey: usage.forkBaselineDependencyKey, projectPath: usage.projectPath, canonicalProjectPath: usage.canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( @@ -883,7 +888,7 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) -> Bool + state: inout CodexScanState) throws -> Bool { guard let cached = input.cached else { return false } let needsSessionId = cached.sessionId == nil @@ -900,6 +905,13 @@ extension CostUsageScanner { if Self.cachedCodexRowsNeedIdentityRescan(cached) { return false } + if let parentSessionId = cached.forkedFromId { + guard let cachedDependencyKey = cached.forkBaselineDependencyKey else { return false } + let currentDependencyKey = try context.resources.inheritedResolver + .currentDependencyKey(for: parentSessionId) + guard cachedDependencyKey == currentDependencyKey else { return false } + } + if sessionAlreadyContributed { guard !cachedRows.isEmpty else { return false } let uniqueRows = Self.uniqueCodexRows( @@ -1119,6 +1131,11 @@ extension CostUsageScanner { range: context.range, inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) + let forkBaselineDependencyKey: String? = if let parentSessionId = parsed.forkedFromId { + context.resources.inheritedResolver.dependencyKeyUsed(for: parentSessionId) + } else { + nil + } let sessionId = parsed.sessionId ?? input.cached?.sessionId let projectPath = parsed.projectPath ?? input.cached?.projectPath let canonicalProjectPath = parsed.projectPath.map { @@ -1163,6 +1180,7 @@ extension CostUsageScanner { lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( @@ -1501,16 +1519,24 @@ extension Data { extension [Int] { subscript(safe index: Int) -> Int? { - if index < 0 { return nil } - if index >= self.count { return nil } + if index < 0 { + return nil + } + if index >= self.count { + return nil + } return self[index] } } extension [UInt8] { subscript(safe index: Int) -> UInt8? { - if index < 0 { return nil } - if index >= self.count { return nil } + if index < 0 { + return nil + } + if index >= self.count { + return nil + } return self[index] } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 5d6567ec2c..e0f26feaae 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -608,9 +608,14 @@ enum CostUsageScanner { } final class CodexInheritedTotalsResolver { + private struct SnapshotResolution { + let dependencyKey: String? + let snapshots: [CodexTimestampedTotals]? + } + private let fileIndex: CodexSessionFileIndex private let checkCancellation: CancellationCheck? - private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] + private var snapshotResolutions: [String: SnapshotResolution] = [:] init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { self.fileIndex = fileIndex @@ -630,7 +635,7 @@ enum CostUsageScanner { "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) } - guard let snapshots = try self.snapshots(for: sessionId) else { return .unresolved } + guard let snapshots = try self.snapshotResolution(for: sessionId).snapshots else { return .unresolved } var inherited: CostUsageCodexTotals? for snapshot in snapshots { let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { @@ -645,8 +650,31 @@ enum CostUsageScanner { return .resolved(inherited) } - private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? { - if let cached = self.snapshotsBySessionId[sessionId] { + func currentDependencyKey(for sessionId: String) throws -> String { + guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { + return "missing:\(sessionId)" + } + return self.dependencyKey(for: sessionId, fileURL: fileURL) + } + + func dependencyKeyUsed(for sessionId: String) -> String? { + self.snapshotResolutions[sessionId]?.dependencyKey + } + + private func dependencyKey(for sessionId: String, fileURL: URL) -> String { + let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + return [ + "file", + sessionId, + fileURL.standardizedFileURL.path, + metadata.fileId ?? "unknown", + String(metadata.mtimeUnixMs), + String(metadata.size), + ].joined(separator: "|") + } + + private func snapshotResolution(for sessionId: String) throws -> SnapshotResolution { + if let cached = self.snapshotResolutions[sessionId] { return cached } try self.checkCancellation?() @@ -654,29 +682,58 @@ enum CostUsageScanner { CostUsageScanner.log.warning( "Codex cost usage parent session file not found", metadata: ["sessionId": sessionId]) - return nil + let resolution = SnapshotResolution( + dependencyKey: "missing:\(sessionId)", + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution } - let parsed = try CostUsageScanner.parseCodexTokenSnapshots( - fileURL: fileURL, - checkCancellation: self.checkCancellation) - guard let parsedSessionId = parsed.sessionId else { - CostUsageScanner.log.warning( - "Codex cost usage parent session missing session metadata", - metadata: ["sessionId": sessionId, "path": fileURL.path]) - return nil - } - if parsedSessionId != sessionId { - CostUsageScanner.log.warning( - "Codex cost usage parent session resolved to mismatched session id", - metadata: [ - "requestedSessionId": sessionId, - "resolvedSessionId": parsedSessionId, - "path": fileURL.path, - ]) - return nil + + for _ in 0..<2 { + let dependencyKeyBeforeParse = self.dependencyKey(for: sessionId, fileURL: fileURL) + let parsed = try CostUsageScanner.parseCodexTokenSnapshots( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + let dependencyKeyAfterParse = self.dependencyKey(for: sessionId, fileURL: fileURL) + guard dependencyKeyBeforeParse == dependencyKeyAfterParse else { continue } + + guard let parsedSessionId = parsed.sessionId else { + CostUsageScanner.log.warning( + "Codex cost usage parent session missing session metadata", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + } + if parsedSessionId != sessionId { + CostUsageScanner.log.warning( + "Codex cost usage parent session resolved to mismatched session id", + metadata: [ + "requestedSessionId": sessionId, + "resolvedSessionId": parsedSessionId, + "path": fileURL.path, + ]) + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + } + let resolution = SnapshotResolution( + dependencyKey: dependencyKeyAfterParse, + snapshots: parsed.snapshots) + self.snapshotResolutions[sessionId] = resolution + return resolution } - self.snapshotsBySessionId[sessionId] = parsed.snapshots - return parsed.snapshots + + CostUsageScanner.log.warning( + "Codex cost usage parent session changed while reading; deferring inherited baseline", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + let resolution = SnapshotResolution(dependencyKey: nil, snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution } } @@ -2394,7 +2451,7 @@ enum CostUsageScanner { let cached = cache.files[metadata.path] let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) - if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { + if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { return } if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 5bead895db..6d5fd1760c 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -3940,6 +3940,332 @@ struct CostUsageScannerBreakdownTests { #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } + @Test + func `codex warm cache invalidates fork when parent baseline and child file change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-growing" + let childSessionId = "sess-child-cached" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(3)) + let parentMetadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + let firstParentUsage = self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)) + + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + firstParentUsage, + ])) + try FileManager.default.setAttributes([.modificationDate: parentDay], ofItemAtPath: parentURL.path) + + let childURL = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(first.data.first?.inputTokens == 17) + #expect(first.data.first?.cacheReadTokens == 5) + #expect(first.data.first?.outputTokens == 3) + + try env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + firstParentUsage, + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ]).write(to: parentURL, atomically: true, encoding: .utf8) + let childHandle = try FileHandle(forWritingTo: childURL) + try childHandle.seekToEnd() + try childHandle.write(contentsOf: Data(env.jsonl([ + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(4)), + model: model, + total: (input: 40, cached: 12, output: 6)), + ]).utf8)) + try childHandle.close() + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + + #expect(second.data.count == 1) + #expect(second.data[0].inputTokens == 10) + #expect(second.data[0].cacheReadTokens == 4) + #expect(second.data[0].outputTokens == 3) + } + + @Test + func `codex warm cache invalidates fork when missing parent appears`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-appears" + let childSessionId = "sess-child-waiting" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(2)) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5), + last: (input: 7, cached: 2, output: 2)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let withoutParent = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(withoutParent.data.first?.inputTokens == 7) + #expect(withoutParent.data.first?.cacheReadTokens == 2) + #expect(withoutParent.data.first?.outputTokens == 2) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + ["type": "session_meta", "payload": ["id": parentSessionId]], + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + + let withParent = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + #expect(withParent.data.first?.inputTokens == 17) + #expect(withParent.data.first?.cacheReadTokens == 5) + #expect(withParent.data.first?.outputTokens == 3) + } + + @Test + func `codex warm cache invalidates fork when parent file selection changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-replaced" + let childSessionId = "sess-child-rebased" + let forkTimestamp = env.isoString(for: parentDay.addingTimeInterval(3)) + let parentMetadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + + let firstParentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T11-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T12-00-00-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": parentSessionId, + "timestamp": forkTimestamp, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 30, cached: 8, output: 3)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 37, cached: 10, output: 5)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + #expect(first.data.first?.inputTokens == 17) + #expect(first.data.first?.cacheReadTokens == 5) + #expect(first.data.first?.outputTokens == 3) + + try FileManager.default.removeItem(at: firstParentURL) + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + parentMetadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ])) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay.addingTimeInterval(1), + options: options) + #expect(second.data.first?.inputTokens == 7) + #expect(second.data.first?.cacheReadTokens == 2) + #expect(second.data.first?.outputTokens == 2) + } + + @Test + func `codex parent dependency key stays bound to parsed snapshots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 1) + let model = "openai/gpt-5.2-codex" + let parentSessionId = "sess-parent-key-binding" + let metadata: [String: Any] = [ + "type": "session_meta", + "payload": ["id": parentSessionId], + ] + let parentURL = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-01T12-00-00-\(parentSessionId).jsonl", + contents: env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 20, cached: 5, output: 2)), + ])) + let fileIndex = CostUsageScanner.CodexSessionFileIndex(files: [parentURL], roots: []) + let resolver = CostUsageScanner.CodexInheritedTotalsResolver( + fileIndex: fileIndex, + checkCancellation: nil) + + _ = try resolver.inheritedTotals( + for: parentSessionId, + atOrBefore: env.isoString(for: parentDay.addingTimeInterval(2))) + let parsedDependencyKey = resolver.dependencyKeyUsed(for: parentSessionId) + + try env.jsonl([ + metadata, + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 30, cached: 8, output: 3)), + ]).write(to: parentURL, atomically: true, encoding: .utf8) + + #expect(parsedDependencyKey != nil) + #expect(try resolver.currentDependencyKey(for: parentSessionId) != parsedDependencyKey) + #expect(resolver.dependencyKeyUsed(for: parentSessionId) == parsedDependencyKey) + } + @Test func `codex forked child skips cumulative totals when parent session is missing`() throws { let env = try CostUsageTestEnvironment()