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
42 changes: 42 additions & 0 deletions src/core/agent-compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/* Claude Code and Codex compactions, in the shape of the Copilot CLI events (`cli-compaction.ts`).
*
* Claude Code writes a `system` line with subtype `compact_boundary` and `compactMetadata`
* (`trigger`, `preTokens`, `postTokens`, `durationMs`).
* Codex logs no sizes: a compaction shows as a `compacted` line, a `compaction` response item, a
* `context_compacted` event or a `ContextCompaction` item, often several of them for one compaction. The size
* before is the prompt of the last model call and the limit its `model_context_window`.
* Only numbers and enum values are kept, never the summary. */

import type { CompactionEvent, SessionRequest } from './types';
import { readCliCompaction } from './cli-compaction';

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Map a Claude Code `compactMetadata` to a compaction event. */
export function readClaudeCompaction(metadata: unknown): CompactionEvent {
const data = isRecord(metadata) ? metadata : {};
return readCliCompaction({
contextLengthBefore: data.preTokens,
postCompactionTokens: data.postTokens,
trigger: data.trigger,
durationMs: data.durationMs,
});
}

/** A Codex compaction: the context of the last model call before it, and that call's limit when logged. */
export function codexCompaction(contextBefore: number, tokenLimit: number | null): CompactionEvent {
return readCliCompaction({ contextLengthBefore: contextBefore, ...(tokenLimit ? { tokenLimit } : {}) });
}

