mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
fix(optimize): exclude sidechains from session heuristics (#974)
This commit is contained in:
parent
c9e6e2ecae
commit
7187dd0da0
7 changed files with 301 additions and 14 deletions
|
|
@ -156,6 +156,12 @@ codeburn optimize --format json # setup health + findings as JSON
|
|||
|
||||
`codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns:
|
||||
|
||||
For Claude Code, the optimize session count and session-level findings below
|
||||
use user-started (main) sessions. Subagent sidechain transcripts are excluded
|
||||
from that population because their delegated context and delivery behavior are
|
||||
structurally different; their tokens, calls, and cost still count in all spend
|
||||
totals and configuration-overhead findings.
|
||||
|
||||
- Files Claude re-reads across sessions (same content, same context, over and over)
|
||||
- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
|
||||
- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import chalk from 'chalk'
|
||||
import { isReadShapedBashCommand } from './bash-utils.js'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { existsSync, statSync } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { isReadShapedBashCommand } from './bash-utils.js'
|
||||
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
|
||||
import { discoverAllSessions } from './providers/index.js'
|
||||
import { parseJsonlLine, shouldSkipLine } from './parser.js'
|
||||
|
|
@ -2484,6 +2485,22 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number
|
|||
+ session.totalCacheWriteTokens
|
||||
}
|
||||
|
||||
// Sidechain transcripts are real usage, so they stay in project totals and in
|
||||
// token/cost calibration. They are not user-started sessions, however, and
|
||||
// should never enter optimize heuristics whose unit is a human work session.
|
||||
// Keep that distinction local to optimize instead of deleting sidechains from
|
||||
// ProjectSummary, which would under-report the work delegated to subagents.
|
||||
function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean {
|
||||
return session.isSidechain !== true
|
||||
}
|
||||
|
||||
function optimizeSessionCount(projects: ProjectSummary[]): number {
|
||||
return projects.reduce(
|
||||
(total, project) => total + project.sessions.filter(isOptimizeSession).length,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number {
|
||||
return session.totalInputTokens
|
||||
+ session.totalCacheReadTokens * CACHE_READ_DISCOUNT
|
||||
|
|
@ -2592,6 +2609,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand
|
|||
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
if (!isOptimizeSession(session)) continue
|
||||
if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue
|
||||
if (sessionDeliveryCommand(session)) continue
|
||||
|
||||
|
|
@ -2692,7 +2710,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB
|
|||
const candidates: ContextBloatCandidate[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const sessions = [...project.sessions].sort((a, b) =>
|
||||
const sessions = project.sessions.filter(isOptimizeSession).sort((a, b) =>
|
||||
new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime()
|
||||
)
|
||||
let previousInputTokens: number | null = null
|
||||
|
|
@ -2805,7 +2823,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio
|
|||
const outliers: Outlier[] = []
|
||||
|
||||
for (const project of projects) {
|
||||
const sessions = project.sessions.filter(s => s.totalCostUSD > 0)
|
||||
const sessions = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0)
|
||||
if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue
|
||||
|
||||
const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0)
|
||||
|
|
@ -2866,7 +2884,7 @@ function findYoungProjectFirstSessionIds(projects: ProjectSummary[]): Set<string
|
|||
const firstSessionIds = new Set<string>()
|
||||
|
||||
for (const project of projects) {
|
||||
const costed = project.sessions.filter(s => s.totalCostUSD > 0)
|
||||
const costed = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0)
|
||||
if (costed.length >= YOUNG_PROJECT_SESSION_LIMIT) continue
|
||||
|
||||
let firstSession: ProjectSummary['sessions'][number] | null = null
|
||||
|
|
@ -2985,15 +3003,25 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde
|
|||
// stale findings when cost/tokens moved (e.g. a re-price) while call count
|
||||
// held - reachable in the long-lived menubar process within the 60s TTL.
|
||||
// Cost is scaled to whole micro-dollars so float jitter cannot thrash the key.
|
||||
let calls = 0, cost = 0, savings = 0, proxied = 0
|
||||
let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0
|
||||
const sidechainIdentities: string[] = []
|
||||
for (const p of projects) {
|
||||
calls += p.totalApiCalls
|
||||
cost += p.totalCostUSD
|
||||
savings += p.totalSavingsUSD
|
||||
proxied += p.totalProxiedCostUSD
|
||||
sessions += p.sessions.length
|
||||
for (const session of p.sessions) {
|
||||
if (session.isSidechain !== true) continue
|
||||
sidechains++
|
||||
sidechainIdentities.push(`${p.projectPath}\0${session.sessionId}`)
|
||||
}
|
||||
}
|
||||
const sidechainDigest = createHash('sha256')
|
||||
.update(sidechainIdentities.sort().join('\0'))
|
||||
.digest('base64url')
|
||||
// Costs scaled to whole micro-dollars so float jitter cannot thrash the key.
|
||||
const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}`
|
||||
const fingerprint = `${projects.length}:${sessions}:${sidechains}:${sidechainDigest}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}`
|
||||
return `${dr}:${fingerprint}`
|
||||
}
|
||||
|
||||
|
|
@ -3205,7 +3233,7 @@ function renderOptimize(
|
|||
|
||||
const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : ''
|
||||
lines.push(' ' + [
|
||||
`${sessionCount} sessions`,
|
||||
`${sessionCount} session${sessionCount === 1 ? '' : 's'}`,
|
||||
`${callCount.toLocaleString()} calls`,
|
||||
chalk.hex(GOLD)(formatCost(periodCost)),
|
||||
`Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`,
|
||||
|
|
@ -3295,7 +3323,7 @@ export async function runOptimize(
|
|||
|
||||
const result = await scanAndDetect(projects, dateRange)
|
||||
const { findings, costRate, healthScore, healthGrade } = result
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const sessionCount = optimizeSessionCount(projects)
|
||||
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
|
||||
|
|
@ -3305,7 +3333,7 @@ export async function runOptimize(
|
|||
}
|
||||
|
||||
const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
|
||||
const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations)
|
||||
console.log(output)
|
||||
}
|
||||
|
||||
|
|
@ -3315,7 +3343,6 @@ export function buildOptimizeJsonReport(
|
|||
result: OptimizeResult,
|
||||
dateRange?: DateRange,
|
||||
): OptimizeJsonReport {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
|
||||
const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0)
|
||||
|
|
@ -3335,7 +3362,7 @@ export function buildOptimizeJsonReport(
|
|||
healthGrade: result.healthGrade,
|
||||
findingCount: result.findings.length,
|
||||
periodCostUSD,
|
||||
sessions: sessions.length,
|
||||
sessions: optimizeSessionCount(projects),
|
||||
calls,
|
||||
potentialSavingsTokens,
|
||||
potentialSavingsCostUSD,
|
||||
|
|
|
|||
|
|
@ -2251,6 +2251,7 @@ async function scanProjectDirs(
|
|||
// on a resumed session) and derive the agent id from the `agent-<agentId>`
|
||||
// filename. A sidechain whose parent id was never captured stays standalone.
|
||||
if (cachedFile.isSidechain) {
|
||||
session.isSidechain = true
|
||||
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
|
||||
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
|
||||
}
|
||||
|
|
@ -3327,6 +3328,7 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary):
|
|||
if (original.prLinks?.length) rebuilt.prLinks = original.prLinks
|
||||
if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource
|
||||
if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory
|
||||
if (original.isSidechain) rebuilt.isSidechain = true
|
||||
// prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at
|
||||
// the new boundary (see recomputeRangeStartPrRefs), not the wide range's value.
|
||||
if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export type CachedFile = {
|
|||
// is re-parsed only when the file changes (fingerprint differs). Carries no
|
||||
// turns, so it contributes no usage. (issue #441 follow-up)
|
||||
failed?: boolean
|
||||
// Rich-session-capture, Claude session-level (capture-only; no report yet).
|
||||
// Rich-session-capture, Claude session-level.
|
||||
// `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every
|
||||
// `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain:
|
||||
// parentUuid references an intra-file entry uuid, not another session id, so it
|
||||
|
|
|
|||
|
|
@ -205,6 +205,11 @@ export type SessionSummary = {
|
|||
/// correlations performed after all saved sessions have been parsed.
|
||||
prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt'
|
||||
source?: SessionSourceMetadata
|
||||
/// Claude Code only: true when this record is a subagent (sidechain)
|
||||
/// transcript rather than a user-started parent session. Sidechain spend is
|
||||
/// real and remains in every cost/token/call aggregate; consumers that reason
|
||||
/// about human session populations may exclude it explicitly.
|
||||
isSidechain?: boolean
|
||||
// Claude Code only: agent type of a subagent transcript session
|
||||
// (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for
|
||||
// ordinary sessions. Drives the Claude-scoped agent-type breakdown.
|
||||
|
|
|
|||
237
tests/optimize-sidechains.test.ts
Normal file
237
tests/optimize-sidechains.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
type ProvidersModule = typeof import('../src/providers/index.js')
|
||||
const actual = await importOriginal<ProvidersModule>()
|
||||
return {
|
||||
...actual,
|
||||
async discoverAllSessions() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
buildOptimizeJsonReport,
|
||||
cacheKey,
|
||||
computeInputCostRate,
|
||||
detectSessionOutliers,
|
||||
findContextBloatCandidates,
|
||||
findLowWorthCandidates,
|
||||
runOptimize,
|
||||
scanAndDetect,
|
||||
type OptimizeResult,
|
||||
} from '../src/optimize.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
function session(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionSummary> = {},
|
||||
): SessionSummary {
|
||||
return {
|
||||
sessionId,
|
||||
project: 'app',
|
||||
firstTimestamp: '2026-08-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-08-01T10:30:00.000Z',
|
||||
totalCostUSD: 1,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 1_000,
|
||||
totalOutputTokens: 1_000,
|
||||
totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function sidechain(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionSummary> = {},
|
||||
): SessionSummary {
|
||||
return session(sessionId, {
|
||||
isSidechain: true,
|
||||
parentSessionId: 'parent-session',
|
||||
agentId: sessionId.replace(/^agent-/, ''),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function project(sessions: SessionSummary[]): ProjectSummary {
|
||||
return {
|
||||
project: 'app',
|
||||
projectPath: '/tmp/app',
|
||||
sessions,
|
||||
totalCostUSD: sessions.reduce((sum, item) => sum + item.totalCostUSD, 0),
|
||||
totalSavingsUSD: sessions.reduce((sum, item) => sum + item.totalSavingsUSD, 0),
|
||||
totalApiCalls: sessions.reduce((sum, item) => sum + item.apiCalls, 0),
|
||||
totalProxiedCostUSD: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('optimize sidechain population (issue #974)', () => {
|
||||
it('keeps sidechain spend out of the low-worth candidate population', () => {
|
||||
const parent = session('parent', {
|
||||
totalCostUSD: 4,
|
||||
turns: [],
|
||||
})
|
||||
const child = sidechain('agent-child', {
|
||||
totalCostUSD: 12,
|
||||
turns: [],
|
||||
})
|
||||
|
||||
expect(findLowWorthCandidates([project([parent, child])]).map(item => item.sessionId))
|
||||
.toEqual(['parent'])
|
||||
})
|
||||
|
||||
it('does not use a sidechain as a context-heavy candidate or growth baseline', () => {
|
||||
const baseline = session('parent-baseline', {
|
||||
firstTimestamp: '2026-08-01T10:00:00.000Z',
|
||||
totalInputTokens: 20_000,
|
||||
totalOutputTokens: 2_000,
|
||||
})
|
||||
const child = sidechain('agent-child', {
|
||||
firstTimestamp: '2026-08-02T10:00:00.000Z',
|
||||
totalInputTokens: 200_000,
|
||||
totalOutputTokens: 100,
|
||||
})
|
||||
const candidate = session('parent-candidate', {
|
||||
firstTimestamp: '2026-08-03T10:00:00.000Z',
|
||||
totalInputTokens: 100_000,
|
||||
totalOutputTokens: 2_000,
|
||||
})
|
||||
|
||||
const candidates = findContextBloatCandidates([project([baseline, child, candidate])])
|
||||
|
||||
expect(candidates.map(item => item.sessionId)).toEqual(['parent-candidate'])
|
||||
expect(candidates[0]!.growthRatio).toBe(5)
|
||||
})
|
||||
|
||||
it('does not let a sidechain satisfy the peer-sample minimum for cost outliers', () => {
|
||||
const sessions = [
|
||||
session('parent-cheap', { totalCostUSD: 1 }),
|
||||
session('parent-expensive', { totalCostUSD: 10 }),
|
||||
sidechain('agent-cheap', { totalCostUSD: 1 }),
|
||||
]
|
||||
|
||||
expect(detectSessionOutliers([project(sessions)])).toBeNull()
|
||||
})
|
||||
|
||||
it('never reports an expensive sidechain as a parent-session cost outlier', () => {
|
||||
const sessions = [
|
||||
session('parent-1', { totalCostUSD: 1 }),
|
||||
session('parent-2', { totalCostUSD: 1 }),
|
||||
session('parent-3', { totalCostUSD: 1 }),
|
||||
sidechain('agent-expensive', { totalCostUSD: 100 }),
|
||||
]
|
||||
|
||||
expect(detectSessionOutliers([project(sessions)])).toBeNull()
|
||||
})
|
||||
|
||||
it('counts only parent sessions while conserving sidechain cost, calls, and tokens', () => {
|
||||
const projects = [project([
|
||||
session('parent', {
|
||||
totalCostUSD: 3,
|
||||
totalInputTokens: 100,
|
||||
totalOutputTokens: 20,
|
||||
apiCalls: 2,
|
||||
}),
|
||||
sidechain('agent-child', {
|
||||
totalCostUSD: 7,
|
||||
totalInputTokens: 900,
|
||||
totalOutputTokens: 80,
|
||||
apiCalls: 4,
|
||||
}),
|
||||
])]
|
||||
const result: OptimizeResult = {
|
||||
findings: [],
|
||||
costRate: computeInputCostRate(projects),
|
||||
healthScore: 100,
|
||||
healthGrade: 'A',
|
||||
}
|
||||
|
||||
const report = buildOptimizeJsonReport(projects, 'fixture', result)
|
||||
|
||||
expect(report.summary.sessions).toBe(1)
|
||||
expect(report.summary.periodCostUSD).toBe(10)
|
||||
expect(report.summary.calls).toBe(6)
|
||||
// Input-cost calibration keeps all spend and all input/cache tokens:
|
||||
// ($10 * 0.7) / (100 + 900) tokens.
|
||||
expect(report.summary.costRateUSD).toBeCloseTo(0.007, 12)
|
||||
})
|
||||
|
||||
it('keeps sidechain spend out of every per-session finding in the optimize pipeline', async () => {
|
||||
const projects = [project([
|
||||
sidechain('agent-only', {
|
||||
totalCostUSD: 100,
|
||||
totalInputTokens: 1_000_000,
|
||||
totalOutputTokens: 100,
|
||||
}),
|
||||
])]
|
||||
|
||||
const result = await scanAndDetect(projects, {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('low-worth-sessions')
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('context-heavy-sessions')
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('cost-outliers')
|
||||
})
|
||||
|
||||
it('uses the parent-session count in the text optimize headline', async () => {
|
||||
const projects = [project([
|
||||
session('parent', {
|
||||
totalCostUSD: 1,
|
||||
bashBreakdown: { 'git commit -m shipped': { calls: 1 } },
|
||||
}),
|
||||
sidechain('agent-child', {
|
||||
totalCostUSD: 2,
|
||||
bashBreakdown: { 'git commit -m irrelevant': { calls: 1 } },
|
||||
}),
|
||||
])]
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
try {
|
||||
await runOptimize(projects, 'fixture', {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
})
|
||||
const output = String(log.mock.calls.at(-1)?.[0] ?? '')
|
||||
expect(output).toContain('1 session')
|
||||
expect(output).not.toContain('2 sessions')
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('separates cached optimize results when only sidechain classification changes', () => {
|
||||
const range = {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
}
|
||||
const parentOnly = project([session('same')])
|
||||
const sidechainOnly = project([sidechain('same')])
|
||||
|
||||
expect(parentOnly.totalCostUSD).toBe(sidechainOnly.totalCostUSD)
|
||||
expect(parentOnly.totalApiCalls).toBe(sidechainOnly.totalApiCalls)
|
||||
expect(cacheKey([parentOnly], range)).not.toBe(cacheKey([sidechainOnly], range))
|
||||
|
||||
const firstSidechain = project([session('first'), sidechain('second')])
|
||||
const secondSidechain = project([sidechain('first'), session('second')])
|
||||
expect(firstSidechain.sessions.length).toBe(secondSidechain.sessions.length)
|
||||
expect(firstSidechain.totalCostUSD).toBe(secondSidechain.totalCostUSD)
|
||||
expect(firstSidechain.sessions.filter(item => item.isSidechain).length)
|
||||
.toBe(secondSidechain.sessions.filter(item => item.isSidechain).length)
|
||||
expect(cacheKey([firstSidechain], range)).not.toBe(cacheKey([secondSidechain], range))
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import { join } from 'path'
|
|||
import { tmpdir } from 'os'
|
||||
|
||||
import { parseAllSessions, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
|
||||
import { clearLoadCacheMemo } from '../src/session-cache.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js'
|
||||
|
||||
|
|
@ -64,8 +65,16 @@ describe('subagent fold across a date-range boundary', () => {
|
|||
const projects = await parseAllSessions(range, 'claude')
|
||||
|
||||
// The child session is present (its work is in range) as a standalone session.
|
||||
const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`))
|
||||
expect(childPresent).toBe(true)
|
||||
const child = projects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.isSidechain).toBe(true)
|
||||
// Drop both in-process memo layers so the second parse reloads the persisted
|
||||
// session cache. The marker must survive that warm-disk path too, not only
|
||||
// the cold transcript parse.
|
||||
clearSessionCache()
|
||||
clearLoadCacheMemo()
|
||||
const warmProjects = await parseAllSessions(range, 'claude')
|
||||
expect(warmProjects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true)
|
||||
// The anchor parent (0 in-range turns) must NOT contaminate the sessions list;
|
||||
// it lives in subagentAnchors only.
|
||||
const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT))
|
||||
|
|
@ -101,6 +110,7 @@ describe('subagent fold across a date-range boundary', () => {
|
|||
const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20']))
|
||||
expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session
|
||||
expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor
|
||||
expect(dayFiltered.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true)
|
||||
|
||||
// The child's spend still folds to the PR through the anchor.
|
||||
const row = aggregateByPr(dayFiltered).find(r => r.url === PR)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue