-
-
Notifications
You must be signed in to change notification settings - Fork 548
feat(cli): show Claude compaction as a summary card with token delta #1691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
junmo-kim
wants to merge
19
commits into
tiann:main
Choose a base branch
from
junmo-kim:feat/claude-compact-ux
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 ae947fe
fix(cli): hide local-command tag user messages from chat
junmo-kim f5cc440
feat(web): collapse compact summary card by default
junmo-kim bdd4908
feat(cli): promote claude compact summary from the local transcript
junmo-kim 3a88e7e
fix(cli): don't double-render the manual /compact boundary as a statu…
junmo-kim d39481f
fix(cli): detect later-turn /compact and ignore stale transcript summ…
junmo-kim 6cd801c
fix(cli): establish the compact transcript baseline before the result…
junmo-kim 947136a
fix(cli): narrow the local-command meta filter to the compact stdout …
junmo-kim 2b6d720
fix(cli): range-read only the transcript tail when polling for the co…
junmo-kim 99617c2
fix(cli): complete the transcript tail read before parsing the compac…
junmo-kim f66e394
fix(cli): refresh the context bar with post-compaction tokens after /…
junmo-kim 5ee8484
fix(cli): scope the Compacted stdout suppression to the active compac…
junmo-kim f892c8c
fix(cli): make compact lifecycle ownership race-safe
junmo-kim fc92134
fix(cli): publish compact outcome before accepting the next turn
junmo-kim f1f303e
fix(cli): cancel compact completion with its failed stream attempt
junmo-kim 93b2110
fix(cli): propagate deferred compact completion failures
junmo-kim 1a60a87
refactor(cli): coordinate compact completion in the response loop
junmo-kim 2368096
fix(cli): correlate compact completion with its stream signal
junmo-kim 8486600
fix(cli): commit compact ownership when its result arrives
junmo-kim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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()chooseslatestUsageonly frommsg.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: