Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ead17f0
feat(cli): report claude compact completion with token delta
junmo-kim Aug 23, 2026
ae947fe
fix(cli): hide local-command tag user messages from chat
junmo-kim Aug 23, 2026
f5cc440
feat(web): collapse compact summary card by default
junmo-kim Aug 23, 2026
bdd4908
feat(cli): promote claude compact summary from the local transcript
junmo-kim Aug 23, 2026
3a88e7e
fix(cli): don't double-render the manual /compact boundary as a statu…
junmo-kim Aug 24, 2026
d39481f
fix(cli): detect later-turn /compact and ignore stale transcript summ…
junmo-kim Aug 26, 2026
6cd801c
fix(cli): establish the compact transcript baseline before the result…
junmo-kim Aug 26, 2026
947136a
fix(cli): narrow the local-command meta filter to the compact stdout …
junmo-kim Aug 26, 2026
2b6d720
fix(cli): range-read only the transcript tail when polling for the co…
junmo-kim Aug 26, 2026
99617c2
fix(cli): complete the transcript tail read before parsing the compac…
junmo-kim Aug 26, 2026
f66e394
fix(cli): refresh the context bar with post-compaction tokens after /…
junmo-kim Aug 26, 2026
5ee8484
fix(cli): scope the Compacted stdout suppression to the active compac…
junmo-kim Aug 26, 2026
f892c8c
fix(cli): make compact lifecycle ownership race-safe
junmo-kim Aug 26, 2026
fc92134
fix(cli): publish compact outcome before accepting the next turn
junmo-kim Aug 27, 2026
f1f303e
fix(cli): cancel compact completion with its failed stream attempt
junmo-kim Aug 27, 2026
93b2110
fix(cli): propagate deferred compact completion failures
junmo-kim Aug 27, 2026
1a60a87
refactor(cli): coordinate compact completion in the response loop
junmo-kim Aug 27, 2026
2368096
fix(cli): correlate compact completion with its stream signal
junmo-kim Aug 27, 2026
8486600
fix(cli): commit compact ownership when its result arrives
junmo-kim Aug 27, 2026
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
982 changes: 973 additions & 9 deletions cli/src/claude/claudeRemote.test.ts

Large diffs are not rendered by default.

296 changes: 266 additions & 30 deletions cli/src/claude/claudeRemote.ts

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions cli/src/claude/claudeRemoteLauncher.launchFailure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ describe('claudeRemoteLauncher launch-failure recovery', () => {
expect(restoredMessageText).toBe('hello');
});

it('does not restore /compact after its SDK result was accepted', async () => {
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
queue.push('/compact', { permissionMode: 'default' });

const client = makeClient();
const session = makeSession(queue, client);
let callCount = 0;
let queueSizeOnSecondAttempt: number | undefined;

claudeRemoteMock.mockImplementation(async (opts: any) => {
callCount += 1;
if (callCount === 1) {
const msg = await opts.nextMessage();
expect(msg?.message).toBe('/compact');
opts.onCompactResultAccepted();
throw new Error('stream failed after compact result');
}

queueSizeOnSecondAttempt = session.queue.size();
triggerSwitch(client);
throw new Error('stop test');
});

const { claudeRemoteLauncher } = await import('./claudeRemoteLauncher');
await claudeRemoteLauncher(session);

expect(callCount).toBe(2);
expect(queueSizeOnSecondAttempt).toBe(0);
});

