mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 08:00:59 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user