mattermost-mobile-upstream/app/products/agents/utils.ts
Nick Misasi 344062a19d
[MM-68856] Respect default agent and persist last-selected agent in agent selectors (#9829)
* [MM-68856] Respect default agent and persist last-selected agent in agent selectors

Mobile agent selectors always defaulted to the first agent and did not
remember the user's previous choice, ignoring the system-wide default
agent configured on the server.

Selectors now resolve the active agent with the precedence
saved-preference -> system default (is_default) -> first, and persist
explicit selections via the shared `agents/selected_agent` core
preference so the choice restores across surfaces and clients.

Applied to the rewrite options, channel summary, and agent chat
selectors. Part of a coordinated 3-repo change (webapp,
mattermost-plugin-agents, mobile) sharing the same preference contract.

Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-68856] Address review feedback on agent selector

- await savePreference in saveSelectedAgent so its errors stay inside the
  try/catch and callers get a consistent {error} result.
- Surface persistence failures from the resolved {error} on all three
  selector surfaces (agent chat, channel summary, rewrite options) via
  logError instead of dropping them silently.
- Rename agent selection tests to the `should...` convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-68856] Address review feedback on agent selector structure

Split channel summary and rewrite options HOCs into index files, switch
persist calls to async/await, and cover non-empty selectedAgentId on mount.

Co-authored-by: Cursor <cursoragent@cursor.com>

* [MM-68856] Log agent selection failures with getFullErrorMessage

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 20:13:11 -04:00

121 lines
3.7 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';
/**
* Resolve which agent/bot should be selected when a selector opens.
* Precedence: saved preference (if still available) -> system default -> first.
* `is_default` is tolerated when absent (older servers / DB-backed lists).
*/
export function resolveSelectedAgent<T extends {id: string; is_default?: boolean}>(agents: T[], savedPrefId?: string | null): T | null {
if (agents.length === 0) {
return null;
}
if (savedPrefId) {
const saved = agents.find((a) => a.id === savedPrefId);
if (saved) {
return saved;
}
}
return agents.find((a) => a.is_default) ?? agents[0];
}
/**
* 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. Returns Done when
* there is no outstanding user decision (no pending tools and no redacted
* result flag).
*/
export function getToolApprovalStage(post: PostModel | Post, toolCalls: ToolCall[]): ToolApprovalStage {
if (isPendingToolResult(post)) {
return ToolApprovalStage.Result;
}
if (toolCalls.some((tc) => tc.status === ToolCallStatus.Pending)) {
return ToolApprovalStage.Call;
}
return ToolApprovalStage.Done;
}
/**
* 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;
}