diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 827fbfea4..eea912816 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -314,6 +314,7 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess // Agents case WebsocketEvents.AGENTS_POST_UPDATE: + case WebsocketEvents.AGENTS_TOOL_CALL_STATUS: handleAgentPostUpdate(msg); break; diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 157cc9813..1f94e69d3 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -10,6 +10,8 @@ export const SNACK_BAR_TYPE = keyMirror({ AGENT_STOP_ERROR: null, AGENT_REGENERATE_ERROR: null, AGENT_TOOL_APPROVAL_ERROR: null, + AGENT_TOOL_RESULT_ERROR: null, + AGENT_FETCH_PRIVATE_ERROR: null, CODE_COPIED: null, FAVORITE_CHANNEL: null, FILE_DOWNLOAD_REJECTED: null, @@ -65,6 +67,14 @@ const messages = defineMessages({ id: 'snack.bar.agent.tool.approval.error', defaultMessage: 'Failed to submit tool approval', }, + AGENT_TOOL_RESULT_ERROR: { + id: 'snack.bar.agent.tool.result.error', + defaultMessage: 'Failed to submit tool result', + }, + AGENT_FETCH_PRIVATE_ERROR: { + id: 'snack.bar.agent.fetch.private.error', + defaultMessage: 'Failed to fetch private data', + }, CODE_COPIED: { id: 'snack.bar.code.copied', defaultMessage: 'Code copied to clipboard', @@ -163,6 +173,18 @@ export const SNACK_BAR_CONFIG: Record = { hasAction: false, type: MESSAGE_TYPE.ERROR, }, + AGENT_TOOL_RESULT_ERROR: { + message: messages.AGENT_TOOL_RESULT_ERROR, + iconName: 'alert-outline', + hasAction: false, + type: MESSAGE_TYPE.ERROR, + }, + AGENT_FETCH_PRIVATE_ERROR: { + message: messages.AGENT_FETCH_PRIVATE_ERROR, + iconName: 'alert-outline', + hasAction: false, + type: MESSAGE_TYPE.ERROR, + }, CODE_COPIED: { message: messages.CODE_COPIED, iconName: 'content-copy', diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index cfdcff542..bbcab1f11 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -109,6 +109,7 @@ const WebsocketEvents = { // Agents AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate', + AGENTS_TOOL_CALL_STATUS: 'custom_mattermost-ai_tool_call_status_updated', // Burn on Read BOR_POST_REVEALED: 'post_revealed', diff --git a/app/products/agents/actions/remote/tool_private.test.ts b/app/products/agents/actions/remote/tool_private.test.ts new file mode 100644 index 000000000..73e6e0417 --- /dev/null +++ b/app/products/agents/actions/remote/tool_private.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {fetchToolCallPrivate, fetchToolResultPrivate} from './tool_private'; + +jest.mock('@actions/remote/session'); +jest.mock('@managers/network_manager'); +jest.mock('@utils/errors'); +jest.mock('@utils/log'); + +const serverUrl = 'https://test.mattermost.com'; +const postId = 'post123'; + +const mockClient = { + getToolCallPrivate: jest.fn(), + getToolResultPrivate: jest.fn(), +}; + +beforeAll(() => { + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('fetchToolCallPrivate', () => { + it('should call client.getToolCallPrivate and return data on success', async () => { + const toolCalls = [{id: 'tc1', name: 'tool1'}]; + mockClient.getToolCallPrivate.mockResolvedValue(toolCalls); + + const result = await fetchToolCallPrivate(serverUrl, postId); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getToolCallPrivate).toHaveBeenCalledWith(postId); + expect(result).toEqual({data: toolCalls}); + expect(result.error).toBeUndefined(); + }); + + it('should return error object and log error on failure', async () => { + const error = new Error('Network error'); + const errorMessage = 'Network error occurred'; + mockClient.getToolCallPrivate.mockRejectedValue(error); + jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage); + + const result = await fetchToolCallPrivate(serverUrl, postId); + + expect(logError).toHaveBeenCalledWith('[fetchToolCallPrivate]', error); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error); + expect(getFullErrorMessage).toHaveBeenCalledWith(error); + expect(result).toEqual({error: errorMessage}); + }); +}); + +describe('fetchToolResultPrivate', () => { + it('should call client.getToolResultPrivate and return data on success', async () => { + const toolResults = [{id: 'tr1', name: 'tool1'}]; + mockClient.getToolResultPrivate.mockResolvedValue(toolResults); + + const result = await fetchToolResultPrivate(serverUrl, postId); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.getToolResultPrivate).toHaveBeenCalledWith(postId); + expect(result).toEqual({data: toolResults}); + expect(result.error).toBeUndefined(); + }); + + it('should return error object and log error on failure', async () => { + const error = new Error('Network error'); + const errorMessage = 'Network error occurred'; + mockClient.getToolResultPrivate.mockRejectedValue(error); + jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage); + + const result = await fetchToolResultPrivate(serverUrl, postId); + + expect(logError).toHaveBeenCalledWith('[fetchToolResultPrivate]', error); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error); + expect(getFullErrorMessage).toHaveBeenCalledWith(error); + expect(result).toEqual({error: errorMessage}); + }); +}); diff --git a/app/products/agents/actions/remote/tool_private.ts b/app/products/agents/actions/remote/tool_private.ts new file mode 100644 index 000000000..2dbe288eb --- /dev/null +++ b/app/products/agents/actions/remote/tool_private.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import type {ToolCall} from '@agents/types'; + +/** + * Fetch private tool call data for a post + * @param serverUrl The server URL + * @param postId The post ID containing the tool calls + * @returns {data} on success, {error} on failure + */ +export async function fetchToolCallPrivate( + serverUrl: string, + postId: string, +): Promise<{data?: ToolCall[]; error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + const data = await client.getToolCallPrivate(postId); + return {data}; + } catch (error) { + logError('[fetchToolCallPrivate]', error); + forceLogoutIfNecessary(serverUrl, error); + return {error: getFullErrorMessage(error)}; + } +} + +/** + * Fetch private tool result data for a post + * @param serverUrl The server URL + * @param postId The post ID containing the tool results + * @returns {data} on success, {error} on failure + */ +export async function fetchToolResultPrivate( + serverUrl: string, + postId: string, +): Promise<{data?: ToolCall[]; error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + const data = await client.getToolResultPrivate(postId); + return {data}; + } catch (error) { + logError('[fetchToolResultPrivate]', error); + forceLogoutIfNecessary(serverUrl, error); + return {error: getFullErrorMessage(error)}; + } +} diff --git a/app/products/agents/actions/remote/tool_result.test.ts b/app/products/agents/actions/remote/tool_result.test.ts new file mode 100644 index 000000000..bc410aea0 --- /dev/null +++ b/app/products/agents/actions/remote/tool_result.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {submitToolResult} from './tool_result'; + +jest.mock('@actions/remote/session'); +jest.mock('@managers/network_manager'); +jest.mock('@utils/errors'); +jest.mock('@utils/log'); + +const serverUrl = 'https://test.mattermost.com'; +const postId = 'post123'; +const acceptedToolIds = ['tool1', 'tool2', 'tool3']; + +const mockClient = { + submitToolResult: jest.fn(), +}; + +beforeAll(() => { + jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any); +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('submitToolResult', () => { + it('should call client.submitToolResult and return empty object on success', async () => { + mockClient.submitToolResult.mockResolvedValue(undefined); + + const result = await submitToolResult(serverUrl, postId, acceptedToolIds); + + expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl); + expect(mockClient.submitToolResult).toHaveBeenCalledWith(postId, acceptedToolIds); + expect(result).toEqual({}); + expect(result.error).toBeUndefined(); + }); + + it('should return error object and log error on failure', async () => { + const error = new Error('Network error'); + const errorMessage = 'Network error occurred'; + mockClient.submitToolResult.mockRejectedValue(error); + jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage); + + const result = await submitToolResult(serverUrl, postId, acceptedToolIds); + + expect(logError).toHaveBeenCalledWith('[submitToolResult]', error); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, error); + expect(getFullErrorMessage).toHaveBeenCalledWith(error); + expect(result).toEqual({error: errorMessage}); + }); +}); diff --git a/app/products/agents/actions/remote/tool_result.ts b/app/products/agents/actions/remote/tool_result.ts new file mode 100644 index 000000000..951a1c776 --- /dev/null +++ b/app/products/agents/actions/remote/tool_result.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +/** + * Submit tool result decisions to the server + * @param serverUrl The server URL + * @param postId The post ID containing the tool calls + * @param acceptedToolIds Array of tool IDs whose results were approved to share + * @returns {error} on failure + */ +export async function submitToolResult( + serverUrl: string, + postId: string, + acceptedToolIds: string[], +): Promise<{error?: unknown}> { + try { + const client = NetworkManager.getClient(serverUrl); + await client.submitToolResult(postId, acceptedToolIds); + return {}; + } catch (error) { + logError('[submitToolResult]', error); + forceLogoutIfNecessary(serverUrl, error); + return {error: getFullErrorMessage(error)}; + } +} diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts index ca82006d4..5bcea9ed4 100644 --- a/app/products/agents/client/rest.ts +++ b/app/products/agents/client/rest.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ToolCall} from '@agents/types'; import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api'; export type {Agent}; @@ -17,6 +18,9 @@ export interface ClientAgentsMix { options?: ChannelAnalysisOptions, ) => Promise; submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise; + getToolCallPrivate: (postId: string) => Promise; + getToolResultPrivate: (postId: string) => Promise; + submitToolResult: (postId: string, acceptedToolIds: string[]) => Promise; // Rewrite methods getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise; @@ -88,6 +92,30 @@ const ClientAgents = (superclass: any) => class extends superclass { ); }; + getToolCallPrivate = async (postId: string): Promise => { + return this.doFetch( + `${this.getAgentsRoute()}/post/${postId}/tool_call_private`, + {method: 'get'}, + ); + }; + + getToolResultPrivate = async (postId: string): Promise => { + return this.doFetch( + `${this.getAgentsRoute()}/post/${postId}/tool_result_private`, + {method: 'get'}, + ); + }; + + submitToolResult = async (postId: string, acceptedToolIds: string[]) => { + return this.doFetch( + `${this.getAgentsRoute()}/post/${postId}/tool_result`, + { + method: 'post', + body: {accepted_tool_ids: acceptedToolIds}, + }, + ); + }; + // ========================================================================= // Rewrite Methods // ========================================================================= diff --git a/app/products/agents/components/agent_post/index.tsx b/app/products/agents/components/agent_post/agent_post.tsx similarity index 62% rename from app/products/agents/components/agent_post/index.tsx rename to app/products/agents/components/agent_post/agent_post.tsx index cec12b02f..635697cc8 100644 --- a/app/products/agents/components/agent_post/index.tsx +++ b/app/products/agents/components/agent_post/agent_post.tsx @@ -2,10 +2,11 @@ // See LICENSE.txt for license information. import {regenerateResponse, stopGeneration} from '@agents/actions/remote/generation_controls'; +import {fetchToolCallPrivate, fetchToolResultPrivate} from '@agents/actions/remote/tool_private'; import {useStreamingState} from '@agents/store/streaming_store'; -import {type Annotation, type ToolCall} from '@agents/types'; -import {isPostRequester} from '@agents/utils'; -import React, {useCallback, useMemo} from 'react'; +import {ToolApprovalStage, type Annotation, type ToolCall} from '@agents/types'; +import {getToolApprovalStage, isPostRequester, isToolCallRedacted, mergeToolCalls} from '@agents/utils'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import FormattedText from '@components/formatted_text'; @@ -56,17 +57,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -interface AgentPostProps { +export interface AgentPostProps { post: PostModel; currentUserId?: string; location: AvailableScreens; + isDM: boolean; } /** * Custom post component for agent responses * Handles streaming text updates and displays animated cursor during generation */ -const AgentPost = ({post, currentUserId, location}: AgentPostProps) => { +const AgentPost = ({post, currentUserId, location, isDM}: AgentPostProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); @@ -123,13 +125,97 @@ const AgentPost = ({post, currentUserId, location}: AgentPostProps) => { return currentUserId ? isPostRequester(post, currentUserId) : false; }, [post, currentUserId]); + // Channel tool calling state + const [privateToolCalls, setPrivateToolCalls] = useState(null); + const [privateToolResults, setPrivateToolResults] = useState(null); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- post.props is the reactive value that drives redaction state + const isRedacted = useMemo(() => isToolCallRedacted(post), [post.props]); + + const approvalStage = useMemo( + () => getToolApprovalStage(post, toolCalls), + // eslint-disable-next-line react-hooks/exhaustive-deps -- post.props drives stage changes + [post.props, toolCalls], + ); + + const canApprove = isRequester; + const canExpand = isDM || isRequester; + const showArguments = isDM || (isRequester && (!isRedacted || privateToolCalls !== null)); + const showResults = isDM || (isRequester && (!isRedacted || privateToolResults !== null)); + + const mergedToolCalls = useMemo(() => { + if (approvalStage === ToolApprovalStage.Result && privateToolResults) { + return mergeToolCalls(toolCalls, privateToolResults); + } + if (privateToolCalls) { + return mergeToolCalls(toolCalls, privateToolCalls); + } + return toolCalls; + }, [toolCalls, privateToolCalls, privateToolResults, approvalStage]); + + // Fetch private tool call data when in Phase 1 + useEffect(() => { + let cancelled = false; + if (isRedacted && isRequester && approvalStage === ToolApprovalStage.Call && toolCalls.length > 0 && !privateToolCalls) { + fetchToolCallPrivate(serverUrl, post.id).then(({data, error}) => { + if (cancelled) { + return; + } + if (data) { + setPrivateToolCalls(data); + } + if (error) { + showSnackBar({barType: SNACK_BAR_TYPE.AGENT_FETCH_PRIVATE_ERROR}); + } + }); + } + return () => { + cancelled = true; + }; + }, [isRedacted, isRequester, approvalStage, toolCalls, privateToolCalls, serverUrl, post.id]); + + // Fetch private tool results when in Phase 2 + useEffect(() => { + let cancelled = false; + if (isRedacted && isRequester && approvalStage === ToolApprovalStage.Result && !privateToolResults) { + fetchToolResultPrivate(serverUrl, post.id).then(({data, error}) => { + if (cancelled) { + return; + } + if (data) { + setPrivateToolResults(data); + } + if (error) { + showSnackBar({barType: SNACK_BAR_TYPE.AGENT_FETCH_PRIVATE_ERROR}); + } + }); + } + return () => { + cancelled = true; + }; + }, [isRedacted, isRequester, approvalStage, privateToolResults, serverUrl, post.id]); + + // Clear private data when streaming tool calls change + const prevStreamingToolCallsRef = useRef(streamingState?.toolCalls); + useEffect(() => { + const currentToolCalls = streamingState?.toolCalls; + const hadToolCalls = prevStreamingToolCallsRef.current != null; + prevStreamingToolCallsRef.current = currentToolCalls; + + // Clear when new streaming tool calls arrive OR when streaming ends + if (currentToolCalls || hadToolCalls) { + setPrivateToolCalls(null); + setPrivateToolResults(null); + } + }, [streamingState?.toolCalls]); + // Determine if generation is in progress (generating or reasoning) const isGenerationInProgress = isGenerating || isReasoningLoading; // Show controls based on state and permissions const showStopButton = isGenerationInProgress && isRequester; const hasContent = displayMessage !== '' || reasoningSummary !== ''; - const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent; + const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent && isDM; // Handler for stop button const handleStop = useCallback(async () => { @@ -179,10 +265,15 @@ const AgentPost = ({post, currentUserId, location}: AgentPostProps) => { )} )} - {toolCalls.length > 0 && ( + {mergedToolCalls.length > 0 && ( )} {annotations.length > 0 && ( diff --git a/app/products/agents/components/agent_post/index.ts b/app/products/agents/components/agent_post/index.ts new file mode 100644 index 000000000..55934a39f --- /dev/null +++ b/app/products/agents/components/agent_post/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {General} from '@constants'; +import {observeChannel} from '@queries/servers/channel'; + +import AgentPost from './agent_post'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type OwnProps = { + post: PostModel; + currentUserId?: string; + location: AvailableScreens; +}; + +const enhanced = withObservables(['post'], ({post, database}: OwnProps & WithDatabaseArgs) => { + const isDM = observeChannel(database, post.channelId).pipe( + switchMap((channel) => of$(channel?.type === General.DM_CHANNEL)), + ); + + return { + isDM, + }; +}); + +export default withDatabase(enhanced(AgentPost)); diff --git a/app/products/agents/components/tool_approval_set/index.tsx b/app/products/agents/components/tool_approval_set/index.tsx index fb99fa70d..aaa27ce2d 100644 --- a/app/products/agents/components/tool_approval_set/index.tsx +++ b/app/products/agents/components/tool_approval_set/index.tsx @@ -2,14 +2,17 @@ // See LICENSE.txt for license information. import {submitToolApproval} from '@agents/actions/remote/tool_approval'; -import {type ToolCall, ToolCallStatus} from '@agents/types'; -import React, {useCallback, useEffect, useState} from 'react'; +import {submitToolResult} from '@agents/actions/remote/tool_result'; +import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import FormattedText from '@components/formatted_text'; import Loading from '@components/loading'; +import {SNACK_BAR_TYPE} from '@constants/snack_bar'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; +import {showSnackBar} from '@utils/snack_bar'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import ToolCard from '../tool_card'; @@ -17,6 +20,11 @@ import ToolCard from '../tool_card'; interface ToolApprovalSetProps { postId: string; toolCalls: ToolCall[]; + approvalStage: ToolApprovalStage | null; + canApprove: boolean; + canExpand: boolean; + showArguments: boolean; + showResults: boolean; } type ToolDecision = { @@ -49,7 +57,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { /** * Container component for displaying and managing tool approval requests */ -const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { +const ToolApprovalSet = ({postId, toolCalls, approvalStage, canApprove, canExpand, showArguments, showResults}: ToolApprovalSetProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); @@ -58,16 +66,31 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { const [expandedTools, setExpandedTools] = useState>({}); const [toolDecisions, setToolDecisions] = useState({}); - // Clear local decisions when tool status changes from Pending to something else + // Reset decisions when approval stage transitions (e.g., Phase 1 → Phase 2) + const prevStageRef = useRef(approvalStage); useEffect(() => { - // Keep only decisions for tools that are still pending - const filterPendingDecisions = (decisions: ToolDecision): ToolDecision => { + if (prevStageRef.current !== approvalStage) { + prevStageRef.current = approvalStage; + setToolDecisions({}); + } + }, [approvalStage]); + + // Clear local decisions when tool status changes from actionable to something else + useEffect(() => { + const isActionable = (tool: ToolCall) => { + if (approvalStage === ToolApprovalStage.Result) { + return tool.status === ToolCallStatus.Success || tool.status === ToolCallStatus.Error; + } + return tool.status === ToolCallStatus.Pending; + }; + + const filterActionableDecisions = (decisions: ToolDecision): ToolDecision => { const updated: ToolDecision = {}; const prevToolIds = Object.keys(decisions); for (const toolId of prevToolIds) { const tool = toolCalls.find((t) => t.id === toolId); - if (tool && tool.status === ToolCallStatus.Pending) { + if (tool && isActionable(tool)) { updated[toolId] = decisions[toolId]; } } @@ -76,12 +99,19 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { }; setToolDecisions((prev) => { - const updated = filterPendingDecisions(prev); + const updated = filterActionableDecisions(prev); const updatedCount = Object.keys(updated).length; const prevCount = Object.keys(prev).length; return updatedCount === prevCount ? prev : updated; }); - }, [toolCalls]); + }, [toolCalls, approvalStage]); + + const actionableTools = useMemo(() => { + if (approvalStage === ToolApprovalStage.Result) { + return toolCalls.filter((call) => call.status === ToolCallStatus.Success || call.status === ToolCallStatus.Error); + } + return toolCalls.filter((call) => call.status === ToolCallStatus.Pending); + }, [toolCalls, approvalStage]); const submitDecisions = useCallback(async (decisions: ToolDecision) => { const approvedToolIds = Object.entries(decisions). @@ -89,13 +119,22 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { map(([id]) => id); setIsSubmitting(true); - const {error} = await submitToolApproval(serverUrl, postId, approvedToolIds); + const submit = approvalStage === ToolApprovalStage.Result ? submitToolResult : submitToolApproval; + const {error} = await submit(serverUrl, postId, approvedToolIds); // Reset submitting state regardless of success/error // On error, user can try again. On success, backend updates via POST_EDITED setIsSubmitting(false); + + if (error) { + const barType = approvalStage === ToolApprovalStage.Result + ? SNACK_BAR_TYPE.AGENT_TOOL_RESULT_ERROR + : SNACK_BAR_TYPE.AGENT_TOOL_APPROVAL_ERROR; + showSnackBar({barType}); + } + return !error; - }, [serverUrl, postId]); + }, [serverUrl, postId, approvalStage]); const handleToolDecision = useCallback(async (toolId: string, approved: boolean) => { if (isSubmitting) { @@ -106,17 +145,18 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { ...toolDecisions, [toolId]: approved, }; - setToolDecisions(updatedDecisions); + setToolDecisions((prev) => ({...prev, [toolId]: approved})); - // Check if there are still undecided tools - const hasUndecided = toolCalls.some((tool) => { + // Check if there are still undecided actionable tools + const hasUndecided = actionableTools.some((tool) => { return !(tool.id in updatedDecisions) || updatedDecisions[tool.id] === null; }); if (!hasUndecided) { await submitDecisions(updatedDecisions); } - }, [isSubmitting, toolDecisions, toolCalls, submitDecisions]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- toolDecisions is read for the submit check but setState uses functional form to avoid stale closure races + }, [isSubmitting, actionableTools, submitDecisions]); const handleApprove = useCallback((toolId: string) => { handleToolDecision(toolId, true); @@ -128,37 +168,39 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { const toggleCollapse = useCallback((toolId: string) => { const tool = toolCalls.find((t) => t.id === toolId); - const defaultExpanded = tool?.status === ToolCallStatus.Pending; + const isActionableTool = tool ? actionableTools.some((a) => a.id === tool.id) : false; setExpandedTools((prev) => ({ ...prev, - [toolId]: !(prev[toolId] ?? defaultExpanded), + [toolId]: !(prev[toolId] ?? isActionableTool), })); - }, [toolCalls]); + }, [toolCalls, actionableTools]); + + // Calculate how many actionable tools haven't been decided yet + const undecidedCount = useMemo(() => { + return actionableTools.filter( + (tool) => !(tool.id in toolDecisions), + ).length; + }, [actionableTools, toolDecisions]); if (toolCalls.length === 0) { return null; } - // Get pending tool calls - const pendingToolCalls = toolCalls.filter((call) => call.status === ToolCallStatus.Pending); - - // Get processed tool calls - const processedToolCalls = toolCalls.filter((call) => call.status !== ToolCallStatus.Pending); - - // Calculate how many pending tools haven't been decided yet - const undecidedCount = pendingToolCalls.filter( - (tool) => !(tool.id in toolDecisions), - ).length; + const actionableIds = new Set(actionableTools.map((t) => t.id)); + const processedToolCalls = toolCalls.filter((call) => !actionableIds.has(call.id)); // Helper to compute if a tool should be collapsed const isToolCollapsed = (tool: ToolCall) => { - const defaultExpanded = tool.status === ToolCallStatus.Pending; + const defaultExpanded = actionableIds.has(tool.id); return !(expandedTools[tool.id] ?? defaultExpanded); }; return ( - - {pendingToolCalls.map((tool) => ( + + {actionableTools.map((tool) => ( { isProcessing={isSubmitting} localDecision={toolDecisions[tool.id]} onToggleCollapse={toggleCollapse} - onApprove={handleApprove} - onReject={handleReject} + onApprove={canApprove ? handleApprove : undefined} + onReject={canApprove ? handleReject : undefined} + approvalStage={approvalStage} + canExpand={canExpand} + showArguments={showArguments} + showResults={showResults} /> ))} @@ -178,12 +224,20 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => { isCollapsed={isToolCollapsed(tool)} isProcessing={false} onToggleCollapse={toggleCollapse} + onApprove={canApprove ? handleApprove : undefined} + onReject={canApprove ? handleReject : undefined} + approvalStage={approvalStage} + canExpand={canExpand} + showArguments={showArguments} + showResults={showResults} /> ))} - {/* Only show status bar for multiple pending tools */} - {pendingToolCalls.length > 1 && isSubmitting && ( - + {actionableTools.length > 1 && isSubmitting && ( + { )} - {/* Only show status counter for multiple pending tools that haven't been submitted yet */} - {pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && ( - + {actionableTools.length > 1 && undecidedCount > 0 && !isSubmitting && ( + void; onApprove?: (toolId: string) => void; onReject?: (toolId: string) => void; + approvalStage: ToolApprovalStage | null; + canExpand?: boolean; + showArguments?: boolean; + showResults?: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -44,31 +50,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { height: 12, }, toolName: { - fontSize: 14, - lineHeight: 20, color: changeOpacity(theme.centerChannelColor, 0.75), flex: 1, + ...typography('Body', 100), }, argumentsContainer: { marginLeft: 24, }, markdownText: { - fontSize: 11, - lineHeight: 16, color: changeOpacity(theme.centerChannelColor, 0.75), + ...typography('Body', 50), }, responseLabel: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingTop: 8, + paddingBottom: 8, paddingLeft: 24, }, responseLabelText: { - fontSize: 14, - fontWeight: 600, - lineHeight: 20, color: changeOpacity(theme.centerChannelColor, 0.75), + ...typography('Body', 100), }, resultContainer: { marginLeft: 24, @@ -81,9 +84,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { paddingLeft: 24, }, statusText: { - fontSize: 14, - lineHeight: 20, color: changeOpacity(theme.centerChannelColor, 0.75), + ...typography('Body', 100), }, buttonContainer: { flexDirection: 'row', @@ -91,6 +93,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { marginTop: 4, paddingLeft: 36, }, + resultButtonContainer: { + flexDirection: 'row', + gap: 8, + marginTop: 12, + marginLeft: 24, + }, button: { backgroundColor: changeOpacity(theme.buttonBg, 0.08), borderRadius: 4, @@ -102,10 +110,59 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { opacity: 0.5, }, buttonText: { - fontSize: 12, - fontWeight: 600, - lineHeight: 16, color: theme.buttonBg, + ...typography('Body', 75, 'SemiBold'), + }, + shareButton: { + backgroundColor: theme.buttonBg, + borderRadius: 4, + paddingVertical: 8, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + justifyContent: 'center', + }, + shareButtonText: { + color: theme.buttonColor, + ...typography('Body', 75, 'SemiBold'), + }, + keepPrivateButton: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: 4, + paddingVertical: 8, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + justifyContent: 'center', + }, + keepPrivateButtonText: { + color: changeOpacity(theme.centerChannelColor, 0.75), + ...typography('Body', 75, 'SemiBold'), + }, + warningCallout: { + backgroundColor: changeOpacity(theme.dndIndicator, 0.08), + borderLeftWidth: 3, + borderLeftColor: theme.dndIndicator, + borderRadius: 4, + padding: 12, + marginTop: 8, + marginLeft: 24, + gap: 4, + }, + warningHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + warningHeaderText: { + color: theme.centerChannelColor, + ...typography('Body', 100, 'SemiBold'), + }, + warningBodyText: { + color: changeOpacity(theme.centerChannelColor, 0.75), + ...typography('Body', 75), }, }; }); @@ -121,17 +178,27 @@ const ToolCard = ({ onToggleCollapse, onApprove, onReject, + approvalStage, + canExpand = true, + showArguments = true, + showResults = true, }: ToolCardProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const contentOpacity = useSharedValue(isCollapsed ? 0 : 1); const chevronRotation = useSharedValue(isCollapsed ? 0 : 90); + useEffect(() => { + contentOpacity.value = isCollapsed ? 0 : 1; + chevronRotation.value = isCollapsed ? 0 : 90; + }, [isCollapsed, contentOpacity, chevronRotation]); + const isPending = tool.status === ToolCallStatus.Pending; const hasLocalDecision = localDecision !== undefined && localDecision !== null; const isSuccess = tool.status === ToolCallStatus.Success; const isError = tool.status === ToolCallStatus.Error; const isRejected = tool.status === ToolCallStatus.Rejected; + const isResultPhase = approvalStage === ToolApprovalStage.Result; // Convert underscores to spaces and capitalize first letter of each word const displayName = useMemo(() => { @@ -159,19 +226,22 @@ const ToolCard = ({ }, [tool.result]); const handleToggle = useCallback(() => { + if (!canExpand) { + return; + } const newCollapsed = !isCollapsed; contentOpacity.value = withTiming(newCollapsed ? 0 : 1, {duration: 200}); chevronRotation.value = withTiming(newCollapsed ? 0 : 90, {duration: 200}); onToggleCollapse(tool.id); - }, [isCollapsed, contentOpacity, chevronRotation, onToggleCollapse, tool.id]); + }, [canExpand, isCollapsed, contentOpacity, chevronRotation, onToggleCollapse, tool.id]); - const handleApprove = useCallback(() => { + const handleApprove = usePreventDoubleTap(useCallback(() => { onApprove?.(tool.id); - }, [onApprove, tool.id]); + }, [onApprove, tool.id])); - const handleReject = useCallback(() => { + const handleReject = usePreventDoubleTap(useCallback(() => { onReject?.(tool.id); - }, [onReject, tool.id]); + }, [onReject, tool.id])); const chevronAnimatedStyle = useAnimatedStyle(() => ({ transform: [{rotate: `${chevronRotation.value}deg`}], @@ -229,24 +299,33 @@ const ToolCard = ({ return null; }; + const testIdPrefix = `agents.tool_card.${tool.id}`; + return ( - + - - - + {canExpand ? ( + + + + ) : null} {getStatusIcon()} {displayName} @@ -254,18 +333,26 @@ const ToolCard = ({ {!isCollapsed && ( - - - + {showArguments && ( + + + + )} - {(isSuccess || isError) && resultMarkdown && ( + {showResults && (isSuccess || isError) && resultMarkdown && ( <> - + {isSuccess && ( + {isResultPhase && ( + + + + + + + + )} )} {isRejected && ( - + + )} - {isPending && !hasLocalDecision && !isProcessing && ( + {isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && ( )} + + {isResultPhase && (isSuccess || isError) && !hasLocalDecision && !isProcessing && onApprove && onReject && ( + + + + + + + + + + + )} ); }; diff --git a/app/products/agents/constants.ts b/app/products/agents/constants.ts index 2452848a7..1418a8f74 100644 --- a/app/products/agents/constants.ts +++ b/app/products/agents/constants.ts @@ -19,6 +19,11 @@ export const TOUCH_TARGET_SIZE = 44; */ export const AGENT_WEBSOCKET_EVENT = 'custom_mattermost-ai_postupdate'; +/** + * WebSocket event name for tool call status updates in channels + */ +export const AGENT_TOOL_CALL_STATUS_EVENT = 'custom_mattermost-ai_tool_call_status_updated'; + /** * Control signal values from WebSocket messages */ diff --git a/app/products/agents/types/index.ts b/app/products/agents/types/index.ts index 70d128d0d..c9c41bba9 100644 --- a/app/products/agents/types/index.ts +++ b/app/products/agents/types/index.ts @@ -15,6 +15,17 @@ export const ToolCallStatus = { // eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus]; +/** + * Tool approval stage values + */ +export const ToolApprovalStage = { + Call: 'call', + Result: 'result', +} as const; + +// eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative +export type ToolApprovalStage = typeof ToolApprovalStage[keyof typeof ToolApprovalStage]; + /** * Tool call data structure */ diff --git a/app/products/agents/utils.test.ts b/app/products/agents/utils.test.ts index f20c29c20..40a52679e 100644 --- a/app/products/agents/utils.test.ts +++ b/app/products/agents/utils.test.ts @@ -2,10 +2,11 @@ // See LICENSE.txt for license information. import {AGENT_POST_TYPES} from '@agents/constants'; +import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; import TestHelper from '@test/test_helper'; -import {isAgentPost, isPostRequester} from './utils'; +import {isAgentPost, isPostRequester, isToolCallRedacted, isPendingToolResult, getToolApprovalStage, mergeToolCalls} from './utils'; describe('isAgentPost', () => { describe('with Post objects', () => { @@ -132,3 +133,183 @@ describe('isPostRequester', () => { }); }); }); + +describe('isToolCallRedacted', () => { + it('should return true when pending_tool_call_redacted is true', () => { + const post = TestHelper.fakePost({ + props: {pending_tool_call_redacted: 'true'}, + }); + expect(isToolCallRedacted(post)).toBe(true); + }); + + it('should return false when prop is not present', () => { + const post = TestHelper.fakePost({ + props: {some_other_prop: 'value'}, + }); + expect(isToolCallRedacted(post)).toBe(false); + }); + + it('should return false when props is empty', () => { + const post = TestHelper.fakePost({props: {}}); + expect(isToolCallRedacted(post)).toBe(false); + }); + + it('should return true for PostModel with redacted prop', () => { + const postModel = TestHelper.fakePostModel({ + props: {pending_tool_call_redacted: 'true'}, + }); + expect(isToolCallRedacted(postModel)).toBe(true); + }); + + it('should return false when props access throws', () => { + const faultyPost = { + get props() { + throw new Error('Access denied'); + }, + } as any; + expect(isToolCallRedacted(faultyPost)).toBe(false); + }); +}); + +describe('isPendingToolResult', () => { + it('should return true when pending_tool_result is true', () => { + const post = TestHelper.fakePost({ + props: {pending_tool_result: 'true'}, + }); + expect(isPendingToolResult(post)).toBe(true); + }); + + it('should return false when prop is not present', () => { + const post = TestHelper.fakePost({ + props: {some_other_prop: 'value'}, + }); + expect(isPendingToolResult(post)).toBe(false); + }); + + it('should return false when props is empty', () => { + const post = TestHelper.fakePost({props: {}}); + expect(isPendingToolResult(post)).toBe(false); + }); + + it('should return true for PostModel with pending result prop', () => { + const postModel = TestHelper.fakePostModel({ + props: {pending_tool_result: 'true'}, + }); + expect(isPendingToolResult(postModel)).toBe(true); + }); +}); + +describe('getToolApprovalStage', () => { + const pendingToolCall: ToolCall = { + id: 'tc1', + name: 'search', + description: 'Search tool', + arguments: {}, + status: ToolCallStatus.Pending, + }; + + const acceptedToolCall: ToolCall = { + id: 'tc2', + name: 'fetch', + description: 'Fetch tool', + arguments: {}, + status: ToolCallStatus.Accepted, + }; + + it('should return Result when pending_tool_result is true', () => { + const post = TestHelper.fakePost({ + props: {pending_tool_result: 'true'}, + }); + expect(getToolApprovalStage(post, [])).toBe(ToolApprovalStage.Result); + }); + + it('should return Call when there are pending tool calls', () => { + const post = TestHelper.fakePost({props: {}}); + expect(getToolApprovalStage(post, [pendingToolCall])).toBe(ToolApprovalStage.Call); + }); + + it('should return null when no pending tools and no pending result', () => { + const post = TestHelper.fakePost({props: {}}); + expect(getToolApprovalStage(post, [acceptedToolCall])).toBeNull(); + }); + + it('should return Result even when there are pending tool calls', () => { + const post = TestHelper.fakePost({ + props: {pending_tool_result: 'true'}, + }); + expect(getToolApprovalStage(post, [pendingToolCall])).toBe(ToolApprovalStage.Result); + }); +}); + +describe('mergeToolCalls', () => { + const makeToolCall = (overrides: Partial & {id: string}): ToolCall => ({ + name: 'tool', + description: 'A tool', + arguments: {}, + status: ToolCallStatus.Pending, + ...overrides, + }); + + it('should return publicCalls unchanged when privateCalls is null', () => { + const publicCalls = [makeToolCall({id: 'tc1', arguments: {q: 'hello'}})]; + expect(mergeToolCalls(publicCalls, null)).toBe(publicCalls); + }); + + it('should return publicCalls unchanged when privateCalls is empty', () => { + const publicCalls = [makeToolCall({id: 'tc1', arguments: {q: 'hello'}})]; + expect(mergeToolCalls(publicCalls, [])).toBe(publicCalls); + }); + + it('should merge private arguments into public calls while preserving public status', () => { + const publicCalls = [makeToolCall({id: 'tc1', status: ToolCallStatus.Accepted, arguments: {redacted: true}})]; + const privateCalls = [makeToolCall({id: 'tc1', status: ToolCallStatus.Pending, arguments: {q: 'secret'}})]; + + const result = mergeToolCalls(publicCalls, privateCalls); + expect(result).toHaveLength(1); + expect(result[0].arguments).toEqual({q: 'secret'}); + expect(result[0].status).toBe(ToolCallStatus.Accepted); + }); + + it('should merge private result when present', () => { + const publicCalls = [makeToolCall({id: 'tc1'})]; + const privateCalls = [makeToolCall({id: 'tc1', result: 'done'})]; + + const result = mergeToolCalls(publicCalls, privateCalls); + expect(result[0].result).toBe('done'); + }); + + it('should not overwrite result when private result is undefined', () => { + const publicCalls = [makeToolCall({id: 'tc1', result: 'original'})]; + const privateCalls = [makeToolCall({id: 'tc1', result: undefined})]; + + const result = mergeToolCalls(publicCalls, privateCalls); + expect(result[0].result).toBe('original'); + }); + + it('should preserve public tools and append private-only tools', () => { + const publicCalls = [makeToolCall({id: 'tc1'})]; + const privateCalls = [makeToolCall({id: 'tc_unknown', name: 'private_only', arguments: {x: 1}})]; + + const result = mergeToolCalls(publicCalls, privateCalls); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('tc1'); + expect(result[1].id).toBe('tc_unknown'); + expect(result[1].name).toBe('private_only'); + expect(result[1].arguments).toEqual({x: 1}); + }); + + it('should preserve public-only tools when private is a subset', () => { + const publicCalls = [ + makeToolCall({id: 'tc1', arguments: {q: 'hello'}}), + makeToolCall({id: 'tc2', arguments: {q: 'world'}}), + ]; + const privateCalls = [makeToolCall({id: 'tc1', arguments: {q: 'secret'}})]; + + const result = mergeToolCalls(publicCalls, privateCalls); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('tc1'); + expect(result[0].arguments).toEqual({q: 'secret'}); + expect(result[1].id).toBe('tc2'); + expect(result[1].arguments).toEqual({q: 'world'}); + }); +}); diff --git a/app/products/agents/utils.ts b/app/products/agents/utils.ts index 03919617c..814ebdea4 100644 --- a/app/products/agents/utils.ts +++ b/app/products/agents/utils.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {AGENT_POST_TYPES} from '@agents/constants'; +import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types'; import type PostModel from '@typings/database/models/servers/post'; @@ -27,3 +28,71 @@ export function isPostRequester(post: PostModel | Post, currentUserId: string): return false; } } + +/** + * Check if a post has redacted tool call data (private arguments hidden from channel) + */ +export function isToolCallRedacted(post: PostModel | Post): boolean { + try { + const props = post.props as Record; + return props?.pending_tool_call_redacted === 'true'; + } catch { + return false; + } +} + +/** + * Check if a post is pending tool result approval + */ +export function isPendingToolResult(post: PostModel | Post): boolean { + try { + const props = post.props as Record; + return props?.pending_tool_result === 'true'; + } catch { + return false; + } +} + +/** + * Determine the current tool approval stage for a post + */ +export function getToolApprovalStage(post: PostModel | Post, toolCalls: ToolCall[]): ToolApprovalStage | null { + if (isPendingToolResult(post)) { + return ToolApprovalStage.Result; + } + if (toolCalls.some((tc) => tc.status === ToolCallStatus.Pending)) { + return ToolApprovalStage.Call; + } + return null; +} + +/** + * Merge public tool calls with private data, preserving status from public and arguments/results from private + */ +export function mergeToolCalls(publicCalls: ToolCall[], privateCalls: ToolCall[] | null): ToolCall[] { + if (!privateCalls?.length) { + return publicCalls; + } + + const privateById = new Map(privateCalls.map((tc) => [tc.id, tc])); + + const merged = publicCalls.map((publicTool) => { + const privateTool = privateById.get(publicTool.id); + if (!privateTool) { + return publicTool; + } + privateById.delete(publicTool.id); + return { + ...publicTool, + arguments: privateTool.arguments, + ...(privateTool.result != null && {result: privateTool.result}), + }; + }); + + // Append any private-only tools not found in public calls + for (const privateTool of privateById.values()) { + merged.push(privateTool); + } + + return merged; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 30dc385be..b1ae6829a 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -39,11 +39,15 @@ "agents.regenerate.confirm_title": "Regenerate Response", "agents.selector.no_agents": "No agents available", "agents.selector.title": "Select Agent", + "agents.tool_call.approval_warning": "Approving lets Agents use this response in their next message. That message will be visible to everyone in the channel — only approve results you are comfortable sharing.", "agents.tool_call.approve": "Accept", + "agents.tool_call.keep_private": "Keep private", "agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}", "agents.tool_call.processing": "Processing...", "agents.tool_call.reject": "Reject", "agents.tool_call.response": "Response", + "agents.tool_call.review_tool_response": "Review tool response", + "agents.tool_call.share": "Share", "agents.tool_call.status.rejected": "Rejected", "agents.tool_call.submitting": "Submitting...", "ai_rewrite.agent_selector_title": "Select AI Agent", @@ -1490,9 +1494,11 @@ "skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.", "skintone_selector.tooltip.title": "Choose your default skin tone", "smobile.search.recent_title": "Recent searches in {teamName}", + "snack.bar.agent.fetch.private.error": "Failed to fetch private data", "snack.bar.agent.regenerate.error": "Failed to regenerate response", "snack.bar.agent.stop.error": "Failed to stop generation", "snack.bar.agent.tool.approval.error": "Failed to submit tool approval", + "snack.bar.agent.tool.result.error": "Failed to submit tool result", "snack.bar.bor_post_expired.error": "This burn-on-read post has expired and can no longer be revealed.", "snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added", "snack.bar.code.copied": "Code copied to clipboard", diff --git a/detox/e2e/test/products/agents/tool_calls_in_channels.e2e.ts b/detox/e2e/test/products/agents/tool_calls_in_channels.e2e.ts new file mode 100644 index 000000000..296ce0762 --- /dev/null +++ b/detox/e2e/test/products/agents/tool_calls_in_channels.e2e.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import { + Channel, + Setup, + Team, + User, +} from '@support/server_api'; +import client from '@support/server_api/client'; +import {getResponseFromError} from '@support/server_api/common'; +import { + serverOneUrl, + siteOneUrl, +} from '@support/test_config'; +import { + ChannelListScreen, + ChannelScreen, + HomeScreen, + LoginScreen, + ServerScreen, +} from '@support/ui/screen'; +import {getRandomId, timeouts, wait} from '@support/utils'; +import {expect} from 'detox'; + +// **************************************************************** +// Agent post helpers +// **************************************************************** + +const ToolCallStatus = { + Pending: 0, + Accepted: 1, + Rejected: 2, + Error: 3, + Success: 4, +} as const; + +interface ToolCallData { + id: string; + name: string; + description: string; + arguments: Record; + result?: string; + status: number; +} + +/** + * Generate a tool call object for test data. + */ +const makeToolCall = (overrides: Partial = {}): ToolCallData => ({ + id: `tc_${getRandomId(6)}`, + name: 'search_documents', + description: 'Search for documents in the knowledge base', + arguments: {query: 'quarterly report'}, + status: ToolCallStatus.Pending, + ...overrides, +}); + +/** + * Create an agent post with tool calls via the REST API. + * Posts are created with type 'custom_llmbot' so the mobile app renders them as AgentPost. + */ +const apiCreateAgentPost = async ( + baseUrl: string, + {channelId, requesterUserId, toolCalls, message = '', extraProps = {}}: { + channelId: string; + requesterUserId: string; + toolCalls: ToolCallData[]; + message?: string; + extraProps?: Record; + }, +): Promise => { + try { + const response = await client.post(`${baseUrl}/api/v4/posts`, { + channel_id: channelId, + message, + type: 'custom_llmbot', + props: { + llm_requester_user_id: requesterUserId, + pending_tool_call: JSON.stringify(toolCalls), + ...extraProps, + }, + }); + return {post: response.data}; + } catch (err) { + return getResponseFromError(err); + } +}; + +// **************************************************************** +// Tests +// **************************************************************** + +describe('Agents - Tool Calls in Channels', () => { + const serverOneDisplayName = 'Server 1'; + const channelsCategory = 'channels'; + let testChannel: any; + let testUser: any; + let testTeam: any; + + beforeAll(async () => { + const {channel, team, user} = await Setup.apiInit(siteOneUrl); + testChannel = channel; + testUser = user; + testTeam = team; + + // # Log in to server + await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); + await LoginScreen.login(user); + }); + + beforeEach(async () => { + // * Verify on channel list screen + await ChannelListScreen.toBeVisible(); + }); + + afterAll(async () => { + // # Log out + await HomeScreen.logout(); + }); + + it('should display tool call card with tool name for pending tool calls', async () => { + // # Create a tool call with a known name + const toolCall = makeToolCall({name: 'search_documents'}); + + // # Create an agent post with pending tool calls (Phase 1) + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool approval set is visible + await waitFor(element(by.id('agents.tool_approval_set'))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify tool card is visible with the correct tool ID + await expect(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible(); + + // * Verify tool name is displayed (converted from underscores to spaces and capitalized) + await expect(element(by.id(`agents.tool_card.${toolCall.id}.name`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should show Accept and Reject buttons for pending tool calls when user is requester', async () => { + // # Create a pending tool call + const toolCall = makeToolCall(); + + // # Create an agent post where the test user is the requester + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify Accept button is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}.approve`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify Reject button is visible + await expect(element(by.id(`agents.tool_card.${toolCall.id}.reject`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should not show approval buttons when user is not the requester', async () => { + // # Create a second user and add to the test channel + const {user: otherUser} = await User.apiCreateUser(siteOneUrl, {prefix: 'other'}); + await Team.apiAddUserToTeam(siteOneUrl, otherUser.id, testTeam.id); + await Channel.apiAddUserToChannel(siteOneUrl, otherUser.id, testChannel.id); + + // # Create a pending tool call + const toolCall = makeToolCall(); + + // # Create an agent post where the OTHER user is the requester (not the logged-in user) + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: otherUser.id, + toolCalls: [toolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify Accept button is NOT visible (user is not the requester) + await expect(element(by.id(`agents.tool_card.${toolCall.id}.approve`))).not.toBeVisible(); + + // * Verify Reject button is NOT visible + await expect(element(by.id(`agents.tool_card.${toolCall.id}.reject`))).not.toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should display tool calls with success status and results', async () => { + // # Create a successful tool call with a result + const toolCall = makeToolCall({ + name: 'fetch_data', + status: ToolCallStatus.Success, + result: JSON.stringify({data: 'Sample result data'}), + }); + + // # Create an agent post with the completed tool call + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify tool name is displayed + await expect(element(by.id(`agents.tool_card.${toolCall.id}.name`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should display rejected status for rejected tool calls', async () => { + // # Create a rejected tool call + const toolCall = makeToolCall({ + name: 'dangerous_action', + status: ToolCallStatus.Rejected, + }); + + // # Create an agent post with the rejected tool call + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // # Tap the tool card header to expand it (rejected cards are collapsed by default) + await element(by.id(`agents.tool_card.${toolCall.id}.header`)).tap(); + await wait(timeouts.ONE_SEC); + + // * Verify rejected status is visible + await expect(element(by.id(`agents.tool_card.${toolCall.id}.status.rejected`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should show Share and Keep Private buttons during result approval phase', async () => { + // # Create a successful tool call (tool has executed) + const toolCall = makeToolCall({ + name: 'web_search', + status: ToolCallStatus.Success, + result: JSON.stringify({results: ['result 1', 'result 2']}), + }); + + // # Create an agent post in Phase 2 (pending_tool_result = 'true') + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + extraProps: { + pending_tool_result: 'true', + }, + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify Share button is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}.share`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify Keep Private button is visible + await expect(element(by.id(`agents.tool_card.${toolCall.id}.keep_private`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should display warning callout during result approval phase', async () => { + // # Create a successful tool call with result + const toolCall = makeToolCall({ + name: 'code_search', + status: ToolCallStatus.Success, + result: 'Found 3 matching files in the repository', + }); + + // # Create an agent post in Phase 2 (result approval) + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + extraProps: { + pending_tool_result: 'true', + }, + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify warning callout is visible (review tool response warning) + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}.warning`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should show pending decisions counter for multiple pending tool calls', async () => { + // # Create multiple pending tool calls + const toolCall1 = makeToolCall({name: 'search_web'}); + const toolCall2 = makeToolCall({name: 'read_file'}); + const toolCall3 = makeToolCall({name: 'execute_query'}); + + // # Create an agent post with multiple tool calls + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall1, toolCall2, toolCall3], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify all tool cards are visible + await waitFor(element(by.id(`agents.tool_card.${toolCall1.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + await expect(element(by.id(`agents.tool_card.${toolCall2.id}`))).toBeVisible(); + await expect(element(by.id(`agents.tool_card.${toolCall3.id}`))).toBeVisible(); + + // * Verify pending decisions status bar is visible (for multiple tool calls) + await expect(element(by.id('agents.tool_approval_set.pending_decisions'))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should show tool arguments when expanded in a DM channel', async () => { + // # Get admin user info (the server API client is logged in as admin) + const adminResponse = await client.get(`${siteOneUrl}/api/v4/users/me`); + const adminUser = adminResponse.data; + + // # Create a DM channel between admin and the test user + const {channel: dmChannel} = await Channel.apiCreateDirectChannel(siteOneUrl, [adminUser.id, testUser.id]); + + // # Create a pending tool call with known arguments + const toolCall = makeToolCall({ + name: 'search_knowledge_base', + arguments: {query: 'quarterly results', limit: 10}, + }); + + // # Create an agent post in the DM channel + await apiCreateAgentPost(siteOneUrl, { + channelId: dmChannel.id, + requesterUserId: testUser.id, + toolCalls: [toolCall], + }); + + // # Open the DM channel (DMs use a different category) + await wait(timeouts.TWO_SEC); + await ChannelListScreen.toBeVisible(); + + // # Use find channels to navigate to the DM + const {headerPlusButton} = ChannelListScreen; + await headerPlusButton.tap(); + await element(by.id('channel_list.header.plus_menu.open_direct_message')).tap(); + + // # Search for admin user in the DM create screen + await waitFor(element(by.id('create_direct_message.search_bar.search.input'))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + await element(by.id('create_direct_message.search_bar.search.input')).typeText(adminUser.username); + await wait(timeouts.ONE_SEC); + await element(by.id(`create_direct_message.user_list.user_item.${adminUser.id}`)).tap(); + await element(by.id('create_direct_message.start.button')).tap(); + + await wait(timeouts.TWO_SEC); + + // * Verify tool card is visible + await waitFor(element(by.id(`agents.tool_card.${toolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + + // * Verify arguments are visible (DM channels show arguments without redaction) + await expect(element(by.id(`agents.tool_card.${toolCall.id}.arguments`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); + + it('should display mix of pending and completed tool calls', async () => { + // # Create a mix of tool calls in different states + const pendingToolCall = makeToolCall({ + name: 'pending_action', + status: ToolCallStatus.Pending, + }); + const completedToolCall = makeToolCall({ + name: 'completed_action', + status: ToolCallStatus.Success, + result: 'Action completed successfully', + }); + const rejectedToolCall = makeToolCall({ + name: 'rejected_action', + status: ToolCallStatus.Rejected, + }); + + // # Create an agent post with mixed tool call states + await apiCreateAgentPost(siteOneUrl, { + channelId: testChannel.id, + requesterUserId: testUser.id, + toolCalls: [pendingToolCall, completedToolCall, rejectedToolCall], + }); + + // # Open the channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await wait(timeouts.TWO_SEC); + + // * Verify all tool cards are visible + await waitFor(element(by.id(`agents.tool_card.${pendingToolCall.id}`))).toBeVisible().withTimeout(timeouts.FOUR_SEC); + await expect(element(by.id(`agents.tool_card.${completedToolCall.id}`))).toBeVisible(); + await expect(element(by.id(`agents.tool_card.${rejectedToolCall.id}`))).toBeVisible(); + + // * Verify the pending tool call has approval buttons + await expect(element(by.id(`agents.tool_card.${pendingToolCall.id}.approve`))).toBeVisible(); + await expect(element(by.id(`agents.tool_card.${pendingToolCall.id}.reject`))).toBeVisible(); + + // # Navigate back + await ChannelScreen.back(); + }); +});