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:
parent
cef108db68
commit
9e0735c00d
18 changed files with 1437 additions and 90 deletions
|
|
@ -314,6 +314,7 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
case WebsocketEvents.AGENTS_POST_UPDATE:
|
case WebsocketEvents.AGENTS_POST_UPDATE:
|
||||||
|
case WebsocketEvents.AGENTS_TOOL_CALL_STATUS:
|
||||||
handleAgentPostUpdate(msg);
|
handleAgentPostUpdate(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export const SNACK_BAR_TYPE = keyMirror({
|
||||||
AGENT_STOP_ERROR: null,
|
AGENT_STOP_ERROR: null,
|
||||||
AGENT_REGENERATE_ERROR: null,
|
AGENT_REGENERATE_ERROR: null,
|
||||||
AGENT_TOOL_APPROVAL_ERROR: null,
|
AGENT_TOOL_APPROVAL_ERROR: null,
|
||||||
|
AGENT_TOOL_RESULT_ERROR: null,
|
||||||
|
AGENT_FETCH_PRIVATE_ERROR: null,
|
||||||
CODE_COPIED: null,
|
CODE_COPIED: null,
|
||||||
FAVORITE_CHANNEL: null,
|
FAVORITE_CHANNEL: null,
|
||||||
FILE_DOWNLOAD_REJECTED: null,
|
FILE_DOWNLOAD_REJECTED: null,
|
||||||
|
|
@ -65,6 +67,14 @@ const messages = defineMessages({
|
||||||
id: 'snack.bar.agent.tool.approval.error',
|
id: 'snack.bar.agent.tool.approval.error',
|
||||||
defaultMessage: 'Failed to submit tool approval',
|
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: {
|
CODE_COPIED: {
|
||||||
id: 'snack.bar.code.copied',
|
id: 'snack.bar.code.copied',
|
||||||
defaultMessage: 'Code copied to clipboard',
|
defaultMessage: 'Code copied to clipboard',
|
||||||
|
|
@ -163,6 +173,18 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||||
hasAction: false,
|
hasAction: false,
|
||||||
type: MESSAGE_TYPE.ERROR,
|
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: {
|
CODE_COPIED: {
|
||||||
message: messages.CODE_COPIED,
|
message: messages.CODE_COPIED,
|
||||||
iconName: 'content-copy',
|
iconName: 'content-copy',
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ const WebsocketEvents = {
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
||||||
|
AGENTS_TOOL_CALL_STATUS: 'custom_mattermost-ai_tool_call_status_updated',
|
||||||
|
|
||||||
// Burn on Read
|
// Burn on Read
|
||||||
BOR_POST_REVEALED: 'post_revealed',
|
BOR_POST_REVEALED: 'post_revealed',
|
||||||
|
|
|
||||||
86
app/products/agents/actions/remote/tool_private.test.ts
Normal file
86
app/products/agents/actions/remote/tool_private.test.ts
Normal 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});
|
||||||
|
});
|
||||||
|
});
|
||||||
51
app/products/agents/actions/remote/tool_private.ts
Normal file
51
app/products/agents/actions/remote/tool_private.ts
Normal 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)};
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/products/agents/actions/remote/tool_result.test.ts
Normal file
57
app/products/agents/actions/remote/tool_result.test.ts
Normal 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});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
app/products/agents/actions/remote/tool_result.ts
Normal file
30
app/products/agents/actions/remote/tool_result.ts
Normal 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)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// 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';
|
import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api';
|
||||||
|
|
||||||
export type {Agent};
|
export type {Agent};
|
||||||
|
|
@ -17,6 +18,9 @@ export interface ClientAgentsMix {
|
||||||
options?: ChannelAnalysisOptions,
|
options?: ChannelAnalysisOptions,
|
||||||
) => Promise<ChannelAnalysisResponse>;
|
) => Promise<ChannelAnalysisResponse>;
|
||||||
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
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
|
// Rewrite methods
|
||||||
getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise<string>;
|
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
|
// Rewrite Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {regenerateResponse, stopGeneration} from '@agents/actions/remote/generation_controls';
|
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 {useStreamingState} from '@agents/store/streaming_store';
|
||||||
import {type Annotation, type ToolCall} from '@agents/types';
|
import {ToolApprovalStage, type Annotation, type ToolCall} from '@agents/types';
|
||||||
import {isPostRequester} from '@agents/utils';
|
import {getToolApprovalStage, isPostRequester, isToolCallRedacted, mergeToolCalls} from '@agents/utils';
|
||||||
import React, {useCallback, useMemo} from 'react';
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
|
|
@ -56,17 +57,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
interface AgentPostProps {
|
export interface AgentPostProps {
|
||||||
post: PostModel;
|
post: PostModel;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
location: AvailableScreens;
|
location: AvailableScreens;
|
||||||
|
isDM: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom post component for agent responses
|
* Custom post component for agent responses
|
||||||
* Handles streaming text updates and displays animated cursor during generation
|
* 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 theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
@ -123,13 +125,97 @@ const AgentPost = ({post, currentUserId, location}: AgentPostProps) => {
|
||||||
return currentUserId ? isPostRequester(post, currentUserId) : false;
|
return currentUserId ? isPostRequester(post, currentUserId) : false;
|
||||||
}, [post, currentUserId]);
|
}, [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)
|
// Determine if generation is in progress (generating or reasoning)
|
||||||
const isGenerationInProgress = isGenerating || isReasoningLoading;
|
const isGenerationInProgress = isGenerating || isReasoningLoading;
|
||||||
|
|
||||||
// Show controls based on state and permissions
|
// Show controls based on state and permissions
|
||||||
const showStopButton = isGenerationInProgress && isRequester;
|
const showStopButton = isGenerationInProgress && isRequester;
|
||||||
const hasContent = displayMessage !== '' || reasoningSummary !== '';
|
const hasContent = displayMessage !== '' || reasoningSummary !== '';
|
||||||
const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent;
|
const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent && isDM;
|
||||||
|
|
||||||
// Handler for stop button
|
// Handler for stop button
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
|
@ -179,10 +265,15 @@ const AgentPost = ({post, currentUserId, location}: AgentPostProps) => {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{toolCalls.length > 0 && (
|
{mergedToolCalls.length > 0 && (
|
||||||
<ToolApprovalSet
|
<ToolApprovalSet
|
||||||
postId={post.id}
|
postId={post.id}
|
||||||
toolCalls={toolCalls}
|
toolCalls={mergedToolCalls}
|
||||||
|
approvalStage={approvalStage}
|
||||||
|
canApprove={canApprove}
|
||||||
|
canExpand={canExpand}
|
||||||
|
showArguments={showArguments}
|
||||||
|
showResults={showResults}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{annotations.length > 0 && (
|
{annotations.length > 0 && (
|
||||||
33
app/products/agents/components/agent_post/index.ts
Normal file
33
app/products/agents/components/agent_post/index.ts
Normal 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));
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {submitToolApproval} from '@agents/actions/remote/tool_approval';
|
import {submitToolApproval} from '@agents/actions/remote/tool_approval';
|
||||||
import {type ToolCall, ToolCallStatus} from '@agents/types';
|
import {submitToolResult} from '@agents/actions/remote/tool_result';
|
||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
|
||||||
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import Loading from '@components/loading';
|
import Loading from '@components/loading';
|
||||||
|
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import {showSnackBar} from '@utils/snack_bar';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
import ToolCard from '../tool_card';
|
import ToolCard from '../tool_card';
|
||||||
|
|
@ -17,6 +20,11 @@ import ToolCard from '../tool_card';
|
||||||
interface ToolApprovalSetProps {
|
interface ToolApprovalSetProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
|
approvalStage: ToolApprovalStage | null;
|
||||||
|
canApprove: boolean;
|
||||||
|
canExpand: boolean;
|
||||||
|
showArguments: boolean;
|
||||||
|
showResults: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolDecision = {
|
type ToolDecision = {
|
||||||
|
|
@ -49,7 +57,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
/**
|
/**
|
||||||
* Container component for displaying and managing tool approval requests
|
* 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 theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
@ -58,16 +66,31 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||||
const [toolDecisions, setToolDecisions] = useState<ToolDecision>({});
|
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(() => {
|
useEffect(() => {
|
||||||
// Keep only decisions for tools that are still pending
|
if (prevStageRef.current !== approvalStage) {
|
||||||
const filterPendingDecisions = (decisions: ToolDecision): ToolDecision => {
|
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 updated: ToolDecision = {};
|
||||||
const prevToolIds = Object.keys(decisions);
|
const prevToolIds = Object.keys(decisions);
|
||||||
|
|
||||||
for (const toolId of prevToolIds) {
|
for (const toolId of prevToolIds) {
|
||||||
const tool = toolCalls.find((t) => t.id === toolId);
|
const tool = toolCalls.find((t) => t.id === toolId);
|
||||||
if (tool && tool.status === ToolCallStatus.Pending) {
|
if (tool && isActionable(tool)) {
|
||||||
updated[toolId] = decisions[toolId];
|
updated[toolId] = decisions[toolId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -76,12 +99,19 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
setToolDecisions((prev) => {
|
setToolDecisions((prev) => {
|
||||||
const updated = filterPendingDecisions(prev);
|
const updated = filterActionableDecisions(prev);
|
||||||
const updatedCount = Object.keys(updated).length;
|
const updatedCount = Object.keys(updated).length;
|
||||||
const prevCount = Object.keys(prev).length;
|
const prevCount = Object.keys(prev).length;
|
||||||
return updatedCount === prevCount ? prev : updated;
|
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 submitDecisions = useCallback(async (decisions: ToolDecision) => {
|
||||||
const approvedToolIds = Object.entries(decisions).
|
const approvedToolIds = Object.entries(decisions).
|
||||||
|
|
@ -89,13 +119,22 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
map(([id]) => id);
|
map(([id]) => id);
|
||||||
|
|
||||||
setIsSubmitting(true);
|
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
|
// Reset submitting state regardless of success/error
|
||||||
// On error, user can try again. On success, backend updates via POST_EDITED
|
// On error, user can try again. On success, backend updates via POST_EDITED
|
||||||
setIsSubmitting(false);
|
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;
|
return !error;
|
||||||
}, [serverUrl, postId]);
|
}, [serverUrl, postId, approvalStage]);
|
||||||
|
|
||||||
const handleToolDecision = useCallback(async (toolId: string, approved: boolean) => {
|
const handleToolDecision = useCallback(async (toolId: string, approved: boolean) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
|
|
@ -106,17 +145,18 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
...toolDecisions,
|
...toolDecisions,
|
||||||
[toolId]: approved,
|
[toolId]: approved,
|
||||||
};
|
};
|
||||||
setToolDecisions(updatedDecisions);
|
setToolDecisions((prev) => ({...prev, [toolId]: approved}));
|
||||||
|
|
||||||
// Check if there are still undecided tools
|
// Check if there are still undecided actionable tools
|
||||||
const hasUndecided = toolCalls.some((tool) => {
|
const hasUndecided = actionableTools.some((tool) => {
|
||||||
return !(tool.id in updatedDecisions) || updatedDecisions[tool.id] === null;
|
return !(tool.id in updatedDecisions) || updatedDecisions[tool.id] === null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasUndecided) {
|
if (!hasUndecided) {
|
||||||
await submitDecisions(updatedDecisions);
|
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) => {
|
const handleApprove = useCallback((toolId: string) => {
|
||||||
handleToolDecision(toolId, true);
|
handleToolDecision(toolId, true);
|
||||||
|
|
@ -128,37 +168,39 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
|
|
||||||
const toggleCollapse = useCallback((toolId: string) => {
|
const toggleCollapse = useCallback((toolId: string) => {
|
||||||
const tool = toolCalls.find((t) => t.id === toolId);
|
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) => ({
|
setExpandedTools((prev) => ({
|
||||||
...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) {
|
if (toolCalls.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get pending tool calls
|
const actionableIds = new Set(actionableTools.map((t) => t.id));
|
||||||
const pendingToolCalls = toolCalls.filter((call) => call.status === ToolCallStatus.Pending);
|
const processedToolCalls = toolCalls.filter((call) => !actionableIds.has(call.id));
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Helper to compute if a tool should be collapsed
|
// Helper to compute if a tool should be collapsed
|
||||||
const isToolCollapsed = (tool: ToolCall) => {
|
const isToolCollapsed = (tool: ToolCall) => {
|
||||||
const defaultExpanded = tool.status === ToolCallStatus.Pending;
|
const defaultExpanded = actionableIds.has(tool.id);
|
||||||
return !(expandedTools[tool.id] ?? defaultExpanded);
|
return !(expandedTools[tool.id] ?? defaultExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View
|
||||||
{pendingToolCalls.map((tool) => (
|
style={styles.container}
|
||||||
|
testID='agents.tool_approval_set'
|
||||||
|
>
|
||||||
|
{actionableTools.map((tool) => (
|
||||||
<ToolCard
|
<ToolCard
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
|
|
@ -166,8 +208,12 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
isProcessing={isSubmitting}
|
isProcessing={isSubmitting}
|
||||||
localDecision={toolDecisions[tool.id]}
|
localDecision={toolDecisions[tool.id]}
|
||||||
onToggleCollapse={toggleCollapse}
|
onToggleCollapse={toggleCollapse}
|
||||||
onApprove={handleApprove}
|
onApprove={canApprove ? handleApprove : undefined}
|
||||||
onReject={handleReject}
|
onReject={canApprove ? handleReject : undefined}
|
||||||
|
approvalStage={approvalStage}
|
||||||
|
canExpand={canExpand}
|
||||||
|
showArguments={showArguments}
|
||||||
|
showResults={showResults}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -178,12 +224,20 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
isCollapsed={isToolCollapsed(tool)}
|
isCollapsed={isToolCollapsed(tool)}
|
||||||
isProcessing={false}
|
isProcessing={false}
|
||||||
onToggleCollapse={toggleCollapse}
|
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 */}
|
{actionableTools.length > 1 && isSubmitting && (
|
||||||
{pendingToolCalls.length > 1 && isSubmitting && (
|
<View
|
||||||
<View style={styles.statusBar}>
|
style={styles.statusBar}
|
||||||
|
testID='agents.tool_approval_set.submitting'
|
||||||
|
>
|
||||||
<Loading
|
<Loading
|
||||||
size='small'
|
size='small'
|
||||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
|
@ -196,9 +250,11 @@ const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Only show status counter for multiple pending tools that haven't been submitted yet */}
|
{actionableTools.length > 1 && undecidedCount > 0 && !isSubmitting && (
|
||||||
{pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && (
|
<View
|
||||||
<View style={styles.statusBar}>
|
style={styles.statusBar}
|
||||||
|
testID='agents.tool_approval_set.pending_decisions'
|
||||||
|
>
|
||||||
<FormattedText
|
<FormattedText
|
||||||
id='agents.tool_call.pending_decisions'
|
id='agents.tool_call.pending_decisions'
|
||||||
defaultMessage='{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}'
|
defaultMessage='{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}'
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {ToolCallStatus, type ToolCall} from '@agents/types';
|
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
|
||||||
import React, {useCallback, useMemo} from 'react';
|
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||||
import {Text, TouchableOpacity, View} from 'react-native';
|
import {Text, TouchableOpacity, View} from 'react-native';
|
||||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
|
@ -12,8 +12,10 @@ import Loading from '@components/loading';
|
||||||
import Markdown from '@components/markdown';
|
import Markdown from '@components/markdown';
|
||||||
import {Screens} from '@constants';
|
import {Screens} from '@constants';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
import {safeParseJSON} from '@utils/helpers';
|
import {safeParseJSON} from '@utils/helpers';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
tool: ToolCall;
|
tool: ToolCall;
|
||||||
|
|
@ -23,6 +25,10 @@ interface ToolCardProps {
|
||||||
onToggleCollapse: (toolId: string) => void;
|
onToggleCollapse: (toolId: string) => void;
|
||||||
onApprove?: (toolId: string) => void;
|
onApprove?: (toolId: string) => void;
|
||||||
onReject?: (toolId: string) => void;
|
onReject?: (toolId: string) => void;
|
||||||
|
approvalStage: ToolApprovalStage | null;
|
||||||
|
canExpand?: boolean;
|
||||||
|
showArguments?: boolean;
|
||||||
|
showResults?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
|
@ -44,31 +50,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
height: 12,
|
height: 12,
|
||||||
},
|
},
|
||||||
toolName: {
|
toolName: {
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 20,
|
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.75),
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
...typography('Body', 100),
|
||||||
},
|
},
|
||||||
argumentsContainer: {
|
argumentsContainer: {
|
||||||
marginLeft: 24,
|
marginLeft: 24,
|
||||||
},
|
},
|
||||||
markdownText: {
|
markdownText: {
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 16,
|
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.75),
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
...typography('Body', 50),
|
||||||
},
|
},
|
||||||
responseLabel: {
|
responseLabel: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
paddingLeft: 24,
|
paddingLeft: 24,
|
||||||
},
|
},
|
||||||
responseLabelText: {
|
responseLabelText: {
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 20,
|
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.75),
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
...typography('Body', 100),
|
||||||
},
|
},
|
||||||
resultContainer: {
|
resultContainer: {
|
||||||
marginLeft: 24,
|
marginLeft: 24,
|
||||||
|
|
@ -81,9 +84,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
paddingLeft: 24,
|
paddingLeft: 24,
|
||||||
},
|
},
|
||||||
statusText: {
|
statusText: {
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 20,
|
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.75),
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
...typography('Body', 100),
|
||||||
},
|
},
|
||||||
buttonContainer: {
|
buttonContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
@ -91,6 +93,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
paddingLeft: 36,
|
paddingLeft: 36,
|
||||||
},
|
},
|
||||||
|
resultButtonContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
button: {
|
button: {
|
||||||
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
|
@ -102,10 +110,59 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 16,
|
|
||||||
color: theme.buttonBg,
|
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,
|
onToggleCollapse,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
|
approvalStage,
|
||||||
|
canExpand = true,
|
||||||
|
showArguments = true,
|
||||||
|
showResults = true,
|
||||||
}: ToolCardProps) => {
|
}: ToolCardProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const contentOpacity = useSharedValue(isCollapsed ? 0 : 1);
|
const contentOpacity = useSharedValue(isCollapsed ? 0 : 1);
|
||||||
const chevronRotation = useSharedValue(isCollapsed ? 0 : 90);
|
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 isPending = tool.status === ToolCallStatus.Pending;
|
||||||
const hasLocalDecision = localDecision !== undefined && localDecision !== null;
|
const hasLocalDecision = localDecision !== undefined && localDecision !== null;
|
||||||
const isSuccess = tool.status === ToolCallStatus.Success;
|
const isSuccess = tool.status === ToolCallStatus.Success;
|
||||||
const isError = tool.status === ToolCallStatus.Error;
|
const isError = tool.status === ToolCallStatus.Error;
|
||||||
const isRejected = tool.status === ToolCallStatus.Rejected;
|
const isRejected = tool.status === ToolCallStatus.Rejected;
|
||||||
|
const isResultPhase = approvalStage === ToolApprovalStage.Result;
|
||||||
|
|
||||||
// Convert underscores to spaces and capitalize first letter of each word
|
// Convert underscores to spaces and capitalize first letter of each word
|
||||||
const displayName = useMemo(() => {
|
const displayName = useMemo(() => {
|
||||||
|
|
@ -159,19 +226,22 @@ const ToolCard = ({
|
||||||
}, [tool.result]);
|
}, [tool.result]);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
|
if (!canExpand) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newCollapsed = !isCollapsed;
|
const newCollapsed = !isCollapsed;
|
||||||
contentOpacity.value = withTiming(newCollapsed ? 0 : 1, {duration: 200});
|
contentOpacity.value = withTiming(newCollapsed ? 0 : 1, {duration: 200});
|
||||||
chevronRotation.value = withTiming(newCollapsed ? 0 : 90, {duration: 200});
|
chevronRotation.value = withTiming(newCollapsed ? 0 : 90, {duration: 200});
|
||||||
onToggleCollapse(tool.id);
|
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]);
|
}, [onApprove, tool.id]));
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
const handleReject = usePreventDoubleTap(useCallback(() => {
|
||||||
onReject?.(tool.id);
|
onReject?.(tool.id);
|
||||||
}, [onReject, tool.id]);
|
}, [onReject, tool.id]));
|
||||||
|
|
||||||
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{rotate: `${chevronRotation.value}deg`}],
|
transform: [{rotate: `${chevronRotation.value}deg`}],
|
||||||
|
|
@ -229,13 +299,20 @@ const ToolCard = ({
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testIdPrefix = `agents.tool_card.${tool.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View
|
||||||
<TouchableOpacity
|
style={styles.container}
|
||||||
onPress={handleToggle}
|
testID={testIdPrefix}
|
||||||
style={styles.header}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={canExpand ? handleToggle : undefined}
|
||||||
|
style={styles.header}
|
||||||
|
activeOpacity={canExpand ? 0.7 : 1}
|
||||||
|
testID={`${testIdPrefix}.header`}
|
||||||
|
>
|
||||||
|
{canExpand ? (
|
||||||
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
|
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
|
||||||
<CompassIcon
|
<CompassIcon
|
||||||
name='chevron-right'
|
name='chevron-right'
|
||||||
|
|
@ -243,10 +320,12 @@ const ToolCard = ({
|
||||||
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
<Text
|
<Text
|
||||||
style={styles.toolName}
|
style={styles.toolName}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
|
testID={`${testIdPrefix}.name`}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -254,7 +333,11 @@ const ToolCard = ({
|
||||||
|
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<Animated.View style={contentAnimatedStyle}>
|
<Animated.View style={contentAnimatedStyle}>
|
||||||
<View style={styles.argumentsContainer}>
|
{showArguments && (
|
||||||
|
<View
|
||||||
|
style={styles.argumentsContainer}
|
||||||
|
testID={`${testIdPrefix}.arguments`}
|
||||||
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
baseTextStyle={styles.markdownText}
|
baseTextStyle={styles.markdownText}
|
||||||
value={argumentsMarkdown}
|
value={argumentsMarkdown}
|
||||||
|
|
@ -262,10 +345,14 @@ const ToolCard = ({
|
||||||
location={Screens.CHANNEL}
|
location={Screens.CHANNEL}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{(isSuccess || isError) && resultMarkdown && (
|
{showResults && (isSuccess || isError) && resultMarkdown && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.responseLabel}>
|
<View
|
||||||
|
style={styles.responseLabel}
|
||||||
|
testID={`${testIdPrefix}.result`}
|
||||||
|
>
|
||||||
{isSuccess && (
|
{isSuccess && (
|
||||||
<CompassIcon
|
<CompassIcon
|
||||||
name='check-circle'
|
name='check-circle'
|
||||||
|
|
@ -294,11 +381,38 @@ const ToolCard = ({
|
||||||
location={Screens.CHANNEL}
|
location={Screens.CHANNEL}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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 && (
|
{isRejected && (
|
||||||
<View style={styles.statusContainer}>
|
<View
|
||||||
|
style={styles.statusContainer}
|
||||||
|
testID={`${testIdPrefix}.status.rejected`}
|
||||||
|
>
|
||||||
<CompassIcon
|
<CompassIcon
|
||||||
name='close-circle-outline'
|
name='close-circle-outline'
|
||||||
size={12}
|
size={12}
|
||||||
|
|
@ -315,7 +429,10 @@ const ToolCard = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending && !hasLocalDecision && isProcessing && (
|
{isPending && !hasLocalDecision && isProcessing && (
|
||||||
<View style={styles.statusContainer}>
|
<View
|
||||||
|
style={styles.statusContainer}
|
||||||
|
testID={`${testIdPrefix}.status.processing`}
|
||||||
|
>
|
||||||
<Loading
|
<Loading
|
||||||
size='small'
|
size='small'
|
||||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
|
@ -328,13 +445,14 @@ const ToolCard = ({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending && !hasLocalDecision && !isProcessing && (
|
{isPending && !hasLocalDecision && !isProcessing && onApprove && onReject && (
|
||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleApprove}
|
onPress={handleApprove}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
testID={`${testIdPrefix}.approve`}
|
||||||
>
|
>
|
||||||
<FormattedText
|
<FormattedText
|
||||||
id='agents.tool_call.approve'
|
id='agents.tool_call.approve'
|
||||||
|
|
@ -347,6 +465,7 @@ const ToolCard = ({
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
testID={`${testIdPrefix}.reject`}
|
||||||
>
|
>
|
||||||
<FormattedText
|
<FormattedText
|
||||||
id='agents.tool_call.reject'
|
id='agents.tool_call.reject'
|
||||||
|
|
@ -356,6 +475,47 @@ const ToolCard = ({
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ export const TOUCH_TARGET_SIZE = 44;
|
||||||
*/
|
*/
|
||||||
export const AGENT_WEBSOCKET_EVENT = 'custom_mattermost-ai_postupdate';
|
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
|
* Control signal values from WebSocket messages
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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];
|
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
|
* Tool call data structure
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {AGENT_POST_TYPES} from '@agents/constants';
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
||||||
|
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
|
||||||
|
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {isAgentPost, isPostRequester} from './utils';
|
import {isAgentPost, isPostRequester, isToolCallRedacted, isPendingToolResult, getToolApprovalStage, mergeToolCalls} from './utils';
|
||||||
|
|
||||||
describe('isAgentPost', () => {
|
describe('isAgentPost', () => {
|
||||||
describe('with Post objects', () => {
|
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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {AGENT_POST_TYPES} from '@agents/constants';
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
||||||
|
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
|
||||||
|
|
||||||
import type PostModel from '@typings/database/models/servers/post';
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
|
||||||
|
|
@ -27,3 +28,71 @@ export function isPostRequester(post: PostModel | Post, currentUserId: string):
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,15 @@
|
||||||
"agents.regenerate.confirm_title": "Regenerate Response",
|
"agents.regenerate.confirm_title": "Regenerate Response",
|
||||||
"agents.selector.no_agents": "No agents available",
|
"agents.selector.no_agents": "No agents available",
|
||||||
"agents.selector.title": "Select Agent",
|
"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.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.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.processing": "Processing...",
|
||||||
"agents.tool_call.reject": "Reject",
|
"agents.tool_call.reject": "Reject",
|
||||||
"agents.tool_call.response": "Response",
|
"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.status.rejected": "Rejected",
|
||||||
"agents.tool_call.submitting": "Submitting...",
|
"agents.tool_call.submitting": "Submitting...",
|
||||||
"ai_rewrite.agent_selector_title": "Select AI Agent",
|
"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.description": "You can now choose the skin tone you prefer to use for your emojis.",
|
||||||
"skintone_selector.tooltip.title": "Choose your default skin tone",
|
"skintone_selector.tooltip.title": "Choose your default skin tone",
|
||||||
"smobile.search.recent_title": "Recent searches in {teamName}",
|
"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.regenerate.error": "Failed to regenerate response",
|
||||||
"snack.bar.agent.stop.error": "Failed to stop generation",
|
"snack.bar.agent.stop.error": "Failed to stop generation",
|
||||||
"snack.bar.agent.tool.approval.error": "Failed to submit tool approval",
|
"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.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.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
||||||
"snack.bar.code.copied": "Code copied to clipboard",
|
"snack.bar.code.copied": "Code copied to clipboard",
|
||||||
|
|
|
||||||
459
detox/e2e/test/products/agents/tool_calls_in_channels.e2e.ts
Normal file
459
detox/e2e/test/products/agents/tool_calls_in_channels.e2e.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue