mattermost-mobile-upstream/app/products/agents/utils.ts
Christopher Speller d3b43df466
Reconcile mobile Agents post display with webapp (multi-round, inline thread, tool approval UX) (#9841)
* Reconcile mobile Agents post display with webapp (multi-round, inline thread, tool approval UX)

* Address review feedback: noRegen parsing, continue-seq reset, reasoning anim sync, batch double-tap guard, pagination resilience

* Add AgentMentionReminderPost tests and strengthen agents test assertions per review

* Address review: fix citation dedup, stop guard, loop-in persistence; remove dead code; nits

* Simplify reasoning display to uncontrolled and drop redundant persisted-rounds ref

* Sync package-lock version with package.json
2026-08-05 13:54:24 +00:00

130 lines
4.0 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 a post is an agent @mention reminder post (an interactive "loop in
* the agent" hint). Distinct from isAgentPost so it routes to its own renderer,
* not the streaming AgentPost.
*/
export function isAgentMentionReminderPost(post: PostModel | Post): boolean {
return post.type === AGENT_POST_TYPES.AGENT_MENTION_REMINDER;
}
/**
* 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;
}