feat: add /compact session chat command (#9764)

* feat: add session chat slash commands

* feat: add /compact session chat command

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: dedupe built-in commands against same-named workspace skills

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-24 15:33:55 +02:00
committed by GitHub
parent 42c5e7a3fc
commit 83cc5533ee
3 changed files with 351 additions and 36 deletions
@@ -113,6 +113,11 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
// flush can tell "the user wants to move on" from "the turn was torn down".
const USER_CANCEL_REASON = 'user_cancelled'
// Built-in `/compact` session command — summarizes the conversation locally
// instead of sending a turn to the model. Matched on the whole input so a
// regular message that merely mentions "/compact" mid-sentence is unaffected.
const COMPACT_COMMAND_NAME = 'compact'
const COMPACT_COMMAND_RE = /^\/compact\s*$/
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
const WEB_SEARCH_ERROR_HINT =
@@ -331,6 +336,23 @@ export class AIChatManager {
globalSkills = $state<AiSkillListItem[]>([])
private globalSkillsRefreshId = 0
// Built-in session-chat slash commands, listed in the command picker
// alongside workspace skills. Unlike a skill, `/compact` runs locally
// (compactManually) and never reaches the model; the submit path intercepts
// it first, so it shadows any workspace skill of the same name.
readonly sessionBuiltinCommands: AiSkillListItem[] = [
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' }
]
// Built-ins followed by workspace skills, with any skill whose name collides
// with a built-in dropped: the picker keys leaves by name, so a duplicate
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
// already shadow same-named skills at execution (the submit interception).
sessionCommands: AiSkillListItem[] = $derived([
...this.sessionBuiltinCommands,
...this.globalSkills.filter((s) => !this.sessionBuiltinCommands.some((b) => b.name === s.name))
])
allowedModes: Record<AIMode, boolean> = $derived({
script:
this.flowAiChatHelpers === undefined &&
@@ -432,6 +454,64 @@ export class AIChatManager {
return freed
}
/**
* Core summarize + rewrite, shared by automatic and manual compaction. Sends
* the prefix to the summarizer, then replaces the summarized prefix with a
* single summary message in `messages` (as a user message) and
* `displayMessages` (as a `summary` boundary). Surviving tail user messages
* have their restart `index` re-based onto the new history: the summary
* occupies slot 0, so a tail user message that was at `keepFrom` lands at slot
* 1. `displayKeepFrom` is where the kept tail begins in `displayMessages`.
*
* Owns only the `compacting` flag and the history rewrite; callers own trigger
* policy (circuit breaker, gates) and persistence. Returns the outcome —
* 'aborted' is a user Stop (history left untouched), distinct from 'error'.
*/
private runSummarization = async (
prefix: ChatCompletionMessageParam[],
tail: ChatCompletionMessageParam[],
keepFrom: number,
displayKeepFrom: number,
abortController: AbortController
): Promise<'ok' | 'empty' | 'aborted' | 'error'> => {
this.compacting = true
try {
const raw = await getNonStreamingCompletion(
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
abortController
)
const formatted = formatCompactSummary(raw ?? '')
if (!formatted) {
return 'empty'
}
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
// Replace the summarized display prefix with the boundary marker and
// re-base the surviving tail's restart indices (the summary occupies
// slot 0, so the tail now starts at slot 1).
this.displayMessages = [
{ role: 'summary', content: formatted },
...this.displayMessages
.slice(displayKeepFrom)
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
]
// The provider report described the pre-compaction history; the new
// history is much smaller, so clear it and let readers re-estimate.
this.contextUsage = undefined
return 'ok'
} catch (err) {
if (abortController.signal.aborted) {
return 'aborted'
}
console.error('Conversation summarization failed', err)
return 'error'
} finally {
this.compacting = false
}
}
/**
* Summary-based partial compaction. Summarizes the older PREFIX of the stored
* history into a single user message and keeps the recent tail verbatim,
@@ -506,45 +586,94 @@ export class AIChatManager {
return false
}
this.compacting = true
try {
const raw = await getNonStreamingCompletion(
[...prefix, { role: 'user', content: getCompactionSummaryPrompt() }],
abortController
)
const formatted = formatCompactSummary(raw ?? '')
if (!formatted) {
this.consecutiveCompactionFailures++
return false
}
this.messages = [{ role: 'user', content: buildSummaryMessageContent(formatted) }, ...tail]
// Replace the summarized display prefix with the boundary marker and
// re-base the surviving tail's restart indices (the summary occupies
// slot 0, so the tail now starts at slot 1).
this.displayMessages = [
{ role: 'summary', content: formatted },
...this.displayMessages
.slice(displayKeepFrom)
.map((m) => (m.role === 'user' ? { ...m, index: m.index - keepFrom + 1 } : m))
]
// The provider report described the pre-compaction history; the new
// history is much smaller, so clear it and let readers re-estimate.
this.contextUsage = undefined
const result = await this.runSummarization(
prefix,
tail,
keepFrom,
displayKeepFrom,
abortController
)
if (result === 'ok') {
this.consecutiveCompactionFailures = 0
return true
} catch (err) {
// A user Stop aborts the in-flight summary — that's a turn cancel, not a
// compaction failure, so it doesn't count toward the circuit breaker.
if (!abortController.signal.aborted) {
console.error('Conversation summarization failed', err)
this.consecutiveCompactionFailures++
}
// 'aborted' is a user Stop during the in-flight summary — a turn cancel, not
// a compaction failure, so it doesn't count toward the circuit breaker.
if (result === 'empty' || result === 'error') {
this.consecutiveCompactionFailures++
}
return false
}
/**
* Manual compaction (the `/compact` session command): summarize the ENTIRE
* stored history into a single summary message and keep nothing verbatim, so
* the next message continues from the summary alone. Unlike the automatic
* trigger it ignores the context-window budget, the circuit breaker, and the
* prefix-size gate — the user asked for it explicitly — and runs on its own
* abort controller so the Stop button (`cancel`) can interrupt the in-flight
* summary, leaving history untouched.
*/
compactManually = async (): Promise<void> => {
if (this.loading) {
return
}
// A summary round-trip only pays off once there's a prior exchange to fold
// in; a single message (or none) has nothing to compact.
if (this.messages.length < 2) {
sendUserToast('Nothing to compact yet.')
return
}
const abortController = new AbortController()
this.abortController = abortController
this.loading = true
let result: 'ok' | 'empty' | 'aborted' | 'error' = 'error'
try {
// Everything is the prefix, nothing is kept verbatim: keepFrom and
// displayKeepFrom point past the end so the kept tail is empty.
result = await this.runSummarization(
[...this.messages],
[],
this.messages.length,
this.displayMessages.length,
abortController
)
switch (result) {
case 'ok':
await this.historyManager.saveChat(
this.displayMessages,
this.messages,
this.contextUsage
)
sendUserToast('Conversation compacted.')
break
case 'empty':
sendUserToast(
'Compaction produced an empty summary — conversation left unchanged.',
true
)
break
case 'error':
sendUserToast('Failed to compact the conversation.', true)
break
// 'aborted' (user Stop): history untouched, no toast.
}
return false
} finally {
this.compacting = false
this.loading = false
}
// Flush a message typed while compaction ran. Mirrors the send-turn
// epilogue (loading gated its capture): auto-send after a successful
// compaction or a deliberate user cancel — the user is ready to move on —
// while a failed/empty compaction or a programmatic cancel leaves it queued.
if ((result === 'ok' || this.wasCancelledByUser()) && this.queuedMessage) {
const next = this.queuedMessage
this.queuedMessage = ''
const accepted = await this.sendRequest({ instructions: next })
if (accepted === false) {
this.queuedMessage = next
}
}
}
@@ -1263,6 +1392,20 @@ export class AIChatManager {
if (!this.instructions.trim()) {
return false
}
// Built-in `/compact` session command: summarize the conversation locally
// instead of sending a turn to the model. Intercepted here — before the
// beforeSend workspace commit, file regrants, and skill expansion — and not
// turned into a chat turn. Scoped to session chat GLOBAL mode, where the
// slash-command UI lives.
if (
this.isSessionChat &&
this.mode === AIMode.GLOBAL &&
COMPACT_COMMAND_RE.test(this.instructions.trim())
) {
this.instructions = ''
await this.compactManually()
return false
}
// Re-grant any locked File System Access handles within this send gesture, so the
// file tools can read the live files. requestPermission() needs a user gesture, and
// this runs before the first await/network call while the Send click is still active.
@@ -1029,6 +1029,178 @@ describe('AIChatManager context compaction', () => {
})
})
describe('AIChatManager manual compaction', () => {
const model = { provider: 'openai', model: 'gpt-4o' }
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(model)
mocks.tryGetCurrentModel.mockReturnValue(model)
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
mocks.listAiSkills.mockResolvedValue([])
})
function seedExchange(manager: AIChatManager) {
manager.messages = [
{ role: 'user', content: 'q1' },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'q2' },
{ role: 'assistant', content: 'a2' }
]
manager.displayMessages = [
{ role: 'user', content: 'q1', index: 0 },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'q2', index: 2 },
{ role: 'assistant', content: 'a2' }
]
}
it('folds the whole history into a single summary boundary, keeping nothing verbatim', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>MANUAL SUMMARY</summary>')
const manager = new AIChatManager()
seedExchange(manager)
manager.contextUsage = 123
const saveChat = vi.spyOn(manager.historyManager, 'saveChat')
await manager.compactManually()
// The summarizer saw the entire history, then the summary instruction.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
const summaryReq = mocks.getNonStreamingCompletion.mock.calls[0][0]
expect(summaryReq).toHaveLength(5)
expect(summaryReq[0].content).toBe('q1')
expect(summaryReq[3].content).toBe('a2')
expect(summaryReq[4].content).toContain('detailed summary')
// Nothing kept verbatim: messages collapse to just the summary user message.
expect(manager.messages).toHaveLength(1)
expect(manager.messages[0].role).toBe('user')
expect(manager.messages[0].content).toContain('MANUAL SUMMARY')
expect(manager.messages[0].content).toContain('continued from a previous conversation')
expect(manager.messages[0].content).not.toContain('<summary>')
// The transcript shows one summary boundary in place of the old bubbles.
expect(manager.displayMessages).toHaveLength(1)
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'MANUAL SUMMARY' })
expect(manager.contextUsage).toBeUndefined()
expect(saveChat).toHaveBeenCalled()
expect(mocks.sendUserToast).toHaveBeenCalledWith('Conversation compacted.')
expect(manager.loading).toBe(false)
expect(manager.compacting).toBe(false)
})
it('no-ops with a toast when there is nothing worth compacting', async () => {
const manager = new AIChatManager()
manager.messages = [{ role: 'user', content: 'only one' }]
await manager.compactManually()
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
expect(mocks.sendUserToast).toHaveBeenCalledWith('Nothing to compact yet.')
expect(manager.messages).toHaveLength(1)
})
it('leaves history untouched when the user stops mid-summary', async () => {
mocks.getNonStreamingCompletion.mockImplementation(async (_msgs: any, ac: AbortController) => {
ac.abort('user_cancelled')
throw new Error('aborted')
})
const manager = new AIChatManager()
seedExchange(manager)
await manager.compactManually()
expect(manager.messages).toHaveLength(4)
expect(manager.displayMessages.some((m) => m.role === 'summary')).toBe(false)
// An abort is a user cancel, not a failure — no toast, no destructive change.
expect(mocks.sendUserToast).not.toHaveBeenCalled()
expect(manager.loading).toBe(false)
})
it('routes the /compact session command to manual compaction instead of the model', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>VIA COMMAND</summary>')
const manager = new AIChatManager()
manager.isSessionChat = true
seedExchange(manager)
const sent = await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
// The command never became a chat turn...
expect(sent).toBe(false)
expect(mocks.runChatLoop).not.toHaveBeenCalled()
// ...it ran the summarizer and compacted in place, clearing the composer.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
expect(manager.displayMessages[0]).toMatchObject({ role: 'summary', content: 'VIA COMMAND' })
expect(manager.instructions).toBe('')
})
it('auto-sends a message queued while compaction was running', async () => {
mocks.getNonStreamingCompletion.mockResolvedValue('<summary>S</summary>')
mocks.runChatLoop.mockImplementation(async (config: any) => {
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = true
manager.changeMode(AIMode.GLOBAL)
seedExchange(manager)
// A message typed while loading was true gets queued, not sent.
manager.queuedMessage = 'follow-up question'
await manager.compactManually()
// Compaction ran once, then the queued message went out as a real turn.
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
const sent = mocks.runChatLoop.mock.calls[0][0].messages
expect(sent[sent.length - 1].content).toContain('follow-up question')
expect(manager.queuedMessage).toBe('')
})
it('does not intercept /compact outside session chat', async () => {
mocks.runChatLoop.mockImplementation(async (config: any) => {
const message = { role: 'assistant' as const, content: 'done' }
config.addedMessages?.push(message)
return {
addedMessages: [message],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
const manager = new AIChatManager()
manager.isSessionChat = false
await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
// Without the session-chat command surface, /compact is a normal message.
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
})
it('shadows a workspace skill that collides with a built-in command', () => {
const manager = new AIChatManager()
manager.globalSkills = [
{ name: 'compact', description: 'a workspace skill that happens to be named compact' },
{ name: 'review-code', description: 'review code for bugs' }
]
// Built-in `compact` comes first and the colliding skill is dropped, so the
// picker never renders two leaves with the same `skill:compact` key.
const names = manager.sessionCommands.map((c) => c.name)
expect(names).toEqual(['compact', 'review-code'])
expect(manager.sessionCommands[0].description).toBe(
'Summarize the conversation to free up context'
)
})
})
const assistantToolCall = (id: string): ChatCompletionMessageParam => ({
role: 'assistant',
content: '',
@@ -80,7 +80,7 @@
const commandSkills = $derived(
aiChatManager.mode === AIMode.GLOBAL && aiChatManager.isSessionChat
? aiChatManager.globalSkills
? aiChatManager.sessionCommands
: []
)
const activeTooltipWord = $derived(showContextTooltip ? contextTooltipWord : commandTooltipWord)