feat: implemented confirmation mechanism for tool invokation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user