* 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>
98 lines
2.9 KiB
TypeScript
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;
|
|
}
|