mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-10 05:00:13 +00:00
* [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>
350 lines
13 KiB
TypeScript
350 lines
13 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 TestHelper from '@test/test_helper';
|
|
|
|
import {isAgentPost, isPostRequester, isToolCallRedacted, isPendingToolResult, getToolApprovalStage, mergeToolCalls, resolveSelectedAgent} from './utils';
|
|
|
|
describe('isAgentPost', () => {
|
|
describe('with Post objects', () => {
|
|
it('returns true when post type is custom_llmbot', () => {
|
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLMBOT});
|
|
expect(isAgentPost(post)).toBe(true);
|
|
});
|
|
|
|
it('returns true when post type is custom_llm_postback', () => {
|
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
|
expect(isAgentPost(post)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-agent post types', () => {
|
|
const post = TestHelper.fakePost({type: ''});
|
|
expect(isAgentPost(post)).toBe(false);
|
|
|
|
const systemPost = TestHelper.fakePost({type: 'system_join_channel'});
|
|
expect(isAgentPost(systemPost)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('with PostModel objects', () => {
|
|
it('returns true when post type is custom_llmbot', () => {
|
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLMBOT});
|
|
expect(isAgentPost(postModel)).toBe(true);
|
|
});
|
|
|
|
it('returns true when post type is custom_llm_postback', () => {
|
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
|
expect(isAgentPost(postModel)).toBe(true);
|
|
});
|
|
|
|
it('returns false for non-agent post types', () => {
|
|
const postModel = TestHelper.fakePostModel({type: ''});
|
|
expect(isAgentPost(postModel)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isPostRequester', () => {
|
|
const currentUserId = 'user123';
|
|
|
|
describe('with Post objects', () => {
|
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
llm_requester_user_id: currentUserId,
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(true);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id does not match', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
llm_requester_user_id: 'different_user',
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is undefined', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: undefined,
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is empty object', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id is missing', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {
|
|
some_other_prop: 'value',
|
|
},
|
|
});
|
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('with PostModel objects', () => {
|
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {
|
|
llm_requester_user_id: currentUserId,
|
|
},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(true);
|
|
});
|
|
|
|
it('returns false when llm_requester_user_id does not match', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {
|
|
llm_requester_user_id: 'different_user',
|
|
},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
|
});
|
|
|
|
it('returns false when props is empty object', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {},
|
|
});
|
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('handles exceptions gracefully and returns false', () => {
|
|
// Create a post object that throws when accessing props
|
|
const faultyPost = {
|
|
get props() {
|
|
throw new Error('Access denied');
|
|
},
|
|
} as any;
|
|
|
|
expect(isPostRequester(faultyPost, currentUserId)).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isToolCallRedacted', () => {
|
|
it('should return true when pending_tool_call_redacted is true', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {pending_tool_call_redacted: 'true'},
|
|
});
|
|
expect(isToolCallRedacted(post)).toBe(true);
|
|
});
|
|
|
|
it('should return false when prop is not present', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {some_other_prop: 'value'},
|
|
});
|
|
expect(isToolCallRedacted(post)).toBe(false);
|
|
});
|
|
|
|
it('should return false when props is empty', () => {
|
|
const post = TestHelper.fakePost({props: {}});
|
|
expect(isToolCallRedacted(post)).toBe(false);
|
|
});
|
|
|
|
it('should return true for PostModel with redacted prop', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {pending_tool_call_redacted: 'true'},
|
|
});
|
|
expect(isToolCallRedacted(postModel)).toBe(true);
|
|
});
|
|
|
|
it('should return false when props access throws', () => {
|
|
const faultyPost = {
|
|
get props() {
|
|
throw new Error('Access denied');
|
|
},
|
|
} as any;
|
|
expect(isToolCallRedacted(faultyPost)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isPendingToolResult', () => {
|
|
it('should return true when pending_tool_result is true', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {pending_tool_result: 'true'},
|
|
});
|
|
expect(isPendingToolResult(post)).toBe(true);
|
|
});
|
|
|
|
it('should return false when prop is not present', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {some_other_prop: 'value'},
|
|
});
|
|
expect(isPendingToolResult(post)).toBe(false);
|
|
});
|
|
|
|
it('should return false when props is empty', () => {
|
|
const post = TestHelper.fakePost({props: {}});
|
|
expect(isPendingToolResult(post)).toBe(false);
|
|
});
|
|
|
|
it('should return true for PostModel with pending result prop', () => {
|
|
const postModel = TestHelper.fakePostModel({
|
|
props: {pending_tool_result: 'true'},
|
|
});
|
|
expect(isPendingToolResult(postModel)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getToolApprovalStage', () => {
|
|
const pendingToolCall: ToolCall = {
|
|
id: 'tc1',
|
|
name: 'search',
|
|
description: 'Search tool',
|
|
arguments: {},
|
|
status: ToolCallStatus.Pending,
|
|
};
|
|
|
|
const acceptedToolCall: ToolCall = {
|
|
id: 'tc2',
|
|
name: 'fetch',
|
|
description: 'Fetch tool',
|
|
arguments: {},
|
|
status: ToolCallStatus.Accepted,
|
|
};
|
|
|
|
it('should return Result when pending_tool_result is true', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {pending_tool_result: 'true'},
|
|
});
|
|
expect(getToolApprovalStage(post, [])).toBe(ToolApprovalStage.Result);
|
|
});
|
|
|
|
it('should return Call when there are pending tool calls', () => {
|
|
const post = TestHelper.fakePost({props: {}});
|
|
expect(getToolApprovalStage(post, [pendingToolCall])).toBe(ToolApprovalStage.Call);
|
|
});
|
|
|
|
it('should return Done when no pending tools and no pending result', () => {
|
|
const post = TestHelper.fakePost({props: {}});
|
|
expect(getToolApprovalStage(post, [acceptedToolCall])).toBe(ToolApprovalStage.Done);
|
|
});
|
|
|
|
it('should return Result even when there are pending tool calls', () => {
|
|
const post = TestHelper.fakePost({
|
|
props: {pending_tool_result: 'true'},
|
|
});
|
|
expect(getToolApprovalStage(post, [pendingToolCall])).toBe(ToolApprovalStage.Result);
|
|
});
|
|
});
|
|
|
|
describe('mergeToolCalls', () => {
|
|
const makeToolCall = (overrides: Partial<ToolCall> & {id: string}): ToolCall => ({
|
|
name: 'tool',
|
|
description: 'A tool',
|
|
arguments: {},
|
|
status: ToolCallStatus.Pending,
|
|
...overrides,
|
|
});
|
|
|
|
it('should return publicCalls unchanged when privateCalls is null', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1', arguments: {q: 'hello'}})];
|
|
expect(mergeToolCalls(publicCalls, null)).toBe(publicCalls);
|
|
});
|
|
|
|
it('should return publicCalls unchanged when privateCalls is empty', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1', arguments: {q: 'hello'}})];
|
|
expect(mergeToolCalls(publicCalls, [])).toBe(publicCalls);
|
|
});
|
|
|
|
it('should merge private arguments into public calls while preserving public status', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1', status: ToolCallStatus.Accepted, arguments: {redacted: true}})];
|
|
const privateCalls = [makeToolCall({id: 'tc1', status: ToolCallStatus.Pending, arguments: {q: 'secret'}})];
|
|
|
|
const result = mergeToolCalls(publicCalls, privateCalls);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].arguments).toEqual({q: 'secret'});
|
|
expect(result[0].status).toBe(ToolCallStatus.Accepted);
|
|
});
|
|
|
|
it('should merge private result when present', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1'})];
|
|
const privateCalls = [makeToolCall({id: 'tc1', result: 'done'})];
|
|
|
|
const result = mergeToolCalls(publicCalls, privateCalls);
|
|
expect(result[0].result).toBe('done');
|
|
});
|
|
|
|
it('should not overwrite result when private result is undefined', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1', result: 'original'})];
|
|
const privateCalls = [makeToolCall({id: 'tc1', result: undefined})];
|
|
|
|
const result = mergeToolCalls(publicCalls, privateCalls);
|
|
expect(result[0].result).toBe('original');
|
|
});
|
|
|
|
it('should preserve public tools and append private-only tools', () => {
|
|
const publicCalls = [makeToolCall({id: 'tc1'})];
|
|
const privateCalls = [makeToolCall({id: 'tc_unknown', name: 'private_only', arguments: {x: 1}})];
|
|
|
|
const result = mergeToolCalls(publicCalls, privateCalls);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].id).toBe('tc1');
|
|
expect(result[1].id).toBe('tc_unknown');
|
|
expect(result[1].name).toBe('private_only');
|
|
expect(result[1].arguments).toEqual({x: 1});
|
|
});
|
|
|
|
it('should preserve public-only tools when private is a subset', () => {
|
|
const publicCalls = [
|
|
makeToolCall({id: 'tc1', arguments: {q: 'hello'}}),
|
|
makeToolCall({id: 'tc2', arguments: {q: 'world'}}),
|
|
];
|
|
const privateCalls = [makeToolCall({id: 'tc1', arguments: {q: 'secret'}})];
|
|
|
|
const result = mergeToolCalls(publicCalls, privateCalls);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].id).toBe('tc1');
|
|
expect(result[0].arguments).toEqual({q: 'secret'});
|
|
expect(result[1].id).toBe('tc2');
|
|
expect(result[1].arguments).toEqual({q: 'world'});
|
|
});
|
|
});
|
|
|
|
describe('resolveSelectedAgent', () => {
|
|
const a1 = {id: 'a1'};
|
|
const a2 = {id: 'a2', is_default: true};
|
|
const a3 = {id: 'a3'};
|
|
const agents = [a1, a2, a3];
|
|
|
|
it('should return null for an empty list', () => {
|
|
expect(resolveSelectedAgent([], 'a1')).toBeNull();
|
|
});
|
|
|
|
it('should return the saved preference when it is still available', () => {
|
|
expect(resolveSelectedAgent(agents, 'a3')).toBe(a3);
|
|
});
|
|
|
|
it('should fall back to the system default when the saved preference is missing', () => {
|
|
expect(resolveSelectedAgent(agents, 'unknown')).toBe(a2);
|
|
});
|
|
|
|
it('should fall back to the system default when no preference is provided', () => {
|
|
expect(resolveSelectedAgent(agents, undefined)).toBe(a2);
|
|
expect(resolveSelectedAgent(agents, '')).toBe(a2);
|
|
expect(resolveSelectedAgent(agents, null)).toBe(a2);
|
|
});
|
|
|
|
it('should fall back to the first agent when there is no default and no valid preference', () => {
|
|
const noDefault = [a1, a3];
|
|
expect(resolveSelectedAgent(noDefault, undefined)).toBe(a1);
|
|
expect(resolveSelectedAgent(noDefault, 'nope')).toBe(a1);
|
|
});
|
|
|
|
it('should prefer the saved preference over the system default', () => {
|
|
expect(resolveSelectedAgent(agents, 'a1')).toBe(a1);
|
|
});
|
|
});
|