diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index dd23abe90c..d46bf4b5b2 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 = "338e4ab0fe153f95" + static let value = "48ac20dad61e9a7f" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift index 22d51a1d05..56f5608a56 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift @@ -7,11 +7,13 @@ extension CostUsageScanner { } /// Subagent source is lineage evidence, not counter semantics. The first session metadata - /// owns leaf identity; only embedded metadata proves that this rollout copied an ancestor - /// prefix. Do not restore a blanket "all subagents are independent/inherited" rule. + /// owns leaf identity. Embedded ancestor metadata proves a copied prefix by itself; compact + /// rollouts need both the first-turn boundary and an exact parent snapshot match in the scanner. + /// Do not restore a blanket "all subagents are independent/inherited" rule. struct CodexSubagentRolloutShape { let counterSemantics: CodexSubagentCounterSemantics let ownedSuffix: CodexSubagentOwnedSuffix? + let ownedSuffixCandidate: CodexSubagentOwnedSuffixCandidate? let inferredParentSessionID: String? struct CodexSubagentOwnedSuffix { @@ -19,6 +21,11 @@ extension CostUsageScanner { let rawTotalsBaseline: CostUsageCodexTotals } + struct CodexSubagentOwnedSuffixCandidate { + let ownedSuffix: CodexSubagentOwnedSuffix + let parentTotalsAtBoundary: CostUsageCodexTotals + } + struct Observation { let lineIndex: Int let kind: Kind @@ -50,12 +57,14 @@ extension CostUsageScanner { return Self( counterSemantics: hasEmbeddedAncestor ? .copiedPrefix : .independent, ownedSuffix: nil, + ownedSuffixCandidate: nil, inferredParentSessionID: inferredParentSessionID) } static func classify( leafSessionID: String?, - observations: [Observation]) -> Self + observations: [Observation], + hasExplicitParent: Bool = false) -> Self { let metadataIDs = observations.reduce(into: [String?]()) { result, observation in guard case let .sessionMetadata(id) = observation.kind else { return } @@ -64,14 +73,19 @@ extension CostUsageScanner { let metadataShape = Self.classify( leafSessionID: leafSessionID, observedSessionIDs: metadataIDs) - guard metadataShape.counterSemantics == .copiedPrefix else { return metadataShape } + let canProposeParentConfirmedSuffix = metadataShape.counterSemantics == .independent + && hasExplicitParent + guard metadataShape.counterSemantics == .copiedPrefix || canProposeParentConfirmedSuffix + else { return metadataShape } let normalizedLeafID = Self.normalizedSessionID(leafSessionID) var lastRawTotals: CostUsageCodexTotals? var pendingTurnContext: (lineIndex: Int, baseline: CostUsageCodexTotals)? var ownedSuffix: CodexSubagentOwnedSuffix? + var parentTotalsAtBoundary: CostUsageCodexTotals? var inspectedOwnedSuffixFirstTotal = false var observedAuthoritativeMetadata = false + var observedTurnContext = false for observation in observations { switch observation.kind { @@ -88,22 +102,32 @@ extension CostUsageScanner { if isEmbeddedAncestor { // A later ancestor meta proves that any earlier candidate boundary was replay. ownedSuffix = nil + parentTotalsAtBoundary = nil inspectedOwnedSuffixFirstTotal = false } pendingTurnContext = nil case .turnContext: - pendingTurnContext = lastRawTotals.map { (observation.lineIndex, $0) } + let isFirstTurnContext = !observedTurnContext + observedTurnContext = true + let acceptsBoundary = metadataShape.counterSemantics == .copiedPrefix + || (canProposeParentConfirmedSuffix && isFirstTurnContext) + pendingTurnContext = acceptsBoundary + ? lastRawTotals.map { (observation.lineIndex, $0) } + : nil case let .interAgentCommunication(triggerTurn): if ownedSuffix == nil, triggerTurn, let pendingTurnContext, - observation.lineIndex == pendingTurnContext.lineIndex + 1 + observation.lineIndex == pendingTurnContext.lineIndex + 1, + metadataShape.counterSemantics == .copiedPrefix + || Self.totalsContainUsage(pendingTurnContext.baseline) { ownedSuffix = Self.CodexSubagentOwnedSuffix( startLineIndex: pendingTurnContext.lineIndex, rawTotalsBaseline: pendingTurnContext.baseline) + parentTotalsAtBoundary = pendingTurnContext.baseline inspectedOwnedSuffixFirstTotal = false } pendingTurnContext = nil @@ -132,9 +156,25 @@ extension CostUsageScanner { } } + if metadataShape.counterSemantics == .copiedPrefix { + return Self( + counterSemantics: .copiedPrefix, + ownedSuffix: ownedSuffix, + ownedSuffixCandidate: nil, + inferredParentSessionID: metadataShape.inferredParentSessionID) + } + + let candidate: CodexSubagentOwnedSuffixCandidate? = if let ownedSuffix, let parentTotalsAtBoundary { + Self.CodexSubagentOwnedSuffixCandidate( + ownedSuffix: ownedSuffix, + parentTotalsAtBoundary: parentTotalsAtBoundary) + } else { + nil + } return Self( - counterSemantics: .copiedPrefix, - ownedSuffix: ownedSuffix, + counterSemantics: .independent, + ownedSuffix: nil, + ownedSuffixCandidate: candidate, inferredParentSessionID: metadataShape.inferredParentSessionID) } @@ -153,6 +193,10 @@ extension CostUsageScanner { lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output } + private static func totalsContainUsage(_ totals: CostUsageCodexTotals) -> Bool { + totals.input > 0 || totals.cached > 0 || totals.output > 0 + } + private static func normalizedSessionID(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 1d407f8efe..7ff583f33d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -2040,6 +2040,8 @@ enum CostUsageScanner { var forkTimestamp: String? var subagentCounterSemantics: CodexSubagentCounterSemantics? var usesLocalSubagentBoundary = false + var candidateBoundaryDependsOnParentTotals = false + var parentConfirmedLocalBoundary = false var suppressUnownedCopiedPrefix = false var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? @@ -2603,15 +2605,34 @@ enum CostUsageScanner { } let shape = CodexSubagentRolloutShape.classify( leafSessionID: sessionId, - observations: observations) + observations: observations, + hasExplicitParent: forkedFromId != nil) subagentCounterSemantics = shape.counterSemantics if forkedFromId == nil { forkedFromId = shape.inferredParentSessionID } - suppressUnownedCopiedPrefix = shape.counterSemantics == .copiedPrefix - && shape.ownedSuffix == nil + var ownedSuffix = shape.ownedSuffix + if let candidate = shape.ownedSuffixCandidate, + let parentSessionID = forkedFromId + { + candidateBoundaryDependsOnParentTotals = true + if let inheritedTotalsResolver { + switch try inheritedTotalsResolver(parentSessionID, forkTimestamp ?? "") { + case let .resolved(parentTotals): + if Self.codexTotalsEqual(parentTotals, candidate.parentTotalsAtBoundary) { + subagentCounterSemantics = .copiedPrefix + ownedSuffix = candidate.ownedSuffix + parentConfirmedLocalBoundary = true + } + case .unresolved: + break + } + } + } + suppressUnownedCopiedPrefix = subagentCounterSemantics == .copiedPrefix + && ownedSuffix == nil && forkedFromId == nil - if let ownedSuffix = shape.ownedSuffix { + if let ownedSuffix { usesLocalSubagentBoundary = true previousTotals = nil // Keep totals-derived accounting after the boundary. Real flat-total rows @@ -2632,7 +2653,8 @@ enum CostUsageScanner { metadata: [ "sessionId": sessionId ?? "unknown", "semantics": subagentCounterSemantics == .copiedPrefix ? "copiedPrefix" : "independent", - "localBoundary": shape.ownedSuffix == nil ? "false" : "true", + "localBoundary": ownedSuffix == nil ? "false" : "true", + "parentConfirmedBoundary": parentConfirmedLocalBoundary ? "true" : "false", "suppressedUnownedPrefix": suppressUnownedCopiedPrefix ? "true" : "false", "sessionMetadataCount": String(observations.count(where: { if case .sessionMetadata = $0.kind { @@ -2644,7 +2666,7 @@ enum CostUsageScanner { ]) try configureForkAccountingIfReady() for buffered in pendingSubagentLines - where shape.ownedSuffix.map({ buffered.lineIndex >= $0.startLineIndex }) ?? true + where ownedSuffix.map({ buffered.lineIndex >= $0.startLineIndex }) ?? true { try processFastLine(buffered.line) } @@ -2675,8 +2697,8 @@ enum CostUsageScanner { sessionId: sessionId, forkedFromId: forkedFromId, dependsOnParentTotals: forkedFromId != nil - && subagentCounterSemantics != .independent - && !usesLocalSubagentBoundary, + && (candidateBoundaryDependsOnParentTotals + || (subagentCounterSemantics != .independent && !usesLocalSubagentBoundary)), projectPath: projectPath, rows: rows) } diff --git a/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift new file mode 100644 index 0000000000..3fa37a5bb9 --- /dev/null +++ b/Tests/CodexBarTests/CodexCompactSubagentAccountingTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexCompactSubagentAccountingTests { + private typealias Fixture = CodexCompactSubagentFixture + private typealias Usage = Fixture.Usage + + @Test + func `parent-confirmed first turn marker drops a compact copied prefix`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let suffix: Usage = (input: 50, cached: 10, output: 5) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-parent.jsonl", + contents: Fixture.parentContents( + env: env, + day: day, + sessionID: "compact-parent", + model: parentModel, + totals: prefix)) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "compact-child", + parentID: "compact-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: (input: 7, cached: 3, output: 2)))) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + options.forceRescan = true + let forced = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: options) + + for report in [cold, warm, forced] { + let daily = try #require(report.data.first) + #expect(daily.totalTokens == 1155) + let breakdowns = try #require(daily.modelBreakdowns) + #expect(!breakdowns.contains { $0.modelName == CostUsagePricing.codexUnattributedModel }) + #expect(breakdowns.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(parentModel) + }?.totalTokens == 1100) + #expect(breakdowns.first { + $0.modelName == CostUsagePricing.normalizeCodexModel(leafModel) + }?.totalTokens == 55) + } + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let child = try #require(cache.files.values.first { $0.sessionId == "compact-child" }) + #expect(child.days[CostUsageScanner.CostUsageDayRange.dayKey(from: day)]?[ + CostUsagePricing.normalizeCodexModel(leafModel), + ] == [50, 10, 5]) + #expect(child.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) + #expect(child.forkBaselineDependencyKey?.hasPrefix("file|") == true) + } + + @Test + func `parent snapshot change invalidates a cached compact child classification`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let parentModel = "openai/gpt-5.3" + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let mismatchedParent: Usage = (input: 999, cached: 899, output: 99) + let suffix: Usage = (input: 50, cached: 10, output: 5) + let initialParentContents = try Fixture.parentContents( + env: env, + day: day, + sessionID: "cache-parent", + model: parentModel, + totals: mismatchedParent) + let parentURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-0-cache-parent.jsonl", + contents: initialParentContents) + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-1-cache-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "cache-child", + parentID: "cache-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: nil))) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let before = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let beforeDay = try #require(before.data.first) + #expect(beforeDay.totalTokens == 2253) + #expect(beforeDay.modelBreakdowns?.first { + $0.modelName == CostUsagePricing.codexUnattributedModel + }?.totalTokens == 1100) + let beforeCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let beforeChild = try #require(beforeCache.files.values.first { $0.sessionId == "cache-child" }) + let beforeDependency = try #require(beforeChild.forkBaselineDependencyKey) + #expect(beforeDependency.hasPrefix("file|")) + + let appendedParentSnapshot = try env.jsonl([ + Fixture.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(-0.5)), + model: parentModel, + total: prefix, + last: (input: 1, cached: 1, output: 1)), + ]) + try (initialParentContents + appendedParentSnapshot) + .write(to: parentURL, atomically: true, encoding: .utf8) + + let after = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options) + let afterDay = try #require(after.data.first) + #expect(afterDay.totalTokens == 1155) + #expect(!(afterDay.modelBreakdowns ?? []).contains { + $0.modelName == CostUsagePricing.codexUnattributedModel + }) + let afterCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let afterChild = try #require(afterCache.files.values.first { $0.sessionId == "cache-child" }) + #expect(afterChild.forkBaselineDependencyKey?.hasPrefix("file|") == true) + #expect(afterChild.forkBaselineDependencyKey != beforeDependency) + #expect(afterChild.days.values.allSatisfy { $0[CostUsagePricing.codexUnattributedModel] == nil }) + } + + @Test + func `unconfirmed compact prefix stays independent and parent-dependent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 17) + let leafModel = "openai/gpt-5.4" + let prefix: Usage = (input: 1000, cached: 900, output: 100) + let suffix: Usage = (input: 50, cached: 10, output: 5) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-unconfirmed-child.jsonl", + contents: Fixture.childContents( + env: env, + day: day, + fixture: Fixture.Child( + sessionID: "unconfirmed-child", + parentID: "unconfirmed-parent", + leafModel: leafModel, + prefix: prefix, + suffix: suffix, + preBoundaryLast: nil))) + let baselines: [CostUsageScanner.CodexForkBaseline] = [ + .resolved(.init(input: 999, cached: 899, output: 99)), + .unresolved, + ] + + for baseline in baselines { + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { _, _ in baseline }) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + #expect(parsed.days[dayKey]?[CostUsagePricing.codexUnattributedModel] == [1000, 900, 100]) + #expect(parsed.days[dayKey]?[CostUsagePricing.normalizeCodexModel(leafModel)] == [50, 10, 5]) + #expect(parsed.dependsOnParentTotals) + } + } +} diff --git a/Tests/CodexBarTests/CodexCompactSubagentFixture.swift b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift new file mode 100644 index 0000000000..85855038c3 --- /dev/null +++ b/Tests/CodexBarTests/CodexCompactSubagentFixture.swift @@ -0,0 +1,150 @@ +import Foundation + +enum CodexCompactSubagentFixture { + typealias Usage = (input: Int, cached: Int, output: Int) + + struct Child { + let sessionID: String + let parentID: String + let leafModel: String + let prefix: Usage + let suffix: Usage + let preBoundaryLast: Usage? + } + + static func parentContents( + env: CostUsageTestEnvironment, + day: Date, + sessionID: String, + model: String, + totals: Usage) throws -> String + { + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day.addingTimeInterval(-2)), + "payload": ["id": sessionID], + ], + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(-2)), + model: model), + self.tokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(-1)), + model: model, + total: totals, + last: totals), + ]) + } + + static func childContents( + env: CostUsageTestEnvironment, + day: Date, + fixture: Child) throws -> String + { + let forkTimestamp = env.isoString(for: day) + var lines: [[String: Any]] = [ + [ + "type": "session_meta", + "timestamp": forkTimestamp, + "payload": [ + "id": fixture.sessionID, + "forked_from_id": fixture.parentID, + "timestamp": forkTimestamp, + "source": [ + "subagent": [ + "thread_spawn": ["parent_thread_id": fixture.parentID], + ], + ], + ], + ], + self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(0.1)), + total: fixture.prefix, + last: fixture.prefix), + ] + if let preBoundaryLast = fixture.preBoundaryLast { + lines.append(self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(0.2)), + last: preBoundaryLast)) + } + lines.append(contentsOf: [ + self.turnContext( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: fixture.leafModel), + [ + "type": "inter_agent_communication_metadata", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": ["trigger_turn": true], + ], + self.tokenCountWithoutModel( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + total: ( + input: fixture.prefix.input + fixture.suffix.input, + cached: fixture.prefix.cached + fixture.suffix.cached, + output: fixture.prefix.output + fixture.suffix.output), + last: fixture.suffix), + ]) + return try env.jsonl(lines) + } + + static func tokenCount( + timestamp: String, + model: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = ["model": model] + if let total { + info["total_token_usage"] = self.usagePayload(total) + } + if let last { + info["last_token_usage"] = self.usagePayload(last) + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + + private static func turnContext(timestamp: String, model: String) -> [String: Any] { + [ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": model], + ] + } + + private static func tokenCountWithoutModel( + timestamp: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = [:] + if let total { + info["total_token_usage"] = self.usagePayload(total) + } + if let last { + info["last_token_usage"] = self.usagePayload(last) + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + + private static func usagePayload(_ usage: Usage) -> [String: Any] { + [ + "input_tokens": usage.input, + "cached_input_tokens": usage.cached, + "output_tokens": usage.output, + ] + } +} diff --git a/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift b/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift index d97355e5f1..f7e8cdab69 100644 --- a/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift +++ b/Tests/CodexBarTests/CodexSubagentRolloutShapeTests.swift @@ -11,6 +11,94 @@ struct CodexSubagentRolloutShapeTests { #expect(shape.counterSemantics == .independent) } + @Test + func `single leaf first turn marker proposes a parent-confirmed suffix`() throws { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + let candidate = try #require(shape.ownedSuffixCandidate) + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffix == nil) + #expect(candidate.ownedSuffix.startLineIndex == 3) + #expect(candidate.parentTotalsAtBoundary.input == baseline.input) + #expect(candidate.parentTotalsAtBoundary.cached == baseline.cached) + #expect(candidate.parentTotalsAtBoundary.output == baseline.output) + } + + @Test + func `single leaf marker without an explicit parent stays independent`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ]) + + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `later marker after an earlier turn does not propose a suffix`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .turnContext), + .init(lineIndex: 2, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 3, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.counterSemantics == .independent) + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `zero pre-turn totals do not propose a suffix`() { + let zero = CostUsageCodexTotals(input: 0, cached: 0, output: 0) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: zero, last: zero)), + .init(lineIndex: 2, kind: .turnContext), + .init(lineIndex: 3, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.ownedSuffixCandidate == nil) + } + + @Test + func `nonadjacent first-turn trigger does not propose a suffix`() { + let baseline = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + let shape = CostUsageScanner.CodexSubagentRolloutShape.classify( + leafSessionID: "leaf", + observations: [ + .init(lineIndex: 0, kind: .sessionMetadata(id: "leaf")), + .init(lineIndex: 1, kind: .tokenCount(total: baseline, last: baseline)), + .init(lineIndex: 2, kind: .turnContext), + .init(lineIndex: 4, kind: .interAgentCommunication(triggerTurn: true)), + ], + hasExplicitParent: true) + + #expect(shape.ownedSuffixCandidate == nil) + } + @Test func `embedded ancestor metadata means a copied prefix`() { let shape = CostUsageScanner.CodexSubagentRolloutShape.classify(