Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion src/act/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ const HONEST_FOOTER =
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down.'

const MCP_KINDS = new Set<ActionKind>(['mcp-remove', 'mcp-project-scope'])
// defer-* re-enable native MCP tool deferral (part 2 of #614): the same
// prefix schema tokens mcp-remove eliminates, deferral moves out of the
// upfront prefix. Realized the same way — per-session schema tokens times the
// post-apply sessions that benefited — but "benefited" flips: instead of a
// server no longer loading, it is deferral having become active (the session
// now carries a deferred-tools inventory, the detector's own signal).
const DEFER_KINDS = new Set<ActionKind>(['defer-enable', 'defer-alwaysload', 'defer-threshold'])
const ARCHIVE_DEF_TOKENS: Partial<Record<ActionKind, number>> = {
'archive-skill': TOKENS_PER_SKILL_DEF,
'archive-agent': TOKENS_PER_AGENT_DEF,
Expand Down Expand Up @@ -178,6 +185,28 @@ function countSessionsLoading(projects: ProjectSummary[], servers: string[]): nu
return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length
}

// Deferral is active in a session exactly when Claude Code emitted a
// deferred-tools inventory for it — the same signal the mcp-deferral-off
// detector uses (its absence, alongside MCP overhead, is what flags a gap).
function sessionHasDeferralActive(s: SessionSummary): boolean {
return (s.mcpInventory?.length ?? 0) > 0
}

// MCP servers observed loading in the window (via inventory or invocation).
// defer-enable / defer-threshold re-enable deferral for the whole MCP surface
// rather than a named set, so the affected servers are derived here.
function observedMcpServers(projects: ProjectSummary[]): string[] {
const servers = new Set<string>()
for (const s of allSessions(projects)) {
for (const fqn of s.mcpInventory ?? []) {
const seg = fqn.split('__')[1]
if (seg) servers.add(seg)
}
for (const server of Object.keys(s.mcpBreakdown)) servers.add(server)
}
return [...servers]
}

// A kind whose realized effect is a token saving (everything except guard,
// which is a dollars/yield correlation, and out-of-scope kinds).
function isTokenKind(kind: ActionKind): boolean {
Expand Down Expand Up @@ -226,6 +255,33 @@ function mcpRow(
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence }
}

function deferRow(
base: ActReportRow, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
): ActReportRow {
const perSessionTokens = Object.values(baseline.metrics).reduce((a, b) => a + b, 0)
if (perSessionTokens === 0) return { ...base, note: 'not measurable: empty baseline' }
if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' }
const estimatedForWindow = Math.floor(perSessionTokens * sessions.length)
// A post-apply session realized the saving only if deferral actually became
// active in it. ENABLE_TOOL_SEARCH is read at process start, so sessions
// begun before the user restarted still run deferral-off — those aren't
// counted, and if none benefited we report it plainly rather than claim a
// saving that hasn't taken effect.
const deferredSessions = sessions.filter(sessionHasDeferralActive).length
const confidence = confidenceFor(sessions.length, baseline, afterStart, now)
if (deferredSessions === 0) {
return {
...base,
estimatedForWindow,
status: 'reverted',
confidence,
note: `not yet in effect: deferral is still inactive in ${sessions.length} post-apply session${sessions.length === 1 ? '' : 's'} (takes effect on the next session; the client may not have restarted, or the change was reverted)`,
}
}
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * deferredSessions), confidence }
}

function archiveRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
Expand Down Expand Up @@ -378,6 +434,7 @@ async function computeRow(
if (!baseline) return { ...base, note: 'not measurable: no baseline captured at apply time' }

if (MCP_KINDS.has(rec.kind)) return mcpRow(base, rec, sessions, baseline, afterStart, now)
if (DEFER_KINDS.has(rec.kind)) return deferRow(base, sessions, baseline, afterStart, now)
if (rec.kind in ARCHIVE_DEF_TOKENS) return archiveRow(base, rec, sessions, baseline, afterStart, now)
if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now)
if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' }
Expand Down Expand Up @@ -585,7 +642,15 @@ function mcpServersFromApply(finding: WasteFinding): string[] {
}

