Add two-phase tool call approval for agents in channels (#9506)

* Add two-phase tool call approval for agents in channels

Implements the mobile counterpart to the webapp's multiplayer tool calling
feature. When a bot is @mentioned in a channel, tool call arguments and
results are redacted from other members. Only the invoker can approve/reject
tool execution (Phase 1) and decide whether to share results with the
channel (Phase 2).

See mattermost/mattermost-plugin-agents#491 for the server/webapp changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for channel tool calling utilities and remote actions

Tests for isToolCallRedacted, isPendingToolResult, getToolApprovalStage,
mergeToolCalls utility functions and fetchToolCallPrivate,
fetchToolResultPrivate, submitToolResult remote actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add e2e tests and testIDs for agent tool calls in channels

Adds detox e2e tests covering tool call card rendering, approval buttons,
result approval phase, and multi-tool-call scenarios. Adds testID props
to ToolApprovalSet and ToolCard components to support the e2e tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback for two-phase tool call approval

- Fix mergeToolCalls to preserve public-only tools instead of dropping them
- Fix stale closure race in handleToolDecision with functional setState
- Add forceLogoutIfNecessary to tool_private and tool_result remote actions
- Show snackbar on submit failure using existing error types
- Reset isDM in catch block to prevent stale state
- Sync animation shared values when isCollapsed changes externally
- Clear private data on streaming-to-persisted transition
- Wrap action buttons with usePreventDoubleTap
- Use toolCalls reference instead of toolCalls.length in effect dependency
- Fix grammar in warning callout ("its" -> "their")
- Change fontWeight from number to string per RN conventions

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback: withObservables HOC, memoization, typography

- Refactor AgentPost to use withObservables HOC for channel observation
  instead of useEffect+subscribe, providing isDM as a prop
- Memoize undecidedCount in ToolApprovalSet and move before early return
- Replace manual font styles with typography() utility in ToolCard

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nick Misasi 2026-02-19 11:20:25 -05:00 committed by GitHub
parent cef108db68
commit 9e0735c00d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1437 additions and 90 deletions

View file

@ -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;

View file

@ -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<string, SnackBarConfig> = {
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',

View file

@ -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',

View file

@ -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});
});
});

View file

@ -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)};
}
}

View file

@ -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});
});
});

View file

@ -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)};
}
}

View file

