feat: implemented confirmation mechanism for tool invokation

This commit is contained in:
Xiaohan-Tian
2026-06-03 17:05:42 -07:00
parent 414565ecc9
commit ad1e0c2aa8
23 changed files with 860 additions and 84 deletions
+41
View File
@@ -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;
}
+79 -6
View File
@@ -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 }) => <div>{content}</div>,
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(
<I18nContext.Provider
value={{
languageSetting: 'en_us',
resolvedLocale: 'en_us',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<ChatBox isVisible={true} />
</I18nContext.Provider>,
);
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(
<I18nContext.Provider
value={{
languageSetting: 'en_us',
resolvedLocale: 'en_us',
setLanguageSetting: async () => undefined,
t: (key, params) => translate(key, params, 'en_us'),
}}
>
<ChatBox isVisible={true} />
</I18nContext.Provider>,
);
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false');
});
});
+15 -11
View File
@@ -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<ChatBoxProps> = ({ isVisible }) => {
const { t } = useI18n();
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
const [inputValue, setInputValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -455,16 +457,15 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
<div className="chatbox-header">
<h3>{t('assistant.displayName')}</h3>
<div className="chatbox-actions">
{isProcessing && (
<button
type="button"
title="Abort"
onClick={handleAbort}
className="chatbox-action-btn"
>
<FaBan />
</button>
)}
<button
type="button"
title={t('chatbox.fastForward.title')}
aria-pressed={toolFastForwardEnabled}
onClick={toggleToolFastForwardEnabled}
className={`chatbox-action-btn chatbox-toggle-btn ${toolFastForwardEnabled ? 'is-active' : ''}`}
>
<FaForward />
</button>
<div className="chatbox-export-wrapper">
<button
type="button"
@@ -551,6 +552,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
toolSuccess={message.toolSuccess}
toolRawResult={message.toolRawResult}
toolResultDisplayContent={message.toolResultDisplayContent}
toolConfirmation={message.toolConfirmation}
toolDenied={message.toolDenied}
onToolConfirmationDecision={message.onToolConfirmationDecision}
todoSnapshot={message.todoSnapshot}
isToolCallMessage={message.isToolCallMessage}
onAbort={message.isStreaming ? handleAbort : undefined}
@@ -230,4 +230,40 @@ describe('AssistantMessage', () => {
expect(screen.getByText(/└──/)).toBeInTheDocument();
expect(screen.getByText('C D E F')).toBeInTheDocument();
});
it('renders tool confirmation buttons and fires the selected action', () => {
const onToolConfirmationDecision = vi.fn();
render(
<AssistantMessage
content="confirmation fallback"
toolConfirmation={{
toolCallId: 'tool-1',
toolName: 'add_notes',
message: 'Allow creating 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?',
}}
onToolConfirmationDecision={onToolConfirmationDecision}
/>
);
expect(screen.getByText('add_notes')).toBeInTheDocument();
expect(screen.getByText(/Allow creating 2 notes/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Always allow' }));
expect(onToolConfirmationDecision).toHaveBeenCalledWith('always_allow');
});
it('renders denied tool results with the denied result text', () => {
render(
<AssistantMessage
content="❌ **add_notes**\n\n └── Execution was denied by the user."
toolName="add_notes"
toolSuccess={false}
toolRawResult="Execution was denied by the user."
toolDenied={true}
/>
);
expect(screen.getByText('add_notes')).toBeInTheDocument();
expect(screen.getByText('Execution was denied by the user.')).toBeInTheDocument();
});
});
+61 -1
View File
@@ -6,9 +6,10 @@ import remarkMath from 'remark-math';
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
import type { PerformanceInfo, ToolApprovalDecision } from '../../agent/llm/StreamingTypes';
import { summarizeTodoCounts } from '../../agent/core/todo';
import type { TodoItem } from '../../agent/core/todo';
import { useI18n } from '../../i18n/useI18n';
interface AssistantMessageProps {
content: string;
@@ -19,6 +20,13 @@ interface AssistantMessageProps {
toolSuccess?: boolean;
toolRawResult?: string;
toolResultDisplayContent?: string;
toolConfirmation?: {
toolCallId: string;
toolName: string;
message: string;
};
toolDenied?: boolean;
onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void;
todoSnapshot?: TodoItem[];
isToolCallMessage?: boolean;
}
@@ -169,9 +177,12 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
toolSuccess,
toolRawResult,
toolResultDisplayContent,
toolConfirmation,
onToolConfirmationDecision,
todoSnapshot,
isToolCallMessage = false,
}) => {
const { t } = useI18n();
const prefillTps = formatTps(performanceInfo?.prefillTps);
const generationTps = formatTps(performanceInfo?.generationTps);
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
@@ -182,6 +193,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
|| content === COMPACTION_DONE_LABEL
|| content === COMPACTION_EMPTY_LABEL;
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
const isToolConfirmationCard = Boolean(toolConfirmation) && Boolean(onToolConfirmationDecision);
const shouldRenderGenericToolResult = Boolean(toolName) && typeof toolSuccess === 'boolean' && !isTodoSnapshotCard;
const genericToolDisplayContent = toolResultDisplayContent ?? toolRawResult ?? content;
@@ -236,6 +248,54 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({
);
}
if (isToolConfirmationCard && toolConfirmation && onToolConfirmationDecision) {
return (
<div className="message-tool-result">
<p className="message-tool-result-title">
<span aria-hidden="true">?</span>{' '}
<strong>{toolConfirmation.toolName}</strong>
</p>
<div className="message-tool-summary">
<span className="message-tool-summary-prefix" aria-hidden="true"> </span>
<div className="message-tool-summary-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code: (props) => <CodeComponent {...props} isToolCallMessage={false} />,
}}
>
{toolConfirmation.message}
</ReactMarkdown>
</div>
</div>
<div className="message-tool-confirmation-actions" aria-label={t('chatbox.tool.confirmation.ariaLabel')}>
<button
type="button"
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
onClick={() => onToolConfirmationDecision('allow')}
>
{t('chatbox.tool.confirmation.allow')}
</button>
<button
type="button"
className="message-tool-confirmation-btn message-tool-confirmation-btn-always dialog-btn dialog-btn-primary kgone-btn-generate"
onClick={() => onToolConfirmationDecision('always_allow')}
>
{t('chatbox.tool.confirmation.alwaysAllow')}
</button>
<button
type="button"
className="message-tool-confirmation-btn message-tool-confirmation-btn-deny dialog-btn dialog-btn-primary kgone-btn-generate"
onClick={() => onToolConfirmationDecision('deny')}
>
{t('chatbox.tool.confirmation.deny')}
</button>
</div>
</div>
);
}
if (isCompactionBanner) {
return (
<div className="message-divider-banner" aria-label={content}>