/** The request fields for a turn's compactions: `compaction` is the first, `compactions` all of them when several. */
export function requestCompactionFields(compactions: CompactionEvent[]): Partial<Pick<SessionRequest, 'compaction' | 'compactions'>> {
if (compactions.length === 0) return {};
return { compaction: compactions[0], ...(compactions.length > 1 ? { compactions: [...compactions] } : {}) };
}
29 changes: 24 additions & 5 deletions src/core/analyzer-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,9 @@ export class ContextAnalyzer extends AnalyzerBase {
const window = sessionContextWindow(s);
for (let i = 0; i < s.requests.length; i++) {
for (const compaction of requestCompactions(s.requests[i])) {
// Claude Code logs no window, and an estimate sized from the session's own peak
// puts every compaction near "full". Only a logged limit can show it.
if (compaction.tokenLimit == null && s.harness === 'Claude' && requestContextLimit(s.requests[i]) == null) continue;
const limit = compaction.tokenLimit ?? window.perRequest[i];
if (compaction.contextLengthBefore > 0 && (compaction.contextLengthBefore / limit) * 100 > 85) {
events.push({ session: s, tokensBefore: compaction.contextLengthBefore });
Expand Down Expand Up @@ -606,18 +609,25 @@ export class ContextAnalyzer extends AnalyzerBase {
});
}

// 4. Runaway Context Growth — sessions where token count grows steadily without stabilizing
const runawayEntries: { session: Session; first: number; last: number }[] = [];
// 4. Runaway Context Growth — sessions where token count grows steadily without stabilizing. A compaction
// resets the context, so only the requests after the session's last compaction count.
const runawayEntries: { session: Session; first: number; last: number; requests: number }[] = [];
for (const s of sessions) {
const sessionTokens = this.estimateSessionTokens(s);
const start = requestsAfterLastCompaction(s);
const sessionTokens = this.estimateSessionTokens(s).slice(start);
if (sessionTokens.length < CONTEXT_GROWING_SESSION_MIN_REQS) continue;
let increasing = 0;
for (let i = 1; i < sessionTokens.length; i++) {
if (sessionTokens[i] > sessionTokens[i - 1]) increasing++;
}
const growthRate = increasing / (sessionTokens.length - 1);
if (growthRate > CONTEXT_GROWING_SESSION_GROWTH_RATE) {
runawayEntries.push({ session: s, first: sessionTokens[0], last: sessionTokens[sessionTokens.length - 1] });
runawayEntries.push({
session: s,
first: sessionTokens[0],
last: sessionTokens[sessionTokens.length - 1],
requests: start === 0 ? s.requestCount : sessionTokens.length,
});
}
}
if (runawayEntries.length > 0) {
Expand All @@ -628,7 +638,7 @@ export class ContextAnalyzer extends AnalyzerBase {
message: `${Math.round(r.first / 1000)}K \u2192 ${Math.round(r.last / 1000)}K tokens`,
model: '',
kind: 'session' as const,
stats: { startK: Math.round(r.first / 1000), endK: Math.round(r.last / 1000), requests: r.session.requestCount },
stats: { startK: Math.round(r.first / 1000), endK: Math.round(r.last / 1000), requests: r.requests },
}));
patterns.push({
id: 'runaway-context',
Expand Down Expand Up @@ -969,6 +979,15 @@ export class ContextAnalyzer extends AnalyzerBase {
}
}

/** Index of the first request after the session's last successful compaction; 0 when it never compacted. The
* request that logged the compaction is left out: its last prompt can be from either side of it. */
function requestsAfterLastCompaction(session: Session): number {
for (let i = session.requests.length - 1; i >= 0; i--) {
if (requestCompactions(session.requests[i]).some(compaction => compaction.outcome !== 'failed')) return i + 1;
}
return 0;
}

/** Adds the timestamps of the session's requests that carry composition but no per-request tokens. They add a
* range for the composition the tab charts, but they aren't per-request token data. A request with tokens, the
* prompt of its last model call included (`requestContextTokens`), is already counted. */
Expand Down
2 changes: 1 addition & 1 deletion src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export interface CacheData {
const cacheFilePath = (): string => path.join(getCacheDir(), 'parsed.json');
const cacheMetaPath = (): string => path.join(getCacheDir(), 'meta.json');

const CACHE_VERSION = 30;
const CACHE_VERSION = 31;

/** Refuse to JSON.parse cache files beyond these sizes: a corrupted (or
* tampered) cache must degrade to a full re-parse, not OOM the host. */
Expand Down
42 changes: 42 additions & 0 deletions src/core/context-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,19 @@ describe('anti-pattern: context-bloat', () => {
expect(bloat!.occurrences).toBe(1);
});

it('does not flag amnesia from an estimated Claude Code window', () => {
const compacted = (harness: string) => {
const requests = flatTokenRequests(CONTEXT_MIN_TOKEN_REQUESTS + 1, 400_000);
requests[3].compaction = {
mode: 'full', numRounds: 1, numRoundsSinceLastSummarization: 1, contextLengthBefore: 400_000,
durationMs: 1, model: 'm', outcome: 'success',
};
return getCtxMgmt([sess({ harness, requests })]).antiPatterns.find(p => p.id === 'context-amnesia');
};
expect(compacted('Claude')).toBeUndefined();
expect(compacted('Codex')).toBeDefined();
});

it('does not trigger for optimal sessions', () => {
const sessions = [
sess({ requests: flatTokenRequests(CONTEXT_MIN_TOKEN_REQUESTS + 1, 30_000) }),
Expand Down Expand Up @@ -751,6 +764,35 @@ describe('anti-pattern: runaway-context', () => {
const runaway = data.antiPatterns.find(p => p.id === 'runaway-context');
expect(runaway).toBeUndefined();
});

it('treats a compaction as a reset: growth before it does not count', () => {
// Steady growth for 10 requests, a compaction, then 4 more growing requests: too few after the reset.
const reqs = [
...linearTokenRequests(10, 10_000, 5_000),
...linearTokenRequests(4, 8_000, 2_000, BASE_TS + 10 * 60_000).map((r, i) => ({ ...r, requestId: `after-${i}` })),
];
reqs[9] = { ...reqs[9], compaction: compaction({ contextLengthBefore: 55_000, contextLengthAfter: 6_000, trigger: 'auto' }) };
const data = getCtxMgmt([sess({ requests: reqs })]);
expect(data.antiPatterns.find(p => p.id === 'runaway-context')).toBeUndefined();
});

it('still flags steady growth after the last compaction, measured from the reset', () => {
const before = linearTokenRequests(3, 50_000, 10_000);
before[2] = { ...before[2], compaction: compaction({ contextLengthBefore: 70_000, contextLengthAfter: 8_000 }) };
const after = linearTokenRequests(CONTEXT_GROWING_SESSION_MIN_REQS + 1, 10_000, 5_000, BASE_TS + 3 * 60_000)
.map((r, i) => ({ ...r, requestId: `after-${i}` }));
const data = getCtxMgmt([sess({ requests: [...before, ...after] })]);
const runaway = data.antiPatterns.find(p => p.id === 'runaway-context');
expect(runaway?.occurrences).toBe(1);
expect(runaway?.details?.[0].stats).toEqual({ startK: 10, endK: 50, requests: CONTEXT_GROWING_SESSION_MIN_REQS + 1 });
});

it('ignores a failed compaction', () => {
const reqs = linearTokenRequests(CONTEXT_GROWING_SESSION_MIN_REQS + 2, 10_000, 5_000);
reqs[5] = { ...reqs[5], compaction: compaction({ outcome: 'failed' }) };
const data = getCtxMgmt([sess({ requests: reqs })]);
expect(data.antiPatterns.find(p => p.id === 'runaway-context')?.occurrences).toBe(1);
});
});

/* ── Tips generation ─────────────────────────────────────────────── */
Expand Down
82 changes: 82 additions & 0 deletions src/core/parser-claude-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/* Claude Code compactions: a `system` line with subtype `compact_boundary` and `compactMetadata`. The
* fixtures are synthetic, in the shape Claude Code writes. */

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
import { parseClaudeSessions } from './parser-claude';
import { requestCompactions } from './context-window';
import { SessionSchema } from './schemas';
import type { Session } from './types';

const SESSION = 'sess-compact';

function parseLines(lines: object[]): Session {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-compaction-test-'));
const projDir = path.join(root, 'projects', '-Users-me-proj');
fs.mkdirSync(projDir, { recursive: true });
fs.writeFileSync(path.join(projDir, `${SESSION}.jsonl`), lines.map(l => JSON.stringify(l)).join('\n'), 'utf-8');
try {
return parseClaudeSessions(path.join(root, 'projects'))[0].sessions[0];
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}

function user(text: string, ts: string, extra: Record<string, unknown> = {}): object {
return { type: 'user', timestamp: ts, sessionId: SESSION, cwd: '/Users/me/proj', ...extra, message: { role: 'user', content: [{ type: 'text', text }] } };
}

function assistant(id: string, ts: string, input: number): object {
return {
type: 'assistant', timestamp: ts, sessionId: SESSION,
message: { id, role: 'assistant', model: 'claude-sonnet-4-6', content: [{ type: 'text', text: 'done' }], usage: { input_tokens: input, output_tokens: 10 } },
};
}

function boundary(ts: string, metadata: Record<string, unknown>): object {
return { type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', timestamp: ts, sessionId: SESSION, compactMetadata: metadata };
}

describe('Claude Code compaction', () => {
it('records a compact_boundary on the request it was logged in, with its sizes and trigger', () => {
const session = parseLines([
user('first task', '2026-11-01T10:00:00Z'),
assistant('m1', '2026-11-01T10:00:05Z', 300_000),
boundary('2026-11-01T10:00:06Z', { trigger: 'auto', preTokens: 300_000, postTokens: 12_000, durationMs: 40 }),
user('This session is being continued from a previous conversation.', '2026-11-01T10:00:07Z', { isCompactSummary: true }),
assistant('m2', '2026-11-01T10:00:09Z', 12_000),
user('next task', '2026-11-01T10:01:00Z'),
assistant('m3', '2026-11-01T10:01:05Z', 15_000),
]);
expect(session.requests).toHaveLength(3);
expect(session.requests[0].compaction).toMatchObject({
contextLengthBefore: 300_000, contextLengthAfter: 12_000, trigger: 'auto', durationMs: 40, outcome: 'success',
});
expect(session.requests[0].compactions).toBeUndefined();
expect(session.requests[1].compaction).toBeNull();
expect(session.requests[2].compaction).toBeNull();
expect(SessionSchema.safeParse(session).success).toBe(true);
});

it('keeps every compaction of a turn, and gives one logged before the first prompt to the first request', () => {
const session = parseLines([
boundary('2026-11-01T09:59:00Z', { trigger: 'manual', preTokens: 90_000 }),
user('long task', '2026-11-01T10:00:00Z'),
assistant('m1', '2026-11-01T10:00:05Z', 200_000),
boundary('2026-11-01T10:00:06Z', { trigger: 'auto', preTokens: 200_000, postTokens: 9_000 }),
assistant('m2', '2026-11-01T10:00:09Z', 9_000),
]);
const all = requestCompactions(session.requests[0]);
expect(all.map(c => [c.trigger, c.contextLengthBefore, c.contextLengthAfter])).toEqual([
['manual', 90_000, undefined],
['auto', 200_000, 9_000],
]);
});
});
31 changes: 30 additions & 1 deletion src/core/parser-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { Session, SessionRequest } from './types';
import { CompactionEvent, Session, SessionRequest } from './types';
import { assertTrustedPath, readFileSafe, createRequest, createSession, detectDevcontainerFromRequests, extractSkillNameFromPath } from './parser-shared';
import { extractReasoningEffortFromModelId } from './helpers';
import { warnCore } from './log';
Expand All @@ -36,6 +36,8 @@ import { imageReferencesFromContent, type ImageReference, type ImageRequestMatch
import { ClaudeResponseAccumulator, type ClaudeRequestUsage } from './parser-claude-responses';
import type { SessionSource } from './cache';
import { forEachJsonlLine } from './parser-vscode-files';
import { readClaudeCompaction, requestCompactionFields } from './agent-compaction';
import { addTurnCompaction } from './cli-compaction';

interface ClaudeContentBlock {
type: string;
Expand Down Expand Up @@ -85,6 +87,10 @@ interface ClaudeLine {
/** Subagent identifier on `subagents/` files. Present alongside
* `isSidechain: true`. Not used for billing math; surfaced for diagnostics. */
agentId?: string;
/** `system` lines: `compact_boundary` marks a compaction. */
subtype?: unknown;
/** On a `compact_boundary` line: `trigger`, `preTokens`, `postTokens`, `durationMs`. */
compactMetadata?: unknown;
message?: ClaudeMessage;
}

Expand Down Expand Up @@ -120,6 +126,20 @@ interface ClaudeAssistantData {
answerEvidence: 'final' | 'legacy' | 'missing';
investigationDelegations: ('research' | 'explore')[];
webDomains: string[];
/** Compactions logged during the request (`compact_boundary` lines), in order. */
compactions: CompactionEvent[];
}

/** A `compact_boundary` line's compaction, or `null` for any other line. */
function claudeCompaction(line: ClaudeLine): CompactionEvent | null {
return line.type === 'system' && line.subtype === 'compact_boundary' ? readClaudeCompaction(line.compactMetadata) : null;
}

/** Put compactions logged before the request's prompt ahead of its own, within the cap. */
function prependCompactions(data: ClaudeAssistantData, earlier: CompactionEvent[]): void {
if (earlier.length === 0) return;
const own = data.compactions.splice(0);
for (const compaction of [...earlier, ...own]) addTurnCompaction(data.compactions, compaction);
}

/** Session-wide prompt-cache state across Claude requests. */
Expand Down Expand Up @@ -415,13 +435,16 @@ function collectClaudeAssistantData(
answerEvidence: 'missing',
investigationDelegations: [],
webDomains: [],
compactions: [],
};

const toolResults = collectClaudeToolResults(lines, startIndex);
let i = startIndex;
while (i < lines.length) {
const next = lines[i];
if (next.type === 'user' && userHasText(next)) break;
const compaction = claudeCompaction(next);
if (compaction) addTurnCompaction(data.compactions, compaction);
if (next.type === 'assistant') {
const blocks = toContentArray(next.message?.content);
const hasText = blocks.some(block => block.type === 'text' && block.text?.trim());
Expand Down Expand Up @@ -808,6 +831,7 @@ function buildClaudeRequest(
...(usage.promptCache ? { promptCache: usage.promptCache } : {}),
...(usage.callRowsWithoutCacheData ? { callRowsWithoutCacheData: usage.callRowsWithoutCacheData } : {}),
...(usage.contextUsage ? { contextUsage: usage.contextUsage } : {}),
...requestCompactionFields(assistantData.compactions),
});
mergeRequestEditLoc(editLocIndex, requestId, assistantData.editLocs);
return request;
Expand Down Expand Up @@ -841,11 +865,15 @@ export function parseClaudeSessionFile(
let firstTs: number | null = null;
let lastTs: number | null = null;
let entrypoint: string | undefined;
// Compactions logged before the first prompt go to the first request.
const earlyCompactions: CompactionEvent[] = [];
let i = 0;

while (i < lines.length) {
const line = lines[i];
if (line.type !== 'user' || !userHasText(line)) {
const compaction = requests.length === 0 ? claudeCompaction(line) : null;
if (compaction) addTurnCompaction(earlyCompactions, compaction);
i++;
continue;
}
Expand All @@ -858,6 +886,7 @@ export function parseClaudeSessionFile(
({ firstTs, lastTs } = updateClaudeTimestampRange(userTs, firstTs, lastTs));

const assistantData = collectClaudeAssistantData(lines, i, lastTs, cacheState);
if (requests.length === 0) prependCompactions(assistantData, earlyCompactions);
lastTs = assistantData.lastTs;
const requestId = claudeRequestId(line, sessionId, requests.length, transcriptKey);
const request = buildClaudeRequest(line, assistantData, userTs, requestId, editLocIndex);
Expand Down
Loading
Loading