function needsConfigBaseline(kind: ActionKind): boolean {
return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
return MCP_KINDS.has(kind) || DEFER_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
}

// Servers whose upfront schema deferral removes from the prefix. defer-alwaysload
// names them; defer-enable / defer-threshold re-enable deferral across the whole
// observed MCP surface.
function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
if (finding.apply?.kind === 'defer-alwaysload') return finding.apply.servers.map(s => s.server)
return observedMcpServers(ctx.projects)
}

export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
Expand All @@ -608,6 +673,19 @@ export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: Ca
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}

if (DEFER_KINDS.has(kind)) {
const servers = deferServers(finding, ctx)
if (servers.length === 0) return undefined
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
const metrics: Record<string, number> = {}
for (const server of servers) {
const cov = covByServer.get(server)
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
metrics[server] = tools * TOKENS_PER_MCP_TOOL
}
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}

const defTokens = ARCHIVE_DEF_TOKENS[kind]
if (defTokens !== undefined) {
const names = finding.apply?.kind === 'archive' ? finding.apply.names : []
Expand Down
140 changes: 140 additions & 0 deletions tests/act-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { journalPath } from '../src/act/journal.js'
import {
buildActReportJson,
buildOptimizeAppliedHeader,
captureBaseline,
computeActReport,
renderActReport,
} from '../src/act/report.js'
import type { ActionRecord } from '../src/act/types.js'
import type { WasteFinding } from '../src/optimize.js'
import type { ClassifiedTurn, ProjectSummary } from '../src/types.js'

type Session = ProjectSummary['sessions'][number]
Expand Down Expand Up @@ -584,3 +586,141 @@ describe('json + render shape', () => {
expect(out).toMatch(/scaled to the measured window/)
})
})

// ---------------------------------------------------------------------------
// defer-* realized deltas (part 2 of #614)
// ---------------------------------------------------------------------------

function deferRecord(over: Partial<ActionRecord> = {}): ActionRecord {
const at = daysAgo(10)
return {
id: 'd1',
at,
kind: 'defer-enable',
findingId: 'mcp-deferral-off',
description: 'Remove the ENABLE_TOOL_SEARCH=false override from settings.json',
changes: [],
status: 'applied',
// 2 servers x 2000 tokens/session = 4000 prefix tokens/session.
baseline: { windowDays: 14, capturedAt: at, estimatedTokens: 40_000, sessions: 5, metrics: { everything: 2000, 'fs-tools': 2000 } },
...over,
}
}

// NOTE: an mcpInventory on a post-apply session means the OPPOSITE for defer-*
// vs mcp-remove. For mcp-remove it is "the server loaded again" (reverted);
// for defer-* it is "deferral became active" (saved). Same fixture, inverse
// meaning — exactly the design.
const DEFERRED = { mcpInventory: ['mcp__everything__get-sum'] }

describe('defer realized delta', () => {
it('measures savings across post-apply sessions where deferral became active', async () => {
const actionsDir = await writeJournal([deferRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(5, daysAgo(5), DEFERRED))]) })

const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(20_000) // 4000/session * 5 deferred sessions
expect(row.estimatedForWindow).toBe(20_000)
expect(report.totalRealizedTokens).toBe(20_000)
})

it('reports "not yet in effect" with zero savings when no post-apply session shows deferral', async () => {
const actionsDir = await writeJournal([deferRecord()])
// sessions with MCP activity but NO inventory = deferral still off
const off = sessionsAt(4, daysAgo(5), { mcpBreakdown: { everything: { calls: 2, savingsUSD: 0, costUSD: 0 } } })
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(off)]) })

const row = report.rows[0]!
expect(row.status).toBe('reverted')
expect(row.realizedTokens ?? 0).toBe(0)
expect(row.note).toMatch(/not yet in effect/)
expect(report.totalRealizedTokens).toBe(0)
})

it('counts only the sessions where deferral actually became active (partial)', async () => {
const actionsDir = await writeJournal([deferRecord()])
const active = sessionsAt(3, daysAgo(5), DEFERRED)
const stillOff = sessionsAt(2, daysAgo(4))
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([...active, ...stillOff])]) })

