mattermost-mobile/app/products/agents/utils.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

98 lines
2.9 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 type PostModel from '@typings/database/models/servers/post';
/**
* Check if a post is an agent post
*/
export function isAgentPost(post: PostModel | Post): boolean {
return post.type === AGENT_POST_TYPES.LLMBOT ||
post.type === AGENT_POST_TYPES.LLM_POSTBACK;
}
/**
* Check if the current user is the requester of an agent post
* @param post The agent post
* @param currentUserId The current user ID
* @returns true if current user is the requester
*/
export function isPostRequester(post: PostModel | Post, currentUserId: string): boolean {
try {
const props = post.props as Record<string, unknown>;
return props?.llm_requester_user_id === currentUserId;
} catch {
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;
}