it('sends the result carrier before compact completion and ready', async () => {
const queue = new MessageQueue2<EnhancedMode>((mode) => JSON.stringify(mode));
queue.push('/compact', { permissionMode: 'default' });
Expand Down
32 changes: 30 additions & 2 deletions cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from "react";
import { Session } from "./session";
import { RemoteModeDisplay } from "@/ui/ink/RemoteModeDisplay";
import { claudeRemote } from "./claudeRemote";
import { claudeRemote, type CompactSummaryPayload } from "./claudeRemote";
import { convertAgentMessage } from "@/agent/messageConverter";
import { PermissionHandler } from "./utils/permissionHandler";
import { Future } from "@/utils/future";
import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk";
Expand Down Expand Up @@ -492,6 +493,13 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
onFirstResult: (initialMessage) => {
applySessionTitleFallback(session.client, initialMessage);
},
onCompactResultAccepted: () => {
// The command has completed at the SDK boundary even
// if transcript summary lookup is still pending. A
// later stream failure must not replay /compact.
reachedReadyThisAttempt = true;
inFlightMessage = null;
},
onCompletionEvent: (message: string) => {
logger.debug(`[remote]: Completion event: ${message}`);
session.client.sendSessionEvent({ type: 'message', message });
Expand All @@ -507,7 +515,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
// just asked to clear.
session.consumeOneTimeFlags();
},
onReady: async (completionEvent?: string) => {
onReady: async (completionEvent?: string, compactSummary?: CompactSummaryPayload, compactContextTokens?: number) => {
// Reaching ready at all means this attempt is not an
// immediate/deterministic failure -- reset the
// respawn-storm guard. The turn that led here is no
Expand All @@ -521,6 +529,26 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
logger.debug(`[remote]: Completion event: ${completionEvent}`);
session.client.sendSessionEvent({ type: 'message', message: completionEvent });
}
if (compactSummary) {
logger.debug(`[remote]: Compact summary promoted (${compactSummary.summary.length} chars)`);
session.client.sendSessionEvent({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Refresh context usage after compaction

This event displays tokensAfter, but it does not create a usage-bearing message. reduceChatMessages() chooses latestUsage only from msg.usage, while the compact result is documented here as all-zero, so the status bar keeps the old pre-compaction context until the next model response. Pi handles the same gap by emitting a context-only usage update after its summary.

Suggested fix:

// Preserve compactTokensAfter through onReady, even without a summary.
const usage = compactContextTokens === undefined ? null : convertAgentMessage({
    type: 'usage',
    inputTokens: 0,
    outputTokens: 0,
    contextTokens: compactContextTokens
}, session.getModel())
if (usage) session.client.sendAgentMessage(usage)

type: 'compact-summary',
summary: compactSummary.summary,
tokensBefore: compactSummary.tokensBefore,
estimatedTokensAfter: compactSummary.tokensAfter
});
}
if (compactContextTokens !== undefined) {
// The status bar keeps the last pre-compaction
// usage until the next model response; refresh
// it with the boundary's post-tokens the same
// way the Pi launcher does.
const convertedUsage = convertAgentMessage(
{ type: 'usage', inputTokens: 0, outputTokens: 0, contextTokens: compactContextTokens },
session.getModel()
);
if (convertedUsage) session.client.sendAgentMessage(convertedUsage);
}

logger.debug(
`[claudeRemoteLauncher][async-debug] onReady callback ` +
Expand Down
29 changes: 29 additions & 0 deletions cli/src/claude/utils/compactCompletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Tests for the claude /compact completion event builder
*/

import { describe, it, expect } from 'vitest'
import { buildCompactCompletionEvent } from './compactCompletion'

describe('buildCompactCompletionEvent', () => {
it('reports failure with its reason', () => {
expect(buildCompactCompletionEvent('context too large', undefined, undefined))
.toBe('📦 Compaction failed: context too large')
})

it('falls back to a generic failure message when no reason is available', () => {
expect(buildCompactCompletionEvent('', undefined, undefined))
.toBe('📦 Compaction failed')
})

it('emits a token delta line when both tokens are known', () => {
expect(buildCompactCompletionEvent(null, 34492, 2082))
.toBe('📦 Compacted (34492 → 2082 tokens)')
})

it('omits the delta when tokens are missing', () => {
expect(buildCompactCompletionEvent(null, undefined, 2082)).toBe('📦 Compacted')
expect(buildCompactCompletionEvent(null, 34492, undefined)).toBe('📦 Compacted')
expect(buildCompactCompletionEvent(null, undefined, undefined)).toBe('📦 Compacted')
})
})
21 changes: 21 additions & 0 deletions cli/src/claude/utils/compactCompletion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Builds the chat-visible completion line for a claude /compact turn.
*
* The SDK stream carries no summary body for compactions (measured: only a
* system/compact_boundary with token metadata arrives), so the completion
* event is the defensive fallback line — pi-style token delta when both
* numbers are known, bare acknowledgment otherwise.
*/
export function buildCompactCompletionEvent(
failure: string | null,
tokensBefore?: number,
tokensAfter?: number
): string {
if (failure !== null) {
return failure.length > 0 ? `📦 Compaction failed: ${failure}` : '📦 Compaction failed'
}
if (typeof tokensBefore === 'number' && typeof tokensAfter === 'number') {
return `📦 Compacted (${tokensBefore} → ${tokensAfter} tokens)`
}
return '📦 Compacted'
}
142 changes: 142 additions & 0 deletions cli/src/claude/utils/compactSummaryLookup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it, expect } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { extractCompactSummaryFromTranscript, findLatestCompactSummary } from './compactSummaryLookup';

function entry(text: string, isCompactSummary = true): string {
return JSON.stringify({
type: 'user',
uuid: `u-${Math.random().toString(36).slice(2)}`,
isCompactSummary,
message: { role: 'user', content: text },
sessionId: 's-1'
});
}

describe('extractCompactSummaryFromTranscript', () => {
it('returns the text of the last isCompactSummary entry', () => {
const content = [
entry('older summary'),
entry('not a summary', false),
entry('newer summary')
].join('\n');

expect(extractCompactSummaryFromTranscript(content)).toBe('newer summary');
});

it('returns null when no entry carries isCompactSummary', () => {
const content = [entry('plain turn', false)].join('\n');
expect(extractCompactSummaryFromTranscript(content)).toBeNull();
});

it('returns null for empty or malformed lines without throwing', () => {
expect(extractCompactSummaryFromTranscript('')).toBeNull();
expect(extractCompactSummaryFromTranscript('{broken json\n[]\n')).toBeNull();
});

it('extracts joined text from array-style message content', () => {
const content = JSON.stringify({
type: 'user',
uuid: 'u-1',
isCompactSummary: true,
message: { role: 'user', content: [{ type: 'text', text: 'part one\n' }, { type: 'text', text: 'part two' }] }
});

expect(extractCompactSummaryFromTranscript(content)).toBe('part one\npart two');
});
});

describe('findLatestCompactSummary', () => {
it('ignores summaries written before the baseline offset', async () => {
const dir = await mkdtemp(join(tmpdir(), 'compact-summary-'));
try {
const filePath = join(dir, 's-1.jsonl');
const stale = entry('stale summary from a previous compaction') + '\n';
await writeFile(filePath, stale);
const baselineBytes = Buffer.byteLength(stale, 'utf8');
// A resumed or second-compact session already carries a summary row.
// With the baseline recorded before the new compaction started, the
// stale row must not satisfy the lookup while the new row is delayed.
const pending = findLatestCompactSummary(filePath, {
attempts: 3,
intervalMs: 5,
sleep: async () => {},
minBytes: baselineBytes
});
await new Promise((r) => setTimeout(r, 20));
expect(await pending).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('returns only the summary written after the baseline offset', async () => {
const dir = await mkdtemp(join(tmpdir(), 'compact-summary-'));
try {
const filePath = join(dir, 's-1.jsonl');
const stale = entry('stale summary') + '\n';
await writeFile(filePath, stale);
const baselineBytes = Buffer.byteLength(stale, 'utf8');
const pending = findLatestCompactSummary(filePath, {
attempts: 5,
intervalMs: 5,
sleep: async () => {},
minBytes: baselineBytes
});
await writeFile(filePath, stale + entry('fresh summary') + '\n');
await expect(pending).resolves.toBe('fresh summary');
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('resolves the summary once the transcript contains an isCompactSummary entry', async () => {
const dir = await mkdtemp(join(tmpdir(), 'compact-summary-'));
try {
const filePath = join(dir, 's-1.jsonl');
const sleeps: number[] = [];
const pending = findLatestCompactSummary(filePath, {
attempts: 5,
intervalMs: 7,
sleep: async (ms) => { sleeps.push(ms); }
});
await writeFile(filePath, entry('late summary') + '\n');
await expect(pending).resolves.toBe('late summary');
expect(sleeps.length).toBeGreaterThan(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('returns null after exhausting attempts when the entry never appears', async () => {
const dir = await mkdtemp(join(tmpdir(), 'compact-summary-'));
try {
const filePath = join(dir, 'missing.jsonl');
const summary = await findLatestCompactSummary(filePath, {
attempts: 3,
intervalMs: 1,
sleep: async () => {}
});
expect(summary).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});

it('stops polling when aborted', async () => {
const dir = await mkdtemp(join(tmpdir(), 'compact-summary-'));
try {
const controller = new AbortController();
const pending = findLatestCompactSummary(join(dir, 'missing.jsonl'), {
attempts: 10,
intervalMs: 10_000,
signal: controller.signal
});
controller.abort();
await expect(pending).resolves.toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
Loading
Loading