mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
feat(ai-chat): add /clear session command to start a fresh conversation (#9769)
* feat(ai-chat): add /clear session command to start a fresh conversation Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-chat): don't re-queue a built-in command flushed from the queue 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:
@@ -118,6 +118,10 @@ const USER_CANCEL_REASON = 'user_cancelled'
|
||||
// regular message that merely mentions "/compact" mid-sentence is unaffected.
|
||||
const COMPACT_COMMAND_NAME = 'compact'
|
||||
const COMPACT_COMMAND_RE = /^\/compact\s*$/
|
||||
// Built-in `/clear` session command — saves the conversation to history and
|
||||
// resets to a fresh chat (the "New chat" action), instead of sending a turn.
|
||||
const CLEAR_COMMAND_NAME = 'clear'
|
||||
const CLEAR_COMMAND_RE = /^\/clear\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 =
|
||||
@@ -337,11 +341,12 @@ export class AIChatManager {
|
||||
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.
|
||||
// alongside workspace skills. Unlike a skill, these run locally and never
|
||||
// reach the model; the submit path intercepts them first, so they shadow any
|
||||
// workspace skill of the same name.
|
||||
readonly sessionBuiltinCommands: AiSkillListItem[] = [
|
||||
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' }
|
||||
{ name: COMPACT_COMMAND_NAME, description: 'Summarize the conversation to free up context' },
|
||||
{ name: CLEAR_COMMAND_NAME, description: 'Clear the conversation and start a new chat' }
|
||||
]
|
||||
|
||||
// Built-ins followed by workspace skills, with any skill whose name collides
|
||||
@@ -1375,9 +1380,11 @@ export class AIChatManager {
|
||||
isPreprocessor?: boolean
|
||||
} = {}
|
||||
) => {
|
||||
// Returns whether the message was actually turned into a chat turn —
|
||||
// the queue flush uses this to restore messages dropped by an early
|
||||
// return instead of silently losing them.
|
||||
// Returns whether the input was consumed: true when it was sent as a chat
|
||||
// turn OR handled as a local built-in command, false when it was dropped
|
||||
// without being acted on (mode hidden, empty, beforeSend failed). The
|
||||
// queue flush restores the queued message only on false, so a consumed
|
||||
// command isn't re-queued and re-fired into the next conversation.
|
||||
const requestedMode = options.mode ?? this.mode
|
||||
if (!isAIModeVisible(requestedMode)) {
|
||||
return false
|
||||
@@ -1392,19 +1399,26 @@ 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
|
||||
// Built-in session commands run locally instead of becoming a chat turn.
|
||||
// Intercepted here — before the beforeSend workspace commit, file regrants,
|
||||
// and skill expansion. Scoped to session chat GLOBAL mode, where the
|
||||
// slash-command UI lives. Return true (consumed, not dropped) so that a
|
||||
// command flushed from the queue isn't restored and re-fired into the next
|
||||
// conversation.
|
||||
if (this.isSessionChat && this.mode === AIMode.GLOBAL) {
|
||||
const trimmed = this.instructions.trim()
|
||||
// `/compact`: summarize the conversation locally to free up context.
|
||||
if (COMPACT_COMMAND_RE.test(trimmed)) {
|
||||
this.instructions = ''
|
||||
await this.compactManually()
|
||||
return true
|
||||
}
|
||||
// `/clear`: save the conversation to history and start a fresh chat.
|
||||
if (CLEAR_COMMAND_RE.test(trimmed)) {
|
||||
this.instructions = ''
|
||||
await this.saveAndClear()
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -1127,8 +1127,9 @@ describe('AIChatManager manual compaction', () => {
|
||||
|
||||
const sent = await manager.sendRequest({ instructions: '/compact', mode: AIMode.GLOBAL })
|
||||
|
||||
// The command never became a chat turn...
|
||||
expect(sent).toBe(false)
|
||||
// Consumed as a local command (true so the queue flush won't re-fire it),
|
||||
// without ever reaching the model loop...
|
||||
expect(sent).toBe(true)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
// ...it ran the summarizer and compacted in place, clearing the composer.
|
||||
expect(mocks.getNonStreamingCompletion).toHaveBeenCalledTimes(1)
|
||||
@@ -1164,6 +1165,71 @@ describe('AIChatManager manual compaction', () => {
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
})
|
||||
|
||||
it('routes the /clear session command to a fresh chat instead of the model', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
seedExchange(manager)
|
||||
|
||||
const sent = await manager.sendRequest({ instructions: '/clear', mode: AIMode.GLOBAL })
|
||||
|
||||
// Consumed as a local command (true so the queue flush won't re-fire it),
|
||||
// without ever reaching the model...
|
||||
expect(sent).toBe(true)
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
|
||||
// ...it reset the conversation and cleared the composer.
|
||||
expect(manager.displayMessages).toEqual([])
|
||||
expect(manager.messages).toEqual([])
|
||||
expect(manager.instructions).toBe('')
|
||||
})
|
||||
|
||||
it('consumes a /clear flushed from the queue without re-queuing it', async () => {
|
||||
// A normal turn that commits cleanly, so its epilogue flushes the queue.
|
||||
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)
|
||||
// `/clear` typed while the turn was streaming gets queued, not sent.
|
||||
manager.queuedMessage = '/clear'
|
||||
|
||||
await manager.sendRequest({ instructions: 'a normal message', mode: AIMode.GLOBAL })
|
||||
|
||||
// The committed turn's flush ran `/clear` (resetting the conversation) and,
|
||||
// because the command reports itself as consumed, did NOT restore it — so a
|
||||
// stale `/clear` can't re-fire and wipe the next conversation.
|
||||
expect(manager.queuedMessage).toBe('')
|
||||
expect(manager.displayMessages).toEqual([])
|
||||
expect(manager.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('does not intercept /clear 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: '/clear', mode: AIMode.GLOBAL })
|
||||
|
||||
// Without the session-chat command surface, /clear is a normal message.
|
||||
expect(mocks.runChatLoop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not intercept /compact outside session chat', async () => {
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
const message = { role: 'assistant' as const, content: 'done' }
|
||||
@@ -1191,10 +1257,10 @@ describe('AIChatManager manual compaction', () => {
|
||||
{ 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.
|
||||
// Built-ins come 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(names).toEqual(['compact', 'clear', 'review-code'])
|
||||
expect(manager.sessionCommands[0].description).toBe(
|
||||
'Summarize the conversation to free up context'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user