const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(12_000) // 4000 * 3 active (2 still-off excluded)
expect(row.estimatedForWindow).toBe(20_000) // 4000 * all 5 window sessions
})

it('is not measurable when no post-apply sessions exist yet', async () => {
const actionsDir = await writeJournal([deferRecord()])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf([])]) })
expect(report.rows[0]!.note).toMatch(/no sessions in the window yet/)
})

it('is not measurable with an empty baseline (zero prefix tokens)', async () => {
const rec = deferRecord({ baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 0, sessions: 5, metrics: { everything: 0 } } })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(5, daysAgo(5), DEFERRED))]) })
expect(report.rows[0]!.note).toMatch(/empty baseline/)
})

it('falls back to the no-baseline note for records applied before baselines existed', async () => {
const rec = deferRecord({ baseline: undefined })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(5, daysAgo(5), DEFERRED))]) })
expect(report.rows[0]!.note).toMatch(/no baseline captured at apply time/)
})

it('measures defer-alwaysload against its named servers', async () => {
const rec = deferRecord({
kind: 'defer-alwaysload',
findingId: 'mcp-alwaysload-hygiene',
description: 'Unpin an alwaysLoad MCP server',
baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 30_000, sessions: 6, metrics: { 'heavy-server': 5000 } },
})
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(6, daysAgo(5), DEFERRED))]) })
const row = report.rows[0]!
expect(row.status).toBe('measured')
expect(row.realizedTokens).toBe(30_000) // 5000 * 6
})

it('measures defer-threshold like the other defer kinds', async () => {
const rec = deferRecord({ kind: 'defer-threshold', findingId: 'mcp-defer-threshold', description: 'Tighten the auto threshold' })
const actionsDir = await writeJournal([rec])
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(5, daysAgo(5), DEFERRED))]) })
expect(report.rows[0]!.status).toBe('measured')
expect(report.rows[0]!.realizedTokens).toBe(20_000)
})
})

describe('defer baseline capture', () => {
const finding = (apply: WasteFinding['apply']): WasteFinding => ({
id: 'mcp-deferral-off',
title: 't', explanation: 'e', impact: 'medium', tokensSaved: 40_000,
fix: { type: 'command', label: 'l', text: 'x' },
apply,
})
const ctx = (projects: ProjectSummary[]) => ({ projects, coverage: [], windowDays: 14, now: NOW })

it('derives servers from observed MCP usage for defer-enable', () => {
const projects = [projectOf(sessionsAt(3, daysAgo(5), { mcpBreakdown: { everything: { calls: 2, savingsUSD: 0, costUSD: 0 }, 'fs-tools': { calls: 1, savingsUSD: 0, costUSD: 0 } } }))]
const b = captureBaseline(finding({ kind: 'defer-enable', cause: 'env-false', settingPath: '/x', settingScope: 'project settings', value: 'false' }), 'defer-enable', ctx(projects))
expect(b).toBeDefined()
// no coverage -> 5 tools x 400 fallback per server
expect(b!.metrics.everything).toBe(2000)
expect(b!.metrics['fs-tools']).toBe(2000)
})

it('uses the named servers for defer-alwaysload', () => {
const projects = [projectOf(sessionsAt(2, daysAgo(5)))]
const b = captureBaseline(finding({ kind: 'defer-alwaysload', servers: [{ server: 'pinned', paths: ['/a/.mcp.json'] }] }), 'defer-alwaysload', ctx(projects))
expect(b).toBeDefined()
expect(Object.keys(b!.metrics)).toEqual(['pinned'])
})

it('returns undefined when there is no observed MCP surface to defer', () => {
const projects = [projectOf(sessionsAt(3, daysAgo(5)))] // no mcpBreakdown, no inventory
const b = captureBaseline(finding({ kind: 'defer-enable', cause: 'env-false', settingPath: '/x', settingScope: 'project settings', value: 'false' }), 'defer-enable', ctx(projects))
expect(b).toBeUndefined()
})
})
Loading