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

154 lines
5.1 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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';
export type {Agent};
export interface ClientAgentsMix {
getAgentsRoute: () => string;
getAgents: () => Promise<Agent[]>;
stopGeneration: (postId: string) => Promise<void>;
regenerateResponse: (postId: string) => Promise<void>;
doChannelAnalysis: (
channelId: string,
analysisType: string,
botUsername: string,
options?: ChannelAnalysisOptions,
) => Promise<ChannelAnalysisResponse>;
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
getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise<string>;
getAgentsStatus: () => Promise<AgentsStatusResponse>;
}
const ClientAgents = (superclass: any) => class extends superclass {
getAgentsRoute = () => {
return '/plugins/mattermost-ai';
};
getAgents = async (): Promise<Agent[]> => {
const response = await this.doFetch(
`${this.urlVersion}/agents`,
{method: 'get'},
);
// Handle both array response and wrapped response
if (Array.isArray(response)) {
return response;
}
return (response as AgentsResponse).agents || [];
};
stopGeneration = async (postId: string) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/stop`,
{method: 'post'},
);
};
regenerateResponse = async (postId: string) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/regenerate`,
{method: 'post'},
);
};
doChannelAnalysis = async (
channelId: string,
analysisType: string,
botUsername: string,
options?: ChannelAnalysisOptions,
): Promise<ChannelAnalysisResponse> => {
const {since, until, days, prompt, unreads_only} = options || {};
return this.doFetch(
`${this.getAgentsRoute()}/channel/${channelId}/analyze?botUsername=${encodeURIComponent(botUsername)}`,
{
method: 'post',
body: {
analysis_type: analysisType,
since,
until,
days,
prompt,
unreads_only,
},
},
);
};
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
return this.doFetch(
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
{
method: 'post',
body: {accepted_tool_ids: acceptedToolIds},
},
);
};
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
// =========================================================================
getRewrittenMessage = async (message: string, action?: string, customPrompt?: string, agentId?: string): Promise<string> => {
const body: RewriteRequest = {
agent_id: agentId,
message,
action,
custom_prompt: customPrompt,
};
const response = await this.doFetch(
`${this.urlVersion}/posts/rewrite`,
{method: 'post', body},
true,
) as RewriteResponse;
// Handle cases where the AI returns plain text instead of the expected JSON format.
// If rewritten_text is undefined, treat the entire response as the rewritten message.
if (response.rewritten_text === undefined) {
return response as unknown as string;
}
return response.rewritten_text;
};
getAgentsStatus = async (): Promise<AgentsStatusResponse> => {
return this.doFetch(
`${this.urlVersion}/agents/status`,
{method: 'get'},
);
};
};
export default ClientAgents;