* 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>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
|
import {ToolApprovalStage, ToolCallStatus, type ToolCall} from '@agents/types';
|
|
|
|
import TestHelper from '@test/test_helper';
|
|
|
|
import {isAgentPost, isPostRequester, isToolCallRedacted, isPendingToolResult, getToolApprovalStage, mergeToolCalls} from './utils';
|
|
|
|
describe('isAgentPost', () => {
|
|
describe('with Post objects', () => {
|
|
it('returns true when post type is custom_llmbot', () => {
|
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLMBOT});
|
|
expect(isAgentPost(post)).toBe(true);
|
|
});
|
|
|
|
it('returns true when post type is custom_llm_postback', () => {
|
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
|
expect(isAgentPost(post)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-agent post types', () => {
|
|
const post = TestHelper.fakePost({type: ''});
|
|
expect(isAgentPost(post)).toBe(false);
|
|
|
|
const systemPost = TestHelper.fakePost({type: 'system_join_channel'});
|
|
expect(isAgentPost(systemPost)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('with PostModel objects', () => {
|
|
it('returns true when post type is custom_llmbot', () => {
|
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLMBOT});
|
|
expect(isAgentPost(postModel)).toBe(true);
|
|
});
|
|
|
|
it('returns true when post type is custom_llm_postback', () => {
|
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
|
expect(isAgentPost(postModel)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-agent post types', () => {
|
|
const postModel = TestHelper.fakePostModel({type: ''});
|
|
expect(isAgentPost(postModel)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isPostRequester', () => {
|
|
const currentUserId = 'user123';
|
|
|
|
describe('with Post objects', () => {
|
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
llm_requester_user_id: currentUserId,
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(true);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id does not match', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
llm_requester_user_id: 'different_user',
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is undefined', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: undefined,
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is empty object', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id is missing', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
some_other_prop: 'value',
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('with PostModel objects', () => {
|
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {
|
|
llm_requester_user_id: currentUserId,
|
|
},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(true);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id does not match', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {
|
|
llm_requester_user_id: 'different_user',
|
|
},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is empty object', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('handles exceptions gracefully and returns false', () => {
|
|
// Create a post object that throws when accessing props
|
|
const faultyPost = {
|
|
get props() {
|
|
throw new Error('Access denied');
|
|
},
|
|
} as any;
|
|
|
|
expect(isPostRequester(faultyPost, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
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'});
|
|
});
|
|
});
|