From ad1e0c2aa80e4de2dc69a0d927f082e0255f212d Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:05:42 -0700 Subject: [PATCH] feat: implemented confirmation mechanism for tool invokation --- src/agent/core/AgentCore.test.ts | 51 +++++ src/agent/core/AgentCore.ts | 39 +++- src/agent/llm/StreamingTypes.ts | 10 +- src/agent/tools/AddNotesTool.test.ts | 26 +++ src/agent/tools/AddNotesTool.ts | 17 ++ src/agent/tools/BaseTool.ts | 17 ++ src/agent/tools/RemoveNotesTool.test.ts | 56 +++++ src/agent/tools/RemoveNotesTool.ts | 103 ++++++++- src/agent/tools/index.ts | 6 + src/components/ChatBox.css | 41 ++++ src/components/ChatBox.test.tsx | 85 +++++++- src/components/ChatBox.tsx | 26 ++- src/components/chat/AssistantMessage.test.tsx | 36 ++++ src/components/chat/AssistantMessage.tsx | 62 +++++- src/hooks/useStreamProcessor.test.ts | 201 +++++++++++++++++- src/hooks/useStreamProcessor.ts | 123 +++++++---- src/i18n/messages/en_us.ts | 5 + src/i18n/messages/fr_fr.ts | 5 + src/i18n/messages/zh_cn.ts | 5 + src/i18n/messages/zh_hk.ts | 5 + src/stores/projectStore.ts | 12 ++ src/types/projectTypes.ts | 9 +- src/util/chatUtil.ts | 4 +- 23 files changed, 860 insertions(+), 84 deletions(-) create mode 100644 src/agent/tools/RemoveNotesTool.test.ts diff --git a/src/agent/core/AgentCore.test.ts b/src/agent/core/AgentCore.test.ts index 68bad75..ef0c0b7 100644 --- a/src/agent/core/AgentCore.test.ts +++ b/src/agent/core/AgentCore.test.ts @@ -131,4 +131,55 @@ describe('AgentCore todo integration', () => { expect(provider.calls[1][provider.calls[1].length - 1]?.content).not.toContain('Keep the task list current'); }); + + it('requests approval for non-read-only tools and continues after allow', async () => { + const provider = new ScriptedProvider([ + [ + { type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') }, + { type: 'done', content: '', finishReason: 'tool_calls' }, + ], + [ + { type: 'text', content: 'Completed' }, + { type: 'done', content: '', finishReason: 'stop' }, + ], + ]); + AgentCore.instance().setLLMProvider(provider); + + const requestToolApproval = vi.fn(async () => 'allow' as const); + const chunks: StreamChunk[] = []; + for await (const chunk of AgentCore.instance().processUserInput('Write notes', { requestToolApproval })) { + chunks.push(chunk); + } + + expect(requestToolApproval).toHaveBeenCalledTimes(1); + expect(chunks.some(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes')).toBe(true); + expect(chunks.at(-1)?.type).toBe('done'); + }); + + it('records denied tool execution and stops the turn after deny', async () => { + const provider = new ScriptedProvider([ + [ + { type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') }, + { type: 'done', content: '', finishReason: 'tool_calls' }, + ], + [ + { type: 'text', content: 'Should not run' }, + { type: 'done', content: '', finishReason: 'stop' }, + ], + ]); + AgentCore.instance().setLLMProvider(provider); + + const chunks: StreamChunk[] = []; + for await (const chunk of AgentCore.instance().processUserInput('Write notes', { + requestToolApproval: async () => 'deny', + })) { + chunks.push(chunk); + } + + const deniedChunk = chunks.find(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes'); + expect(deniedChunk?.toolResult?.denied).toBe(true); + expect(deniedChunk?.toolResult?.result).toBe('Execution was denied by the user.'); + expect(provider.calls).toHaveLength(1); + expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool'); + }); }); diff --git a/src/agent/core/AgentCore.ts b/src/agent/core/AgentCore.ts index 1837cc5..f4d3a19 100644 --- a/src/agent/core/AgentCore.ts +++ b/src/agent/core/AgentCore.ts @@ -1,9 +1,9 @@ import type { LLMProvider } from '../llm/LLMProvider'; import { AgentState } from './AgentState'; import { SystemPrompts } from './SystemPrompts'; -import { AVAILABLE_TOOLS } from '../tools'; +import { AVAILABLE_TOOLS, createToolInstance } from '../tools'; import { useProjectStore } from '../../stores/projectStore'; -import type { StreamChunk } from '../llm/StreamingTypes'; +import type { StreamChunk, ToolApprovalDecision } from '../llm/StreamingTypes'; import type { ToolCall } from './AgentState'; import type { OpenAIToolDefinition } from '../tools/BaseTool'; import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor'; @@ -21,6 +21,10 @@ export interface CompactConversationResult { compactedConversation: string; } +export interface ProcessUserInputOptions { + requestToolApproval?: (toolCall: ToolCall) => Promise; +} + /** * Main orchestrator for the AI agent system. * Handles the full agentic loop: LLM streaming → tool execution → result feedback → repeat. @@ -79,16 +83,13 @@ export class AgentCore { * Execute a single tool call and return the result */ private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> { - const toolName = toolCall.function.name; - const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS]; - - if (!ToolClass) { - return { success: false, result: `Unknown tool: ${toolName}` }; + const toolInstance = createToolInstance(toolCall.function.name); + if (!toolInstance) { + return { success: false, result: `Unknown tool: ${toolCall.function.name}` }; } try { const params = JSON.parse(toolCall.function.arguments); - const toolInstance = new ToolClass(); const result = await toolInstance.execute(params); // Sync UI state after successful tool execution @@ -107,7 +108,10 @@ export class AgentCore { * Handles the full agentic loop internally: if the LLM returns tool_calls, * execute them and feed results back until the LLM produces a final text response. */ - async *processUserInput(userInput: string): AsyncIterableIterator { + async *processUserInput( + userInput: string, + options?: ProcessUserInputOptions, + ): AsyncIterableIterator { if (!this.llmProvider) { throw new Error('No LLM provider configured'); } @@ -165,7 +169,16 @@ export class AgentCore { // Notify UI about the tool call yield { type: 'tool_call', content: '', toolCall }; - const result = await this.executeTool(toolCall); + let denied = false; + const toolInstance = createToolInstance(toolCall.function.name); + if (toolInstance && !toolInstance.isReadOnlyTool() && options?.requestToolApproval) { + const approvalDecision = await options.requestToolApproval(toolCall); + denied = approvalDecision === 'deny'; + } + + const result = denied + ? { success: false, result: 'Execution was denied by the user.' } + : await this.executeTool(toolCall); // Add tool result message to conversation history this.agentState.addMessage('tool', JSON.stringify(result), { @@ -181,8 +194,14 @@ export class AgentCore { name: toolCall.function.name, success: result.success, result: result.result, + denied, }, }; + + if (denied) { + continueLoop = false; + break; + } } // Clear assistant message ID before next iteration creates a new one diff --git a/src/agent/llm/StreamingTypes.ts b/src/agent/llm/StreamingTypes.ts index 18255a7..d1c6714 100644 --- a/src/agent/llm/StreamingTypes.ts +++ b/src/agent/llm/StreamingTypes.ts @@ -9,11 +9,19 @@ export interface PerformanceInfo { generationTps?: number; } +export type ToolApprovalDecision = 'allow' | 'always_allow' | 'deny'; + export interface StreamChunk { type: 'text' | 'tool_call' | 'tool_result' | 'done'; content: string; toolCall?: ToolCall; - toolResult?: { toolCallId?: string; name: string; success: boolean; result: string }; + toolResult?: { + toolCallId?: string; + name: string; + success: boolean; + result: string; + denied?: boolean; + }; performanceInfo?: PerformanceInfo; finishReason?: string; } diff --git a/src/agent/tools/AddNotesTool.test.ts b/src/agent/tools/AddNotesTool.test.ts index fd825a2..31ed8d2 100644 --- a/src/agent/tools/AddNotesTool.test.ts +++ b/src/agent/tools/AddNotesTool.test.ts @@ -50,6 +50,32 @@ describe('AddNotesTool', () => { ); }); + it('builds a confirmation summary for note creation', () => { + const track = new KGMidiTrack('Lead', 1); + const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32); + track.setRegions([region]); + const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); + project.setTracks([track]); + storeState.activeRegionId = region.getId(); + + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => project, + getSelectedItems: () => [], + } as unknown as KGCore); + + const tool = new AddNotesTool(); + + expect(tool.isReadOnlyTool()).toBe(false); + expect(tool.buildConfirmationContent({ + notes: [ + { pitch: 'C4', start: 16, length: 4 }, + { pitch: 'E4', start: 20, length: 8 }, + ], + })).toBe( + 'Allow creating 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?' + ); + }); + it('returns no compact summary when the target region cannot be resolved', () => { const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); vi.spyOn(KGCore, 'instance').mockReturnValue({ diff --git a/src/agent/tools/AddNotesTool.ts b/src/agent/tools/AddNotesTool.ts index e0b94fc..989436a 100644 --- a/src/agent/tools/AddNotesTool.ts +++ b/src/agent/tools/AddNotesTool.ts @@ -22,6 +22,10 @@ export class AddNotesTool extends BaseTool { readonly name = 'add_notes'; readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.'; + override isReadOnlyTool(): boolean { + return false; + } + readonly parameters: Record = { notes: { type: 'array', @@ -74,6 +78,19 @@ export class AddNotesTool extends BaseTool { return `Successfully created ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`; } + override buildConfirmationContent(args: Record | null): string | undefined { + if (!args) { + return undefined; + } + + const summary = this.buildSummaryData(args); + if (!summary) { + return undefined; + } + + return `Allow creating ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`; + } + async execute(params: Record): Promise { try { // Validate parameters diff --git a/src/agent/tools/BaseTool.ts b/src/agent/tools/BaseTool.ts index 1011b25..a3469cd 100644 --- a/src/agent/tools/BaseTool.ts +++ b/src/agent/tools/BaseTool.ts @@ -67,6 +67,13 @@ export abstract class BaseTool { */ abstract execute(params: Record): Promise; + /** + * Whether the tool only reads state and can execute without user approval. + */ + isReadOnlyTool(): boolean { + return true; + } + /** * Optionally build a compact UI summary for a successful tool result. * The raw tool result remains the canonical output stored in agent history. @@ -78,6 +85,16 @@ export abstract class BaseTool { return undefined; } + /** + * Optionally build a user-facing confirmation summary before execution. + * Non-read-only tools should override this with a concise approval prompt. + */ + buildConfirmationContent( + _args: Record | null, + ): string | undefined { + return undefined; + } + /** * Get the tool definition in OpenAI function calling format */ diff --git a/src/agent/tools/RemoveNotesTool.test.ts b/src/agent/tools/RemoveNotesTool.test.ts new file mode 100644 index 0000000..0206e86 --- /dev/null +++ b/src/agent/tools/RemoveNotesTool.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RemoveNotesTool } from './RemoveNotesTool'; +import { KGCore } from '../../core/KGCore'; +import { KGProject } from '../../core/KGProject'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGMidiRegion } from '../../core/region/KGMidiRegion'; +import { KGMidiNote } from '../../core/midi/KGMidiNote'; + +const storeState = { + activeRegionId: null as string | null, +}; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => storeState, + }, +})); + +describe('RemoveNotesTool', () => { + beforeEach(() => { + storeState.activeRegionId = null; + vi.restoreAllMocks(); + }); + + it('builds confirmation and result summaries for note removal', () => { + const track = new KGMidiTrack('Lead', 1); + const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32); + region.setNotes([ + new KGMidiNote('note-1', 16, 20, 60, 100), + new KGMidiNote('note-2', 20, 28, 64, 100), + ]); + track.setRegions([region]); + const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major'); + project.setTracks([track]); + storeState.activeRegionId = region.getId(); + + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => project, + getSelectedItems: () => [], + } as unknown as KGCore); + + const tool = new RemoveNotesTool(); + const args = { + start: 16, + end: 24, + }; + + expect(tool.isReadOnlyTool()).toBe(false); + expect(tool.buildConfirmationContent(args)).toBe( + 'Allow removing 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?' + ); + expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe( + 'Successfully removed 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.' + ); + }); +}); diff --git a/src/agent/tools/RemoveNotesTool.ts b/src/agent/tools/RemoveNotesTool.ts index 5362042..80893cc 100644 --- a/src/agent/tools/RemoveNotesTool.ts +++ b/src/agent/tools/RemoveNotesTool.ts @@ -5,6 +5,16 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { useProjectStore } from '../../stores/projectStore'; import { KGCore } from '../../core/KGCore'; +interface RemoveNotesSummaryData { + noteCount: number; + startBeat: number; + endBeat: number; + regionName: string; + trackName: string; + earliestNoteStartBar: number; + latestNoteEndBar: number; +} + /** * Tool for removing notes from MIDI regions within a specified beat range * Integrates with the existing command system for undo/redo support @@ -13,6 +23,10 @@ export class RemoveNotesTool extends BaseTool { readonly name = 'remove_notes'; readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.'; + override isReadOnlyTool(): boolean { + return false; + } + readonly parameters: Record = { start: { type: 'number', @@ -31,6 +45,32 @@ export class RemoveNotesTool extends BaseTool { } }; + override buildToolResultDisplayContent(args: Record | null, toolResult: ToolResult): string | undefined { + if (!toolResult.success || !args) { + return undefined; + } + + const summary = this.buildSummaryData(args); + if (!summary || summary.noteCount === 0) { + return undefined; + } + + return `Successfully removed ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`; + } + + override buildConfirmationContent(args: Record | null): string | undefined { + if (!args) { + return undefined; + } + + const summary = this.buildSummaryData(args); + if (!summary) { + return undefined; + } + + return `Allow removing ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`; + } + async execute(params: Record): Promise { try { // Validate parameters @@ -105,6 +145,10 @@ export class RemoveNotesTool extends BaseTool { * Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found */ private findTargetRegion(regionId?: string): KGMidiRegion | null { + return this.findTargetRegionContext(regionId)?.region ?? null; + } + + private findTargetRegionContext(regionId?: string): { region: KGMidiRegion; trackName: string } | null { const project = this.getCurrentProject(); const tracks = project.getTracks(); @@ -114,7 +158,10 @@ export class RemoveNotesTool extends BaseTool { const regions = track.getRegions(); const region = regions.find(r => r.getId() === regionId); if (region && region instanceof KGMidiRegion) { - return region; + return { + region, + trackName: track.getName() || `Track ${track.getTrackIndex() + 1}`, + }; } } return null; @@ -128,7 +175,10 @@ export class RemoveNotesTool extends BaseTool { const regions = track.getRegions(); const region = regions.find(r => r.getId() === storeState.activeRegionId); if (region && region instanceof KGMidiRegion) { - return region; + return { + region, + trackName: track.getName() || `Track ${track.getTrackIndex() + 1}`, + }; } } } @@ -138,7 +188,11 @@ export class RemoveNotesTool extends BaseTool { const selectedItems = core.getSelectedItems(); for (const item of selectedItems) { if (item instanceof KGMidiRegion) { - return item; + const track = tracks.find(candidate => candidate.getId().toString() === item.getTrackId()); + return { + region: item, + trackName: track?.getName() || `Track ${item.getTrackIndex() + 1}`, + }; } } @@ -147,6 +201,47 @@ export class RemoveNotesTool extends BaseTool { } } + private buildSummaryData(args: Record): RemoveNotesSummaryData | null { + const typedArgs = args as { + start?: number; + end?: number; + region_id?: string; + }; + + if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number' || typedArgs.end <= typedArgs.start) { + return null; + } + + const targetRegion = this.findTargetRegionContext(typedArgs.region_id); + if (!targetRegion) { + return null; + } + + const regionStartBeat = targetRegion.region.getStartFromBeat(); + const adjustedStartBeat = typedArgs.start - regionStartBeat; + const adjustedEndBeat = typedArgs.end - regionStartBeat; + const notesToRemove = this.findNotesInRange(targetRegion.region, adjustedStartBeat, adjustedEndBeat); + const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator; + + let earliestBeat = typedArgs.start; + let latestBeat = typedArgs.end; + + if (notesToRemove.length > 0) { + earliestBeat = Math.min(...notesToRemove.map(note => note.getStartBeat() + regionStartBeat)); + latestBeat = Math.max(...notesToRemove.map(note => note.getEndBeat() + regionStartBeat)); + } + + return { + noteCount: notesToRemove.length, + startBeat: typedArgs.start, + endBeat: typedArgs.end, + regionName: targetRegion.region.getName(), + trackName: targetRegion.trackName, + earliestNoteStartBar: Math.floor(earliestBeat / beatsPerBar) + 1, + latestNoteEndBar: Math.max(1, Math.ceil(latestBeat / beatsPerBar)), + }; + } + /** * Get KGCore instance for selection access */ @@ -165,4 +260,4 @@ export class RemoveNotesTool extends BaseTool { return noteStartBeat >= startBeat && noteStartBeat < endBeat; }); } -} \ No newline at end of file +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 9651d20..7549758 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -1,4 +1,5 @@ // Base tool system +import { BaseTool } from './BaseTool'; export { BaseTool } from './BaseTool'; export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool'; @@ -21,3 +22,8 @@ export const AVAILABLE_TOOLS = { } as const; export type ToolName = keyof typeof AVAILABLE_TOOLS; + +export const createToolInstance = (toolName: string): BaseTool | null => { + const ToolClass = AVAILABLE_TOOLS[toolName as ToolName]; + return ToolClass ? new ToolClass() : null; +}; diff --git a/src/components/ChatBox.css b/src/components/ChatBox.css index 7169ce4..12fd0a9 100644 --- a/src/components/ChatBox.css +++ b/src/components/ChatBox.css @@ -46,6 +46,16 @@ border-radius: 3px; } +.chatbox-toggle-btn.is-active { + background-color: #e0e0e0; + color: #2d2d2d; + border-radius: 3px; +} + +.chatbox-toggle-btn.is-active:hover { + background-color: #f0f0f0; +} + /* ChatBox export button wrapper and dropdown positioning */ .chatbox-export-wrapper { position: relative; @@ -418,6 +428,37 @@ flex: 1 1 auto; } +.message-tool-confirmation-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.message-tool-confirmation-btn { + width: 100%; + margin-top: 0; + min-height: 32px; +} + +.message-tool-confirmation-btn-always.dialog-btn-primary { + background-color: #5aa36a; +} + +.message-tool-confirmation-btn-always.dialog-btn-primary:hover { + background-color: #4a935a; + box-shadow: 0 4px 12px rgba(90, 163, 106, 0.3); +} + +.message-tool-confirmation-btn-deny.dialog-btn-primary { + background-color: #c96a6a; +} + +.message-tool-confirmation-btn-deny.dialog-btn-primary:hover { + background-color: #b85b5b; + box-shadow: 0 4px 12px rgba(201, 106, 106, 0.3); +} + .message-tool-summary-content > :first-child { margin-top: 0; } diff --git a/src/components/ChatBox.test.tsx b/src/components/ChatBox.test.tsx index 89a3765..2e3c563 100644 --- a/src/components/ChatBox.test.tsx +++ b/src/components/ChatBox.test.tsx @@ -12,6 +12,8 @@ const { processUserMessageMock, processStreamMock, streamProcessorCallbacks, + clearChatHistoryAndUIMock, + projectStoreState, } = vi.hoisted(() => ({ agentCoreMock: { setLLMProvider: vi.fn(), @@ -27,6 +29,13 @@ const { }, processUserMessageMock: vi.fn(), processStreamMock: vi.fn(async () => ''), + clearChatHistoryAndUIMock: vi.fn(), + projectStoreState: { + toolFastForwardEnabled: false, + setStatus: vi.fn(), + setToolFastForwardEnabled: vi.fn(), + toggleToolFastForwardEnabled: vi.fn(), + }, streamProcessorCallbacks: { onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined, onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined, @@ -35,6 +44,13 @@ const { }, })); +projectStoreState.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => { + projectStoreState.toolFastForwardEnabled = enabled; +}); +projectStoreState.toggleToolFastForwardEnabled.mockImplementation(() => { + projectStoreState.toolFastForwardEnabled = !projectStoreState.toolFastForwardEnabled; +}); + vi.mock('./chat', () => ({ UserMessage: ({ content }: { content: string }) =>
{content}
, AssistantMessage: ({ @@ -81,11 +97,14 @@ vi.mock('../core/config/ConfigManager', () => ({ })); vi.mock('../stores/projectStore', () => ({ - useProjectStore: { - getState: () => ({ - setStatus: vi.fn(), - }), - }, + useProjectStore: Object.assign( + ((selector?: (state: typeof projectStoreState) => unknown) => ( + selector ? selector(projectStoreState) : projectStoreState + )) as never, + { + getState: () => projectStoreState, + } + ), })); vi.mock('../agent/core/SystemPrompts', () => ({ @@ -95,7 +114,7 @@ vi.mock('../agent/core/SystemPrompts', () => ({ })); vi.mock('../util/chatUtil', () => ({ - clearChatHistoryAndUI: vi.fn(), + clearChatHistoryAndUI: clearChatHistoryAndUIMock, registerClearChatUICallback: vi.fn(), })); @@ -189,12 +208,17 @@ describe('ChatBox', () => { beforeEach(() => { processUserMessageMock.mockReset(); processStreamMock.mockClear(); + clearChatHistoryAndUIMock.mockClear(); agentCoreMock.compactConversation.mockClear(); agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false); streamProcessorCallbacks.onMessageAdd = undefined; streamProcessorCallbacks.onMessageUpdate = undefined; streamProcessorCallbacks.onMessageRemove = undefined; streamProcessorCallbacks.onProcessingChange = undefined; + projectStoreState.toolFastForwardEnabled = false; + projectStoreState.setStatus.mockClear(); + projectStoreState.setToolFastForwardEnabled.mockClear(); + projectStoreState.toggleToolFastForwardEnabled.mockClear(); }); it('renders the English assistant title under en_us', () => { @@ -388,4 +412,53 @@ describe('ChatBox', () => { expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy(); }); }); + + it('renders and toggles the fast-forward button state', () => { + const { rerender } = renderWithLocale('en_us'); + + const button = screen.getByTitle('Fast forward tool execution approvals'); + expect(button).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(button); + rerender( + undefined, + t: (key, params) => translate(key, params, 'en_us'), + }} + > + + , + ); + + expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('resets fast-forward through the shared new chat clear path', () => { + projectStoreState.toolFastForwardEnabled = true; + clearChatHistoryAndUIMock.mockImplementation(() => { + projectStoreState.setToolFastForwardEnabled(false); + }); + + const { rerender } = renderWithLocale('en_us'); + fireEvent.click(screen.getByTitle('New Chat')); + + rerender( + undefined, + t: (key, params) => translate(key, params, 'en_us'), + }} + > + + , + ); + + expect(clearChatHistoryAndUIMock).toHaveBeenCalled(); + expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 35c78bc..efa9687 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; import './ChatBox.css'; -import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; +import { FaPlus, FaDownload, FaForward } from 'react-icons/fa'; import { UserMessage, AssistantMessage } from './chat'; import { AgentCore } from '../agent/core/AgentCore'; import { summarizeTodoCounts } from '../agent/core/todo'; @@ -76,6 +76,8 @@ interface ChatBoxProps { const ChatBox: React.FC = ({ isVisible }) => { const { t } = useI18n(); + const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled); + const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled); const [inputValue, setInputValue] = useState(''); const textareaRef = useRef(null); @@ -455,16 +457,15 @@ const ChatBox: React.FC = ({ isVisible }) => {

{t('assistant.displayName')}

- {isProcessing && ( - - )} +
+ + +
+
+ ); + } + if (isCompactionBanner) { return (
diff --git a/src/hooks/useStreamProcessor.test.ts b/src/hooks/useStreamProcessor.test.ts index e68a17b..fc195d3 100644 --- a/src/hooks/useStreamProcessor.test.ts +++ b/src/hooks/useStreamProcessor.test.ts @@ -1,7 +1,23 @@ import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TodoItem } from '../agent/core/todo'; +const { mockedStoreState } = vi.hoisted(() => { + const state = { + activeRegionId: null as string | null, + selectedRegionIds: [] as string[], + timeSignature: { numerator: 4, denominator: 4 }, + tracks: [] as unknown[], + refreshProjectState: vi.fn(), + toolFastForwardEnabled: false, + setToolFastForwardEnabled: vi.fn(), + }; + state.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => { + state.toolFastForwardEnabled = enabled; + }); + return { mockedStoreState: state }; +}); + vi.mock('../agent/core/AgentCore', () => ({ AgentCore: { instance: vi.fn() @@ -16,13 +32,7 @@ vi.mock('../core/KGCore', () => ({ vi.mock('../stores/projectStore', () => ({ useProjectStore: { - getState: vi.fn(() => ({ - activeRegionId: null, - selectedRegionIds: [], - timeSignature: { numerator: 4, denominator: 4 }, - tracks: [], - refreshProjectState: vi.fn(), - })), + getState: vi.fn(() => mockedStoreState), }, })); @@ -53,6 +63,13 @@ const flushMicrotasks = async (): Promise => { }; describe('useStreamProcessor', () => { + beforeEach(() => { + mockedStoreState.activeRegionId = null; + mockedStoreState.toolFastForwardEnabled = false; + mockedStoreState.setToolFastForwardEnabled.mockClear(); + mockedStoreState.refreshProjectState.mockClear(); + }); + it('switches from Thinking to Processing after the first text token arrives', async () => { let releaseDone!: () => void; const doneGate = new Promise((resolve) => { @@ -388,4 +405,172 @@ describe('useStreamProcessor', () => { expect(toolResultMessage?.toolRawResult).toBe('raw music result'); expect(toolResultMessage?.toolResultDisplayContent).toBe('raw music result'); }); + + it('shows a confirmation card and replaces it with a denied result when the user denies execution', async () => { + vi.spyOn(AgentCore, 'instance').mockReturnValue({ + getAgentState: () => ({ + getTodos: () => [], + }), + processUserInput: async function* (_input: string, options?: { requestToolApproval?: (toolCall: { id: string; type: 'function'; function: { name: string; arguments: string } }) => Promise<'allow' | 'always_allow' | 'deny'> }) { + const toolCall = { + id: 'add-notes-call-confirm', + type: 'function' as const, + function: { + name: 'add_notes', + arguments: JSON.stringify({ + notes: [{ pitch: 'C4', start: 16, length: 4 }], + }), + }, + }; + + yield { type: 'tool_call', content: '', toolCall }; + const decision = await options?.requestToolApproval?.(toolCall); + yield { + type: 'tool_result', + content: '', + toolResult: { + toolCallId: toolCall.id, + name: 'add_notes', + success: false, + result: 'Execution was denied by the user.', + denied: decision === 'deny', + }, + }; + }, + } as unknown as AgentCore); + + const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Verse Melody'); + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => ({ + getTimeSignature: () => ({ numerator: 4, denominator: 4 }), + getTracks: () => [ + { + getId: () => '1', + getName: () => 'Lead', + getRegions: () => [selectedRegion], + }, + ], + }), + getSelectedItems: () => [selectedRegion], + } as unknown as KGCore); + mockedStoreState.activeRegionId = selectedRegion.getId(); + + const messages = new Map(); + const { result } = renderHook(() => useStreamProcessor({ + onMessageAdd: (message) => { + messages.set(message.id, message); + }, + onMessageUpdate: (messageId, updater) => { + const current = messages.get(messageId); + if (!current) { + throw new Error(`Missing message ${messageId}`); + } + messages.set(messageId, updater(current)); + }, + onMessageRemove: (messageId) => { + messages.delete(messageId); + }, + onProcessingChange: () => undefined, + })); + + let responsePromise!: Promise; + await act(async () => { + responsePromise = result.current.processStream('confirm prompt'); + await flushMicrotasks(); + }); + + const confirmationMessage = [...messages.values()].find(message => message.toolConfirmation); + expect(confirmationMessage?.toolConfirmation?.toolName).toBe('add_notes'); + expect(confirmationMessage?.toolConfirmation?.message).toContain('Allow creating 1 note in region **Verse Melody**'); + + act(() => { + confirmationMessage?.onToolConfirmationDecision?.('deny'); + }); + + await act(async () => { + await responsePromise; + }); + + expect([...messages.values()].some(message => message.toolConfirmation)).toBe(false); + const deniedResult = [...messages.values()].find(message => message.toolName === 'add_notes'); + expect(deniedResult?.toolDenied).toBe(true); + expect(deniedResult?.toolRawResult).toBe('Execution was denied by the user.'); + }); + + it('skips the confirmation card when fast-forward mode is enabled', async () => { + mockedStoreState.toolFastForwardEnabled = true; + let capturedDecision: string | undefined; + + vi.spyOn(AgentCore, 'instance').mockReturnValue({ + getAgentState: () => ({ + getTodos: () => [], + }), + processUserInput: async function* (_input: string, options?: { requestToolApproval?: (toolCall: { id: string; type: 'function'; function: { name: string; arguments: string } }) => Promise<'allow' | 'always_allow' | 'deny'> }) { + const toolCall = { + id: 'add-notes-call-auto', + type: 'function' as const, + function: { + name: 'add_notes', + arguments: JSON.stringify({ + notes: [{ pitch: 'C4', start: 0, length: 4 }], + }), + }, + }; + + yield { type: 'tool_call', content: '', toolCall }; + capturedDecision = await options?.requestToolApproval?.(toolCall); + yield { + type: 'tool_result', + content: '', + toolResult: { + toolCallId: toolCall.id, + name: 'add_notes', + success: true, + result: 'Successfully created 1 note: C4 (beat 0, length 4)', + }, + }; + }, + } as unknown as AgentCore); + + const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Intro'); + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => ({ + getTimeSignature: () => ({ numerator: 4, denominator: 4 }), + getTracks: () => [ + { + getId: () => '1', + getName: () => 'Lead', + getRegions: () => [selectedRegion], + }, + ], + }), + getSelectedItems: () => [selectedRegion], + } as unknown as KGCore); + mockedStoreState.activeRegionId = selectedRegion.getId(); + + const messages = new Map(); + const { result } = renderHook(() => useStreamProcessor({ + onMessageAdd: (message) => { + messages.set(message.id, message); + }, + onMessageUpdate: (messageId, updater) => { + const current = messages.get(messageId); + if (!current) { + throw new Error(`Missing message ${messageId}`); + } + messages.set(messageId, updater(current)); + }, + onMessageRemove: (messageId) => { + messages.delete(messageId); + }, + onProcessingChange: () => undefined, + })); + + await act(async () => { + await result.current.processStream('auto allow prompt'); + }); + + expect(capturedDecision).toBe('allow'); + expect([...messages.values()].some(message => message.toolConfirmation)).toBe(false); + }); }); diff --git a/src/hooks/useStreamProcessor.ts b/src/hooks/useStreamProcessor.ts index ed8417d..fa69d3e 100644 --- a/src/hooks/useStreamProcessor.ts +++ b/src/hooks/useStreamProcessor.ts @@ -1,8 +1,10 @@ import { useState, useCallback } from 'react'; import { AgentCore } from '../agent/core/AgentCore'; -import { AVAILABLE_TOOLS } from '../agent/tools'; +import { createToolInstance } from '../agent/tools'; import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils'; import type { ChatMessage } from '../types/projectTypes'; +import type { ToolApprovalDecision } from '../agent/llm/StreamingTypes'; +import { useProjectStore } from '../stores/projectStore'; const TODO_TOOL_NAME = 'update_todo_list'; @@ -12,29 +14,6 @@ interface PendingToolCall { arguments: Record | null; } -const buildToolResultDisplayContent = ( - toolName: string, - success: boolean, - rawResult: string, - toolArgs: Record | null, -): string => { - if (!success) { - return rawResult; - } - - const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS]; - if (!ToolClass) { - return rawResult; - } - - try { - const toolInstance = new ToolClass(); - return toolInstance.buildToolResultDisplayContent(toolArgs, { success, result: rawResult }) ?? rawResult; - } catch { - return rawResult; - } -}; - interface StreamProcessorOptions { onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void; onMessageAdd: (message: ChatMessage) => void; @@ -79,7 +58,55 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce console.log(input); console.log('------------------------------'); - for await (const chunk of agentCore.processUserInput(input)) { + const requestToolApproval = async (toolCall: PendingToolCall): Promise => { + const { toolFastForwardEnabled, setToolFastForwardEnabled } = useProjectStore.getState(); + if (toolFastForwardEnabled) { + return 'allow'; + } + + const toolInstance = createToolInstance(toolCall.name); + const confirmationContent = toolInstance?.buildConfirmationContent(toolCall.arguments) ?? undefined; + if (!confirmationContent) { + return 'allow'; + } + + return await new Promise((resolve) => { + const confirmationMessage = { + ...createMessage('assistant', confirmationContent), + toolConfirmation: { + toolCallId: toolCall.id, + toolName: toolCall.name, + message: confirmationContent, + }, + onToolConfirmationDecision: (decision: ToolApprovalDecision) => { + onMessageRemove(confirmationMessage.id); + if (decision === 'always_allow') { + setToolFastForwardEnabled(true); + } + resolve(decision); + }, + }; + + onMessageAdd(confirmationMessage); + }); + }; + + for await (const chunk of agentCore.processUserInput(input, { + requestToolApproval: async (toolCall) => { + let parsedArguments: Record | null; + try { + parsedArguments = JSON.parse(toolCall.function.arguments); + } catch { + parsedArguments = null; + } + + return requestToolApproval({ + id: toolCall.id, + name: toolCall.function.name, + arguments: parsedArguments, + }); + }, + })) { if (controller.signal.aborted) { return ''; } @@ -142,17 +169,23 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce console.log('-------------------------------------'); // Show tool result in UI - const { toolCallId, name, success, result } = chunk.toolResult; + const { toolCallId, name, success, result, denied } = chunk.toolResult; const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.id === toolCallId); const pendingToolCall = pendingToolCallIndex >= 0 ? pendingToolCalls.splice(pendingToolCallIndex, 1)[0] : undefined; - const toolResultDisplayContent = buildToolResultDisplayContent( - name, - success, - result, - pendingToolCall?.arguments ?? null, - ); + let toolResultDisplayContent = result; + if (success) { + try { + const toolInstance = createToolInstance(name); + toolResultDisplayContent = toolInstance?.buildToolResultDisplayContent( + pendingToolCall?.arguments ?? null, + { success, result }, + ) ?? result; + } catch { + toolResultDisplayContent = result; + } + } const toolResultMsg = name === TODO_TOOL_NAME ? { ...createMessage('assistant', result), @@ -166,6 +199,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce toolSuccess: success, toolRawResult: result, toolResultDisplayContent, + toolDenied: denied, }; onMessageAdd(toolResultMsg); @@ -176,9 +210,11 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce performanceInfo = undefined; // Create a fresh streaming placeholder for the next LLM response - const nextMsg = createStreamingMessage(); - currentStreamingId = nextMsg.id; - onMessageAdd(nextMsg); + if (!denied) { + const nextMsg = createStreamingMessage(); + currentStreamingId = nextMsg.id; + onMessageAdd(nextMsg); + } } else if (chunk.type === 'done') { performanceInfo = chunk.performanceInfo; // Finalize the streaming message @@ -209,12 +245,17 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce } console.error('Error processing stream:', error); - onMessageUpdate(currentStreamingId, (msg) => ({ - ...msg, - content: `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`, - isStreaming: false, - tokenCount: undefined - })); + const errorContent = `Error: ${error instanceof Error ? error.message : 'Failed to process message'}`; + try { + onMessageUpdate(currentStreamingId, (msg) => ({ + ...msg, + content: errorContent, + isStreaming: false, + tokenCount: undefined + })); + } catch { + onMessageAdd(createMessage('assistant', errorContent)); + } throw error; } finally { setAbortController(null); diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index 4aa0be3..4ebd7d9 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -8,6 +8,11 @@ export const enUsMessages: TranslationMessages = { 'chatbox.todo.ariaLabel': 'Agent task checklist', 'chatbox.todo.count': '{completed}/{total} completed', 'chatbox.todo.active': 'Working on: {task}', + 'chatbox.fastForward.title': 'Fast forward tool execution approvals', + 'chatbox.tool.confirmation.ariaLabel': 'Tool execution approval actions', + 'chatbox.tool.confirmation.allow': 'Allow', + 'chatbox.tool.confirmation.alwaysAllow': 'Always allow', + 'chatbox.tool.confirmation.deny': 'Deny', 'mainContent.createTrack': 'Create track', 'mainContent.showGlobalTracks': 'Show global tracks', 'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}', diff --git a/src/i18n/messages/fr_fr.ts b/src/i18n/messages/fr_fr.ts index a5561fd..42b77a8 100644 --- a/src/i18n/messages/fr_fr.ts +++ b/src/i18n/messages/fr_fr.ts @@ -10,6 +10,11 @@ export const frFrMessages: TranslationMessages = { 'chatbox.todo.ariaLabel': 'Liste des tâches de l\'agent', 'chatbox.todo.count': '{completed}/{total} terminée(s)', 'chatbox.todo.active': 'En cours : {task}', + 'chatbox.fastForward.title': 'Approuver rapidement les exécutions d\'outils', + 'chatbox.tool.confirmation.ariaLabel': 'Actions d\'approbation d\'exécution d\'outil', + 'chatbox.tool.confirmation.allow': 'Autoriser', + 'chatbox.tool.confirmation.alwaysAllow': 'Toujours autoriser', + 'chatbox.tool.confirmation.deny': 'Refuser', 'mainContent.createTrack': 'Créer une piste', 'mainContent.showGlobalTracks': 'Afficher les pistes globales', 'status.chordGuideCandidate': 'Suggestion du guide d\'accords : {name} - {notes} - {note}', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index 1231208..fd1f896 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -10,6 +10,11 @@ export const zhCnMessages: TranslationMessages = { 'chatbox.todo.ariaLabel': '代理任务清单', 'chatbox.todo.count': '已完成 {completed}/{total}', 'chatbox.todo.active': '当前进行中:{task}', + 'chatbox.fastForward.title': '快速放行工具执行审批', + 'chatbox.tool.confirmation.ariaLabel': '工具执行审批操作', + 'chatbox.tool.confirmation.allow': '允许', + 'chatbox.tool.confirmation.alwaysAllow': '始终允许', + 'chatbox.tool.confirmation.deny': '拒绝', 'mainContent.createTrack': '创建轨道', 'mainContent.showGlobalTracks': '显示全局轨道', 'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}', diff --git a/src/i18n/messages/zh_hk.ts b/src/i18n/messages/zh_hk.ts index 6335605..fdcc596 100644 --- a/src/i18n/messages/zh_hk.ts +++ b/src/i18n/messages/zh_hk.ts @@ -10,6 +10,11 @@ export const zhHkMessages: TranslationMessages = { 'chatbox.todo.ariaLabel': '代理任務清單', 'chatbox.todo.count': '已完成 {completed}/{total}', 'chatbox.todo.active': '當前進行中:{task}', + 'chatbox.fastForward.title': '快速放行工具執行審批', + 'chatbox.tool.confirmation.ariaLabel': '工具執行審批操作', + 'chatbox.tool.confirmation.allow': '允許', + 'chatbox.tool.confirmation.alwaysAllow': '始終允許', + 'chatbox.tool.confirmation.deny': '拒絕', 'mainContent.createTrack': '建立音軌', 'mainContent.showGlobalTracks': '顯示全域音軌', 'status.chordGuideCandidate': '和弦指導候選: {name} - {notes} - {note}', diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 7b7662e..48e8d0d 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -123,6 +123,7 @@ interface ProjectState { // ChatBox state showChatBox: boolean; + toolFastForwardEnabled: boolean; // K.G.One panel state showKGOnePanel: boolean; @@ -227,6 +228,8 @@ interface ProjectState { // ChatBox actions setShowChatBox: (show: boolean) => void; toggleChatBox: () => void; + setToolFastForwardEnabled: (enabled: boolean) => void; + toggleToolFastForwardEnabled: () => void; // K.G.One panel actions toggleKGOnePanel: () => void; @@ -466,6 +469,7 @@ export const useProjectStore = create((set, get) => { // Initial ChatBox state showChatBox: initialChatBoxState, + toolFastForwardEnabled: false, // Initial K.G.One panel state showKGOnePanel: false, @@ -1740,6 +1744,14 @@ export const useProjectStore = create((set, get) => { set({ showChatBox: false }); }, + setToolFastForwardEnabled: (enabled: boolean) => { + set({ toolFastForwardEnabled: enabled }); + }, + + toggleToolFastForwardEnabled: () => { + set((state) => ({ toolFastForwardEnabled: !state.toolFastForwardEnabled })); + }, + toggleKGOnePanel: () => { const { showKGOnePanel, showSettings } = get(); if (showSettings || !showKGOnePanel) { diff --git a/src/types/projectTypes.ts b/src/types/projectTypes.ts index 6077a60..a0721e2 100644 --- a/src/types/projectTypes.ts +++ b/src/types/projectTypes.ts @@ -1,5 +1,5 @@ import { Transform, type TransformFnParams } from 'class-transformer'; -import type { PerformanceInfo } from '../agent/llm/StreamingTypes'; +import type { PerformanceInfo, ToolApprovalDecision } from '../agent/llm/StreamingTypes'; import type { TodoItem } from '../agent/core/todo'; export interface TimeSignature { @@ -18,6 +18,13 @@ export interface ChatMessage { toolSuccess?: boolean; toolRawResult?: string; toolResultDisplayContent?: string; + toolConfirmation?: { + toolCallId: string; + toolName: string; + message: string; + }; + toolDenied?: boolean; + onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void; todoSnapshot?: TodoItem[]; isToolCallMessage?: boolean; } diff --git a/src/util/chatUtil.ts b/src/util/chatUtil.ts index 1553417..6d90a2e 100644 --- a/src/util/chatUtil.ts +++ b/src/util/chatUtil.ts @@ -1,4 +1,5 @@ import { AgentCore } from '../agent/core/AgentCore'; +import { useProjectStore } from '../stores/projectStore'; /** * Clear chat history and reset chat state @@ -9,6 +10,7 @@ export const clearChatHistory = () => { // Clear agent state const agentCore = AgentCore.instance(); agentCore.clearConversation(); + useProjectStore.getState().setToolFastForwardEnabled(false); console.log('Chat history cleared programmatically'); }; @@ -52,4 +54,4 @@ export const clearChatHistoryAndUI = (setStatus?: (statusMessage: string) => voi if (setStatus) { setStatus('Chat history cleared'); } -}; \ No newline at end of file +};