@ -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<ChannelAnalysisResponse>;
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
getToolCallPrivate: (postId: string) => Promise<ToolCall[]>;
getToolResultPrivate: (postId: string) => Promise<ToolCall[]>;
submitToolResult: (postId: string, acceptedToolIds: string[]) => Promise<void>;
// Rewrite methods
getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise<string>;
@ -88,6 +92,30 @@ const ClientAgents = (superclass: any) => class extends superclass {
);
};
getToolCallPrivate = async (postId: string): Promise<ToolCall[]> => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/tool_call_private`,
{method: 'get'},
);
};
getToolResultPrivate = async (postId: string): Promise<ToolCall[]> => {
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
// =========================================================================

View file

@ -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<ToolCall[] | null>(null);
const [privateToolResults, setPrivateToolResults] = useState<ToolCall[] | null>(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) => {
)}
</View>
)}
{toolCalls.length > 0 && (
{mergedToolCalls.length > 0 && (
<ToolApprovalSet
postId={post.id}
toolCalls={toolCalls}
toolCalls={mergedToolCalls}
approvalStage={approvalStage}
canApprove={canApprove}
canExpand={canExpand}
showArguments={showArguments}
showResults={showResults}
/>
)}
{annotations.length > 0 && (

View file

@ -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));

View file

@ -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<Record<string, boolean>>({});
const [toolDecisions, setToolDecisions] = useState<ToolDecision>({});
// 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 (
<View style={styles.container}>
{pendingToolCalls.map((tool) => (
<View
style={styles.container}
testID='agents.tool_approval_set'
>
{actionableTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
@ -166,8 +208,12 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
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 && (
<View style={styles.statusBar}>
{actionableTools.length > 1 && isSubmitting && (
<View
style={styles.statusBar}
testID='agents.tool_approval_set.submitting'
>
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
@ -196,9 +250,11 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
</View>
)}
{/* Only show status counter for multiple pending tools that haven't been submitted yet */}
{pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && (
<View style={styles.statusBar}>
{actionableTools.length > 1 && undecidedCount > 0 && !isSubmitting && (
<View
style={styles.statusBar}
testID='agents.tool_approval_set.pending_decisions'
>
<FormattedText
id='agents.tool_call.pending_decisions'
defaultMessage='{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}'

View file

@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ToolCallStatus, type ToolCall} from '@agents/types';
import React, {useCallback, useMemo} from 'react';
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
import React, {useCallback, useEffect, useMemo} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
@ -12,8 +12,10 @@ import Loading from '@components/loading';
import Markdown from '@components/markdown';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {safeParseJSON} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
interface ToolCardProps {
tool: ToolCall;
@ -23,6 +25,10 @@ interface ToolCardProps {
onToggleCollapse: (toolId: string) => 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 (
<View style={styles.container}>
<View
style={styles.container}
testID={testIdPrefix}
>
<TouchableOpacity
onPress={handleToggle}
onPress={canExpand ? handleToggle : undefined}
style={styles.header}
activeOpacity={0.7}
activeOpacity={canExpand ? 0.7 : 1}
testID={`${testIdPrefix}.header`}
>
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
<CompassIcon
name='chevron-right'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</Animated.View>
{canExpand ? (
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
<CompassIcon
name='chevron-right'
size={16}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
</Animated.View>
) : null}
{getStatusIcon()}
<Text
style={styles.toolName}
numberOfLines={1}
testID={`${testIdPrefix}.name`}
>
{displayName}
</Text>
@ -254,18 +333,26 @@ const ToolCard = ({
{!isCollapsed && (
<Animated.View style={contentAnimatedStyle}>
<View style={styles.argumentsContainer}>
<Markdown
baseTextStyle={styles.markdownText}
value={argumentsMarkdown}
theme={theme}
location={Screens.CHANNEL}
/>
</View>
{showArguments && (
<View
style={styles.argumentsContainer}
testID={`${testIdPrefix}.arguments`}
>
<Markdown
baseTextStyle={styles.markdownText}
value={argumentsMarkdown}
theme={theme}
location={Screens.CHANNEL}
/>
</View>
)}
{(isSuccess || isError) && resultMarkdown && (
{showResults && (isSuccess || isError) && resultMarkdown && (
<>
<View style={styles.responseLabel}>
<View
style={styles.responseLabel}
testID={`${testIdPrefix}.result`}
>
{isSuccess && (
<CompassIcon
name='check-circle'
@ -294,11 +381,38 @@ const ToolCard = ({
location={Screens.CHANNEL}
/>
</View>
{isResultPhase && (
<View
style={styles.warningCallout}
testID={`${testIdPrefix}.warning`}
>
<View style={styles.warningHeader}>
<CompassIcon
name='information-outline'
size={14}
color={theme.dndIndicator}
/>
<FormattedText
id='agents.tool_call.review_tool_response'
defaultMessage='Review tool response'
style={styles.warningHeaderText}
/>
</View>
<FormattedText
id='agents.tool_call.approval_warning'
defaultMessage='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.'
style={styles.warningBodyText}
/>
</View>
)}
</>
)}
{isRejected && (
<View style={styles.statusContainer}>
<View
style={styles.statusContainer}
testID={`${testIdPrefix}.status.rejected`}
>
<CompassIcon
name='close-circle-outline'
size={12}
@ -315,7 +429,10 @@ const ToolCard = ({
)}
{isPending && !hasLocalDecision && isProcessing && (
<View style={styles.statusContainer}>
<View
style={styles.statusContainer}
testID={`${testIdPrefix}.status.processing`}
>
<Loading
size='small'
color={changeOpacity(theme.centerChannelColor, 0.64)}
@ -328,13 +445,14 @@ const ToolCard = ({
</View>
)}
{isPending && !hasLocalDecision && !isProcessing && (
{isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && (
<View style={styles.buttonContainer}>
<TouchableOpacity
onPress={handleApprove}
disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
testID={`${testIdPrefix}.approve`}
>
<FormattedText
id='agents.tool_call.approve'
@ -347,6 +465,7 @@ const ToolCard = ({
disabled={isProcessing}
style={[styles.button, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
testID={`${testIdPrefix}.reject`}
>
<FormattedText
id='agents.tool_call.reject'
@ -356,6 +475,47 @@ const ToolCard = ({
</TouchableOpacity>
</View>
)}
{isResultPhase && (isSuccess || isError) && !hasLocalDecision && !isProcessing && onApprove && onReject && (
<View style={styles.resultButtonContainer}>
<TouchableOpacity
onPress={handleApprove}
disabled={isProcessing}
style={[styles.shareButton, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
testID={`${testIdPrefix}.share`}
>
<CompassIcon
name='globe'
size={14}
color={theme.buttonColor}
/>
<FormattedText
id='agents.tool_call.share'
defaultMessage='Share'
style={styles.shareButtonText}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={handleReject}
disabled={isProcessing}
style={[styles.keepPrivateButton, isProcessing && styles.buttonDisabled]}
activeOpacity={0.7}
testID={`${testIdPrefix}.keep_private`}
>
<CompassIcon
name='lock-outline'
size={14}
color={changeOpacity(theme.centerChannelColor, 0.75)}
/>
<FormattedText
id='agents.tool_call.keep_private'
defaultMessage='Keep private'
style={styles.keepPrivateButtonText}
/>
</TouchableOpacity>
</View>
)}
</View>
);
};

View file

@ -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
*/

View file

@ -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
*/

View file

@ -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<ToolCall> & {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'});
});
});

View file

@ -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<string, unknown>;
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<string, unknown>;
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;
}

View file

@ -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",

View file

@ -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<string, unknown>;
result?: string;
status: number;
}
/**
* Generate a tool call object for test data.
*/
const makeToolCall = (overrides: Partial<ToolCallData> = {}): 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<string, unknown>;
},
): Promise<any> => {
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();
});
});