mattermost-mobile/app/products/agents/utils.test.ts
Nick Misasi 9e0735c00d
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>
2026-02-19 11:20:25 -05:00

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