Ability to E2E AI Bridge features + Initial Recaps E2E (#35541)

* Add shared AI bridge seam

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add AI bridge test helper API

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add AI bridge seam test coverage

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add Playwright AI bridge recap helpers

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix recap channel persistence test

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Restore bridge client compatibility shim

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Expand recap card in Playwright spec

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Recaps e2e test coverage (#35543)

* Add Recaps Playwright page object

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Expand AI recap Playwright coverage

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Format recap Playwright coverage

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix recap regeneration test flows

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

---------

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

* Fix AI bridge lint and OpenAPI docs

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix recap lint shadowing

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Stabilize failed recap regeneration spec

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fill AI bridge i18n strings

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix i18n

* Add service completion bridge path and operation tracking fields

Extend AgentsBridge with CompleteService for service-based completions,
add ClientOperation/OperationSubType tracking to BridgeCompletionRequest,
and propagate operation metadata through to the bridge client.

Made-with: Cursor

* Fill empty i18n translation strings for enterprise keys

The previous "Fix i18n" commit added 145 i18n entries with empty
translation strings, causing the i18n check to fail in CI. Fill in
all translations based on the corresponding error messages in the
enterprise and server source code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix i18n

* Fix i18n again

* Rename Complete/CompleteService to AgentCompletion/ServiceCompletion

Align the AgentsBridge interface method names with the underlying
bridge client methods they delegate to (AgentCompletion, ServiceCompletion).

Made-with: Cursor

* Refactor

* Add e2eAgentsBridge implementation

The new file was missed from the prior refactor commit.

Made-with: Cursor

* Address CodeRabbit review feedback

- Add 400 BadRequest response to AI bridge PUT endpoint OpenAPI spec
- Add missing client_operation, operation_sub_type, service_id fields to
  AIBridgeTestHelperRecordedRequest schema
- Deep-clone nested JSON schema values in cloneJSONOutputFormat
- Populate ChannelID on recap summary bridge requests
- Fix msg_count assertion to mention_count for mark-as-read verification
- Make AgentCompletion/ServiceCompletion mutex usage atomic

Made-with: Cursor

* fix(playwright): align recaps page object with placeholder and channel menu

Made-with: Cursor

* fix(playwright): update recaps expectEmptyState to match RecapsList empty state

After the master merge, the recaps page now renders RecapsList's
"You're all caught up" empty state instead of the old placeholder.

Made-with: Cursor

* chore(playwright): update package-lock.json after npm install

Made-with: Cursor

* Revert "chore(playwright): update package-lock.json after npm install"

This reverts commit 95c670863a.

* style(playwright): fix prettier formatting in recaps page object

Made-with: Cursor

* fix(playwright): handle both recaps empty states correctly

The recaps page has two distinct empty states:
- Setup placeholder ("Set up your recap") when allRecaps is empty
- RecapsList caught-up state ("You're all caught up") when the
  filtered tab list is empty

Split expectEmptyState into expectSetupPlaceholder and
expectCaughtUpEmptyState, used by the delete and bridge-unavailable
tests respectively.

Made-with: Cursor

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Misasi
2026-03-30 12:20:47 -04:00
committed by GitHub
parent ece6b956fa
commit c81d0ddd73
31 changed files with 2835 additions and 111 deletions
+14 -2
View File
@@ -620,6 +620,7 @@
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -906,6 +907,7 @@
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz",
"integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==",
"hasInstallScript": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -1708,6 +1710,7 @@
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.12.3.tgz",
"integrity": "sha512-c8NKkO4R2lShkSXZ2Ongj1ycjugjzFFo/UswHBnS62y07DMcTc9Rvo03/3nRyszIvwPNljlkd4S828zIBv/piw==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
@@ -2171,6 +2174,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2182,6 +2186,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2557,6 +2562,7 @@
"version": "6.1.11",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.11.tgz",
"integrity": "sha512-Ui0jXPzbp1phYij90h12ksljKGqF8ncGx+pjrNPsSPhbUUjWT2tD1FwGo2LF6USCnbrsIhNngDfodhxbegfEOA==",
"peer": true,
"dependencies": {
"@emotion/is-prop-valid": "1.2.2",
"@emotion/unitless": "0.8.1",
@@ -3428,6 +3434,7 @@
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -3614,7 +3621,8 @@
"core-js": {
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz",
"integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw=="
"integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==",
"peer": true
},
"css-color-keywords": {
"version": "1.0.0",
@@ -4202,7 +4210,8 @@
"mobx": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/mobx/-/mobx-6.12.3.tgz",
"integrity": "sha512-c8NKkO4R2lShkSXZ2Ongj1ycjugjzFFo/UswHBnS62y07DMcTc9Rvo03/3nRyszIvwPNljlkd4S828zIBv/piw=="
"integrity": "sha512-c8NKkO4R2lShkSXZ2Ongj1ycjugjzFFo/UswHBnS62y07DMcTc9Rvo03/3nRyszIvwPNljlkd4S828zIBv/piw==",
"peer": true
},
"mobx-react": {
"version": "7.6.0",
@@ -4511,6 +4520,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
@@ -4519,6 +4529,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -4792,6 +4803,7 @@
"version": "6.1.11",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.11.tgz",
"integrity": "sha512-Ui0jXPzbp1phYij90h12ksljKGqF8ncGx+pjrNPsSPhbUUjWT2tD1FwGo2LF6USCnbrsIhNngDfodhxbegfEOA==",
"peer": true,
"requires": {
"@emotion/is-prop-valid": "1.2.2",
"@emotion/unitless": "0.8.1",
+139
View File
@@ -4155,6 +4155,145 @@ components:
reason:
type: string
description: Reason code if not available (translation ID)
AIBridgeTestHelperStatus:
type: object
properties:
available:
type: boolean
description: Whether the mocked AI bridge should be reported as available
reason:
type: string
description: Optional reason code when the mocked AI bridge is unavailable
AIBridgeTestHelperFeatureFlags:
type: object
properties:
enable_ai_plugin_bridge:
type: boolean
description: Override for the EnableAIPluginBridge feature flag in test mode
enable_ai_recaps:
type: boolean
description: Override for the EnableAIRecaps feature flag in test mode
AIBridgeTestHelperCompletion:
type: object
properties:
completion:
type: string
description: Mocked completion payload returned for a queued bridge operation
error:
type: string
description: Mocked error message returned for a queued bridge operation
status_code:
type: integer
description: Optional HTTP-style status code associated with a mocked error
AIBridgeTestHelperMessage:
type: object
properties:
role:
type: string
description: Role associated with the message payload
message:
type: string
description: Message content sent through the AI bridge
file_ids:
type: array
description: Optional file IDs attached to the bridge message
items:
type: string
AIBridgeTestHelperConfig:
type: object
properties:
status:
$ref: "#/components/schemas/AIBridgeTestHelperStatus"
agents:
type: array
items:
$ref: "#/components/schemas/BridgeAgentInfo"
description: Mock agent list returned from the bridge
services:
type: array
items:
$ref: "#/components/schemas/BridgeServiceInfo"
description: Mock service list returned from the bridge
agent_completions:
type: object
description: Queued mocked completion responses keyed by explicit bridge operation name
additionalProperties:
type: array
items:
$ref: "#/components/schemas/AIBridgeTestHelperCompletion"
feature_flags:
$ref: "#/components/schemas/AIBridgeTestHelperFeatureFlags"
record_requests:
type: boolean
description: Whether bridge requests should be recorded for later inspection
AIBridgeTestHelperRecordedRequest:
type: object
properties:
operation:
type: string
description: Explicit bridge operation key such as recap_summary or rewrite
client_operation:
type: string
description: Client-facing operation routed through the bridge client
operation_sub_type:
type: string
description: Optional subtype used to disambiguate bridge requests
session_user_id:
type: string
description: Session user ID used when invoking the bridge
user_id:
type: string
description: Optional effective user ID passed through the bridge request
channel_id:
type: string
description: Optional channel context passed through the bridge request
agent_id:
type: string
description: Agent ID targeted by the bridge completion request
service_id:
type: string
description: Service ID targeted by the bridge completion request
messages:
type: array
items:
$ref: "#/components/schemas/AIBridgeTestHelperMessage"
description: Bridge messages sent for the recorded request
json_output_format:
type: object
description: Optional JSON schema requested for structured bridge output
additionalProperties: true
AIBridgeTestHelperState:
type: object
properties:
status:
$ref: "#/components/schemas/AIBridgeTestHelperStatus"
agents:
type: array
items:
$ref: "#/components/schemas/BridgeAgentInfo"
description: Current mocked agent list
services:
type: array
items:
$ref: "#/components/schemas/BridgeServiceInfo"
description: Current mocked service list
agent_completions:
type: object
description: Remaining queued mocked completions keyed by bridge operation
additionalProperties:
type: array
items:
$ref: "#/components/schemas/AIBridgeTestHelperCompletion"
feature_flags:
$ref: "#/components/schemas/AIBridgeTestHelperFeatureFlags"
record_requests:
type: boolean
description: Whether bridge request recording is currently enabled
recorded_requests:
type: array
description: Recorded bridge requests captured while record_requests was enabled
items:
$ref: "#/components/schemas/AIBridgeTestHelperRecordedRequest"
PostAcknowledgement:
type: object
properties:
+84
View File
@@ -185,6 +185,90 @@
$ref: "#/components/schemas/StatusOK"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/system/e2e/ai_bridge:
put:
tags:
- system
summary: Configure AI bridge E2E test helper
description: >
Configure the in-memory AI bridge test helper used by end-to-end tests to
mock agent availability, agent/service listings, queued completion
responses, and test-only AI feature flag overrides.
This endpoint is only available when `EnableTesting` is enabled.
##### Permissions
Must have `manage_system` permission.
operationId: SetAIBridgeTestHelper
requestBody:
description: AI bridge E2E helper configuration
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AIBridgeTestHelperConfig"
responses:
"200":
description: AI bridge test helper configured successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AIBridgeTestHelperState"
"400":
$ref: "#/components/responses/BadRequest"
"403":
$ref: "#/components/responses/Forbidden"
"501":
$ref: "#/components/responses/NotImplemented"
get:
tags:
- system
summary: Get AI bridge E2E test helper state
description: >
Retrieve the current in-memory AI bridge test helper state used for end-to-end tests.
This endpoint is only available when `EnableTesting` is enabled.
##### Permissions
Must have `manage_system` permission.
operationId: GetAIBridgeTestHelper
responses:
"200":
description: AI bridge test helper state retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AIBridgeTestHelperState"
"403":
$ref: "#/components/responses/Forbidden"
"501":
$ref: "#/components/responses/NotImplemented"
delete:
tags:
- system
summary: Reset AI bridge E2E test helper
description: >
Reset the in-memory AI bridge test helper state used for end-to-end tests.
This endpoint is only available when `EnableTesting` is enabled.
##### Permissions
Must have `manage_system` permission.
operationId: DeleteAIBridgeTestHelper
responses:
"200":
description: AI bridge test helper was reset successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"403":
$ref: "#/components/responses/Forbidden"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/database/recycle:
post:
tags:
@@ -36,6 +36,7 @@ export class TestBrowser {
const systemConsolePage = new pages.SystemConsolePage(page);
const scheduledPostsPage = new pages.ScheduledPostsPage(page);
const draftsPage = new pages.DraftsPage(page);
const recapsPage = new pages.RecapsPage(page);
const threadsPage = new pages.ThreadsPage(page);
const contentReviewPage = new pages.ContentReviewPage(page);
@@ -48,6 +49,7 @@ export class TestBrowser {
systemConsolePage,
scheduledPostsPage,
draftsPage,
recapsPage,
threadsPage,
contentReviewPage,
};
+1
View File
@@ -13,6 +13,7 @@ export {
ChannelsPage,
LandingLoginPage,
LoginPage,
RecapsPage,
ResetPasswordPage,
SignupPage,
ScheduledPostsPage,
@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {AdminConfig} from '@mattermost/types/config';
import {getRandomId} from '@/util';
type AIBridgeOperation = 'recap_summary' | 'rewrite';
export type AIBridgeMockStatus = {
available: boolean;
reason?: string;
};
export type AIBridgeMockAgent = {
id: string;
displayName: string;
username: string;
service_id: string;
service_type: string;
is_default?: boolean;
};
export type AIBridgeMockService = {
id: string;
name: string;
type: string;
};
export type AIBridgeMockCompletion = {
completion?: string;
error?: string;
status_code?: number;
};
export type AIBridgeMockMessage = {
role: string;
message: string;
file_ids?: string[];
};
export type AIBridgeMockRecordedRequest = {
operation: AIBridgeOperation | string;
client_operation?: string;
operation_sub_type?: string;
session_user_id?: string;
user_id?: string;
channel_id?: string;
agent_id?: string;
service_id?: string;
messages: AIBridgeMockMessage[];
json_output_format?: Record<string, unknown>;
};
export type AIBridgeMockConfig = {
status?: AIBridgeMockStatus;
agents?: AIBridgeMockAgent[];
services?: AIBridgeMockService[];
agent_completions?: Partial<Record<AIBridgeOperation | string, AIBridgeMockCompletion[]>>;
feature_flags?: {
enable_ai_plugin_bridge?: boolean;
enable_ai_recaps?: boolean;
};
record_requests?: boolean;
};
export type AIBridgeMockState = AIBridgeMockConfig & {
record_requests: boolean;
recorded_requests: AIBridgeMockRecordedRequest[];
};
type EnableAIBridgeTestModeOptions = {
enableRecaps?: boolean;
};
type CreateMockAIAgentOverrides = {
agent?: Partial<AIBridgeMockAgent>;
service?: Partial<AIBridgeMockService>;
status?: AIBridgeMockStatus;
record_requests?: boolean;
};
const AI_BRIDGE_TEST_HELPER_ROUTE = '/system/e2e/ai_bridge';
async function doAdminFetch<T>(adminClient: Client4, method: 'GET' | 'PUT' | 'DELETE', body?: unknown): Promise<T> {
const route = `${adminClient.getBaseRoute()}${AI_BRIDGE_TEST_HELPER_ROUTE}`;
return (adminClient as any).doFetch(route, {
method,
...(body === undefined ? {} : {body: JSON.stringify(body)}),
});
}
function upsertById<T extends {id: string}>(items: T[], item: T): T[] {
const existingIndex = items.findIndex((existingItem) => existingItem.id === item.id);
if (existingIndex === -1) {
return [...items, item];
}
const nextItems = [...items];
nextItems[existingIndex] = item;
return nextItems;
}
export async function enableAIBridgeTestMode(
adminClient: Client4,
{enableRecaps = false}: EnableAIBridgeTestModeOptions = {},
): Promise<AdminConfig> {
await adminClient.patchConfig({
ServiceSettings: {
EnableTesting: true,
},
});
await configureAIBridgeMock(adminClient, {
feature_flags: {
enable_ai_plugin_bridge: true,
...(enableRecaps ? {enable_ai_recaps: true} : {}),
},
});
return adminClient.getConfig();
}
export async function configureAIBridgeMock(
adminClient: Client4,
config: AIBridgeMockConfig,
): Promise<AIBridgeMockState> {
return doAdminFetch<AIBridgeMockState>(adminClient, 'PUT', config);
}
export async function getAIBridgeMock(adminClient: Client4): Promise<AIBridgeMockState> {
return doAdminFetch<AIBridgeMockState>(adminClient, 'GET');
}
export async function resetAIBridgeMock(adminClient: Client4): Promise<void> {
await doAdminFetch(adminClient, 'DELETE');
}
export async function createMockAIAgent(
adminClient: Client4,
overrides: CreateMockAIAgentOverrides = {},
): Promise<{agent: AIBridgeMockAgent; service: AIBridgeMockService; state: AIBridgeMockState}> {
const state = await getAIBridgeMock(adminClient);
const randomId = await getRandomId();
const service: AIBridgeMockService = {
id: overrides.service?.id ?? overrides.agent?.service_id ?? `mock-service-${randomId}`,
name: overrides.service?.name ?? 'Mock AI Service',
type: overrides.service?.type ?? overrides.agent?.service_type ?? 'openaicompatible',
};
const agent: AIBridgeMockAgent = {
id: overrides.agent?.id ?? `mock-agent-${randomId}`,
displayName: overrides.agent?.displayName ?? 'Mock AI Agent',
username: overrides.agent?.username ?? `mock.ai.${randomId}`,
service_id: overrides.agent?.service_id ?? service.id,
service_type: overrides.agent?.service_type ?? service.type,
is_default: overrides.agent?.is_default ?? (state.agents ?? []).length === 0,
};
const nextState = await configureAIBridgeMock(adminClient, {
status: overrides.status ?? state.status,
agents: upsertById(state.agents ?? [], agent),
services: upsertById(state.services ?? [], service),
agent_completions: state.agent_completions,
record_requests: overrides.record_requests ?? state.record_requests,
});
return {agent, service, state: nextState};
}
export function rewriteCompletion(text: string): AIBridgeMockCompletion {
return {
completion: JSON.stringify({rewritten_text: text}),
};
}
export function recapCompletion({
highlights,
actionItems,
}: {
highlights: string[];
actionItems: string[];
}): AIBridgeMockCompletion {
return {
completion: JSON.stringify({
highlights,
action_items: actionItems,
}),
};
}
@@ -8,6 +8,15 @@ export {initSetup, getAdminClient} from './init';
export {createRandomPost} from './post';
export {createNewTeam, createRandomTeam} from './team';
export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user';
export {
enableAIBridgeTestMode,
configureAIBridgeMock,
getAIBridgeMock,
resetAIBridgeMock,
createMockAIAgent,
rewriteCompletion,
recapCompletion,
} from './ai_bridge';
export {
createUserWithAttributes,
enableABAC,
@@ -18,6 +18,8 @@ import {
} from './flag';
import {getBlobFromAsset, getFileFromAsset} from './file';
import {
configureAIBridgeMock,
createMockAIAgent,
createNewUserProfile,
createNewTeam,
createRandomChannel,
@@ -25,11 +27,16 @@ import {
createRandomTeam,
createRandomUser,
createUserWithAttributes,
enableAIBridgeTestMode,
getAIBridgeMock,
getAdminClient,
initSetup,
isOutsideRemoteUserHour,
makeClient,
mergeWithOnPremServerConfig,
recapCompletion,
resetAIBridgeMock,
rewriteCompletion,
installAndEnablePlugin,
isPluginActive,
} from './server';
@@ -92,6 +99,13 @@ export class PlaywrightExtended {
readonly getAdminClient;
readonly mergeWithOnPremServerConfig;
readonly initSetup;
readonly enableAIBridgeTestMode;
readonly configureAIBridgeMock;
readonly getAIBridgeMock;
readonly resetAIBridgeMock;
readonly createMockAIAgent;
readonly rewriteCompletion;
readonly recapCompletion;
readonly installAndEnablePlugin;
readonly isPluginActive;
@@ -153,6 +167,13 @@ export class PlaywrightExtended {
this.initSetup = initSetup;
this.getAdminClient = getAdminClient;
this.mergeWithOnPremServerConfig = mergeWithOnPremServerConfig;
this.enableAIBridgeTestMode = enableAIBridgeTestMode;
this.configureAIBridgeMock = configureAIBridgeMock;
this.getAIBridgeMock = getAIBridgeMock;
this.resetAIBridgeMock = resetAIBridgeMock;
this.createMockAIAgent = createMockAIAgent;
this.rewriteCompletion = rewriteCompletion;
this.recapCompletion = recapCompletion;
this.isOutsideRemoteUserHour = isOutsideRemoteUserHour;
this.installAndEnablePlugin = installAndEnablePlugin;
this.isPluginActive = isPluginActive;
@@ -4,6 +4,7 @@
import ChannelsPage from './channels';
import LandingLoginPage from './landing_login';
import LoginPage from './login';
import RecapsPage from './recaps';
import ResetPasswordPage from './reset_password';
import SignupPage from './signup';
import SystemConsolePage from './system_console';
@@ -16,6 +17,7 @@ const pages = {
ChannelsPage,
LandingLoginPage,
LoginPage,
RecapsPage,
ResetPasswordPage,
SignupPage,
ScheduledPostsPage,
@@ -32,6 +34,7 @@ export {
DraftsPage,
LandingLoginPage,
LoginPage,
RecapsPage,
ResetPasswordPage,
SignupPage,
ScheduledPostsPage,
@@ -0,0 +1,286 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, Page, expect} from '@playwright/test';
import {duration} from '@/util';
class CreateRecapModal {
readonly container: Locator;
readonly titleInput: Locator;
readonly channelSearchInput: Locator;
constructor(private readonly page: Page) {
this.container = page.locator('#createRecapModal');
this.titleInput = this.container.locator('#recap-name-input');
this.channelSearchInput = this.container.getByPlaceholder('Search and select channels');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async fillTitle(title: string) {
await expect(this.titleInput).toBeVisible();
await this.titleInput.fill(title);
}
async selectSelectedChannels() {
await this.container.getByRole('button', {name: 'Recap selected channels'}).click();
}
async selectAllUnreads() {
await this.container.getByRole('button', {name: 'Recap all my unreads'}).click();
}
async clickNext() {
await this.container.getByRole('button', {name: 'Next'}).click();
}
async clickPrevious() {
await this.container.getByRole('button', {name: 'Previous'}).click();
}
async startRecap() {
await this.container.getByRole('button', {name: 'Start recap'}).click();
await expect(this.container).not.toBeVisible({timeout: duration.ten_sec});
}
async expectChannelSelectorVisible() {
await expect(this.channelSearchInput).toBeVisible();
}
async expectChannelSelectorHidden() {
await expect(this.channelSearchInput).not.toBeVisible();
}
async searchChannel(channelName: string) {
await this.channelSearchInput.fill(channelName);
}
getChannelOption(channelName: string) {
return this.container.locator('.channel-selector-item').filter({hasText: channelName});
}
async selectChannel(channelName: string) {
const channelOption = this.getChannelOption(channelName);
await expect(channelOption).toBeVisible();
await channelOption.click();
await expect(channelOption.locator('input[type="checkbox"]')).toBeChecked();
}
async expectSummaryChannels(channelNames: string[]) {
for (const channelName of channelNames) {
await expect(this.container.locator('.summary-channel-item').filter({hasText: channelName})).toBeVisible();
}
}
async selectAgent(agentName: string) {
await this.container.getByLabel('Agent selector').click();
await this.page
.getByRole('menuitem', {name: new RegExp(`^${escapeRegExp(agentName)}(?: \\(default\\))?$`)})
.click();
}
}
class RecapChannelCard {
readonly channelButton: Locator;
readonly collapseButton: Locator;
readonly menuButton: Locator;
constructor(
private readonly page: Page,
readonly container: Locator,
) {
this.channelButton = container.locator('.recap-channel-name-tag');
this.collapseButton = container.locator('.recap-channel-collapse-button');
// Scope to header actions so we do not match the parent .recap-channel-header (role="button").
this.menuButton = container
.locator('.recap-channel-header-actions')
.getByRole('button', {name: /Options for /});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async clickChannelName() {
await this.channelButton.click();
}
async toggleCollapse() {
await this.collapseButton.click();
}
async expectText(text: string) {
await expect(this.container).toContainText(text);
}
async openMenuAction(actionName: string) {
await this.menuButton.click();
await this.page.getByRole('menuitem', {name: actionName}).click();
}
}
class RecapItem {
readonly header: Locator;
readonly markReadButton: Locator;
readonly deleteButton: Locator;
readonly menuButton: Locator;
constructor(
private readonly page: Page,
readonly container: Locator,
) {
this.header = container.locator('.recap-item-header');
this.markReadButton = container.getByRole('button', {name: 'Mark read'});
this.deleteButton = container.locator('.recap-delete-button');
this.menuButton = this.header.getByRole('button', {name: /Options for /});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async expectProcessing() {
await expect(this.container).toContainText("Recap created. You'll receive a summary shortly");
await expect(this.container).toContainText("We're working on your recap. Check back shortly");
}
async expectFailed() {
await expect(this.container).toContainText('Failed');
}
async expectText(text: string) {
await expect(this.container).toContainText(text);
}
async isExpanded() {
const className = await this.container.getAttribute('class');
return className?.includes('expanded') ?? false;
}
async expand() {
if (await this.isExpanded()) {
return;
}
await this.header.click();
await expect(this.container).toHaveClass(/expanded/);
}
async clickMarkRead() {
await this.markReadButton.click();
}
async clickDelete() {
await this.deleteButton.click();
}
async openMenuAction(actionName: string) {
await this.menuButton.click();
await this.page.getByRole('menuitem', {name: actionName}).click();
}
getChannelCard(channelName: string) {
return new RecapChannelCard(
this.page,
this.container.locator('.recap-channel-card').filter({hasText: channelName}).first(),
);
}
}
export default class RecapsPage {
readonly heading: Locator;
readonly unreadTab: Locator;
readonly readTab: Locator;
readonly addRecapButton: Locator;
readonly createRecapModal: CreateRecapModal;
constructor(readonly page: Page) {
this.heading = page.getByRole('heading', {name: 'Recaps'});
this.unreadTab = page.getByRole('button', {name: 'Unread', exact: true});
this.readTab = page.getByRole('button', {name: 'Read', exact: true});
this.addRecapButton = page.getByRole('button', {name: 'Add a recap'});
this.createRecapModal = new CreateRecapModal(page);
}
async goto(teamName: string) {
await this.page.goto(`/${teamName}/recaps`);
await this.dismissViewInBrowserPrompt();
}
async toBeVisible() {
await expect(this.page).toHaveURL(/.*\/recaps/);
await expect(this.heading).toBeVisible({timeout: duration.one_min});
}
async dismissViewInBrowserPrompt() {
const viewInBrowserButton = this.page.getByRole('button', {name: 'View in Browser'});
if (await viewInBrowserButton.isVisible({timeout: 1000}).catch(() => false)) {
await viewInBrowserButton.click();
}
}
async openCreateRecap() {
await this.addRecapButton.click();
await this.createRecapModal.toBeVisible();
return this.createRecapModal;
}
async switchToUnread() {
await this.unreadTab.click();
await expect(this.unreadTab).toHaveClass(/active/);
}
async switchToRead() {
await this.readTab.click();
await expect(this.readTab).toHaveClass(/active/);
}
async expectSetupPlaceholder() {
await expect(this.page.getByRole('heading', {name: 'Set up your recap'})).toBeVisible();
await expect(
this.page.getByText(
'Recaps help you get caught up quickly on discussions that are most important to you with a summarized report.',
),
).toBeVisible();
await expect(this.page.getByRole('button', {name: 'Create a recap'})).toBeVisible();
}
async expectCaughtUpEmptyState() {
await expect(this.page.getByRole('heading', {name: "You're all caught up"})).toBeVisible();
await expect(this.page.getByText("You don't have any recaps yet. Create one to get started.")).toBeVisible();
}
async expectAddRecapDisabled(reason: string) {
await expect(this.addRecapButton).toBeDisabled();
await expect(this.addRecapButton).toHaveAttribute('title', reason);
}
async confirmDelete() {
const dialog = this.page.locator('#confirmModal');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', {name: 'Delete'}).click();
await expect(dialog).not.toBeVisible({timeout: duration.ten_sec});
}
getRecap(title: string) {
return new RecapItem(
this.page,
this.page
.locator('.recap-item, .recap-processing')
.filter({
has: this.page.getByRole('heading', {name: title, exact: true}),
})
.first(),
);
}
async expectRecapNotVisible(title: string) {
await expect(this.page.getByRole('heading', {name: title, exact: true})).not.toBeVisible();
}
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,594 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import {expect, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
/**
* @objective Verify a user can create a selected-channels AI recap and receive the mocked summary without reloading the page
*/
test('creates selected-channels recap and auto-renders mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
const recapHighlight = `Deterministic highlight ${await pw.random.id()}`;
const recapActionItem = `Deterministic action item ${await pw.random.id()}`;
const recapTitle = `AI recap ${await pw.random.id()}`;
const sourceMessage = `Please summarize this update ${await pw.random.id()}`;
// # Initialize the test server state and configure one deterministic recap agent.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [recapActionItem],
}),
],
});
// # Seed a real unread post so the recap job has content to summarize.
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'AI Recap Selected Channel',
sourceMessage,
);
// # Login as the end user, create the recap through the selected-channels modal flow, and submit it.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectSelectedChannels();
await createRecapModal.clickNext();
await createRecapModal.expectChannelSelectorVisible();
await createRecapModal.searchChannel(channel.display_name);
await createRecapModal.selectChannel(channel.display_name);
await createRecapModal.clickNext();
await createRecapModal.expectSummaryChannels([channel.display_name]);
await createRecapModal.startRecap();
// * Verify the recap first appears in its pending state.
const recap = recapsPage.getRecap(recapTitle);
await recap.toBeVisible();
await recap.expectProcessing();
// * Verify the recap completes and renders the mocked summary content without a manual page reload.
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
await expect(recap.container).toContainText(recapHighlight, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(recapActionItem, {timeout: pw.duration.one_min});
// * Verify the recorded bridge request used the recap_summary operation and included the unread source message.
await waitForRecordedRequestCount(pw, adminClient, 1);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequest = bridgeState.recorded_requests.find((request) => request.operation === 'recap_summary');
expect(recapRequest).toBeDefined();
expect(recapRequest?.agent_id).toBe(agent.id);
expect(recapRequest?.messages.some((message) => message.message.includes(sourceMessage))).toBe(true);
});
/**
* @objective Verify the all-unreads recap flow skips channel selection and only summarizes unread channels
*/
test('creates all-unreads recap for only unread channels', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Unread recap ${await pw.random.id()}`;
const sourceMessageOne = `Unread source one ${await pw.random.id()}`;
const sourceMessageTwo = `Unread source two ${await pw.random.id()}`;
const highlightOne = `Unread highlight one ${await pw.random.id()}`;
const highlightTwo = `Unread highlight two ${await pw.random.id()}`;
// # Initialize the test server state, clear baseline unread state, and configure two deterministic recap completions.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
await markAllCurrentChannelsRead(userClient, team.id);
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [highlightOne],
actionItems: [],
}),
pw.recapCompletion({
highlights: [highlightTwo],
actionItems: [],
}),
],
});
const firstChannel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Unread recap one',
sourceMessageOne,
);
const secondChannel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Unread recap two',
sourceMessageTwo,
);
// # Open the all-unreads modal flow and advance directly to the summary step.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const createRecapModal = await recapsPage.openCreateRecap();
await createRecapModal.fillTitle(recapTitle);
await createRecapModal.selectAgent(agent.displayName);
await createRecapModal.selectAllUnreads();
await createRecapModal.clickNext();
// * Verify the all-unreads flow skips the channel selector and includes only the unread channels in the summary.
await createRecapModal.expectChannelSelectorHidden();
await createRecapModal.expectSummaryChannels([firstChannel.display_name, secondChannel.display_name]);
await createRecapModal.startRecap();
// * Verify the completed recap renders summaries for both unread channels.
const recap = recapsPage.getRecap(recapTitle);
await expect(recap.container).toContainText(highlightOne, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(highlightTwo, {timeout: pw.duration.one_min});
await expect(recap.container).toContainText(firstChannel.display_name);
await expect(recap.container).toContainText(secondChannel.display_name);
// * Verify the bridge recorded one recap_summary request per unread channel and included both source messages.
await waitForRecordedRequestCount(pw, adminClient, 2);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
expect(
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageOne))),
).toBe(true);
expect(
recapRequests.some((request) => request.messages.some((message) => message.message.includes(sourceMessageTwo))),
).toBe(true);
});
/**
* @objective Verify recap creation is disabled when the AI bridge reports itself as unavailable
*/
test('disables recap creation when the bridge is unavailable', {tag: '@ai_recaps'}, async ({pw}) => {
// # Initialize the test server state and configure the recap bridge as unavailable.
const {adminClient, team, user} = await pw.initSetup();
await setupRecapBridge(pw, adminClient, {
available: false,
completions: [],
});
// # Open the recaps page as the end user.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
// * Verify the page shows the caught-up empty state and disables the Add a recap button with the expected reason.
await recapsPage.expectCaughtUpEmptyState();
await recapsPage.expectAddRecapDisabled('Agents Bridge is not enabled');
});
/**
* @objective Verify marking a recap as read moves it from the Unread tab to the Read tab
*/
test('moves a recap from unread to read when marked read', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Read state recap ${await pw.random.id()}`;
const recapHighlight = `Read state highlight ${await pw.random.id()}`;
const sourceMessage = `Read state source ${await pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Read state channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the unread recap and mark it as read from the recap card.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const unreadRecap = recapsPage.getRecap(recapTitle);
await unreadRecap.toBeVisible();
await unreadRecap.clickMarkRead();
// * Verify the recap disappears from the Unread tab and appears in the Read tab with unread-only actions removed.
await recapsPage.expectRecapNotVisible(recapTitle);
await recapsPage.switchToRead();
const readRecap = recapsPage.getRecap(recapTitle);
await readRecap.toBeVisible();
await expect(readRecap.markReadButton).not.toBeVisible();
await expect(readRecap.menuButton).not.toBeVisible();
});
/**
* @objective Verify deleting a recap removes it from the recaps list
*/
test('deletes a recap from the recaps page', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Delete recap ${await pw.random.id()}`;
const recapHighlight = `Delete recap highlight ${await pw.random.id()}`;
const sourceMessage = `Delete recap source ${await pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap for the user.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Delete recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, trigger the delete action, and confirm the delete modal.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.toBeVisible();
await recap.clickDelete();
await recapsPage.confirmDelete();
// * Verify the recap disappears from the list and the page returns to the setup placeholder.
await recapsPage.expectRecapNotVisible(recapTitle);
await recapsPage.expectSetupPlaceholder();
});
/**
* @objective Verify regenerating a recap returns it to processing and replaces the rendered summary with the latest mocked response
*/
test('regenerates a recap with a new mocked summary', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Regenerated recap ${await pw.random.id()}`;
const firstHighlight = `Original summary ${await pw.random.id()}`;
const secondHighlight = `Regenerated summary ${await pw.random.id()}`;
const sourceMessage = `Regenerate source ${await pw.random.id()}`;
// # Initialize the test server state, queue two recap completions, and seed the first completed recap.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [firstHighlight],
actionItems: [],
}),
pw.recapCompletion({
highlights: [secondHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Regenerate recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, confirm the original summary is visible, and trigger regeneration from the recap menu.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
await recap.expectText(firstHighlight);
await recap.openMenuAction('Regenerate this recap');
// * Verify the recap returns to the processing state and then renders the regenerated summary.
await recap.expectProcessing();
await expect(recap.container).toContainText(secondHighlight, {timeout: pw.duration.one_min});
await expect(recap.container).not.toContainText(firstHighlight);
// * Verify two recap_summary requests were recorded for the original generation and the regeneration.
await waitForRecordedRequestCount(pw, adminClient, 2);
const bridgeState = await pw.getAIBridgeMock(adminClient);
const recapRequests = bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary');
expect(recapRequests).toHaveLength(2);
});
/**
* @objective Verify a failed recap renders the failed state and can recover after regeneration
*/
test('recovers a failed recap through regeneration', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Failed recap ${await pw.random.id()}`;
const recoveredHighlight = `Recovered highlight ${await pw.random.id()}`;
const sourceMessage = `Failed recap source ${await pw.random.id()}`;
// # Initialize the test server state, queue a failing completion followed by a successful completion, and seed the failed recap.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
{error: 'deterministic recap failure', status_code: 500},
pw.recapCompletion({
highlights: [recoveredHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Failed recap channel',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'failed');
// # Open the failed recap and trigger regeneration from the recap menu.
const {recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const failedRecap = recapsPage.getRecap(recapTitle);
await failedRecap.toBeVisible();
await failedRecap.expectFailed();
await expect(failedRecap.markReadButton).not.toBeVisible();
await failedRecap.openMenuAction('Regenerate this recap');
// * Verify the failed recap returns to processing and then recovers into a completed recap.
await failedRecap.expectProcessing();
await waitForRecapStatus(pw, userClient, recapTitle, 'completed');
await recapsPage.page.reload();
await recapsPage.toBeVisible();
await failedRecap.expand();
await expect(failedRecap.container).toContainText(recoveredHighlight, {timeout: pw.duration.one_min});
});
/**
* @objective Verify recap channel card actions can mark a source channel as read and navigate back into that channel
*/
test('executes recap channel card actions', {tag: '@ai_recaps'}, async ({pw}) => {
const recapTitle = `Channel actions recap ${await pw.random.id()}`;
const recapHighlight = `Channel action highlight ${await pw.random.id()}`;
const sourceMessage = `Channel action source ${await pw.random.id()}`;
// # Initialize the test server state, configure the recap bridge, and seed a completed recap with one unread channel.
const {adminClient, adminUser, team, user, userClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
const {agent} = await setupRecapBridge(pw, adminClient, {
completions: [
pw.recapCompletion({
highlights: [recapHighlight],
actionItems: [],
}),
],
});
const channel = await createUnreadChannelFixture(
pw,
adminClient,
adminUser.id,
user.id,
team.id,
'Channel action recap',
sourceMessage,
);
await createRecapAndWaitForStatus(pw, userClient, recapTitle, [channel.id], agent.id, 'completed');
// # Open the recap, mark the recap channel as read from the channel card, and then navigate back into the source channel.
const {page, recapsPage} = await pw.testBrowser.login(user);
await recapsPage.goto(team.name);
await recapsPage.toBeVisible();
const recap = recapsPage.getRecap(recapTitle);
await recap.expand();
const recapChannelCard = recap.getChannelCard(channel.display_name);
await recapChannelCard.toBeVisible();
await recapChannelCard.openMenuAction('Mark this channel as read');
// * Verify the recap channel read action clears the unread count for the summarized channel.
await pw.waitUntil(
async () => {
const channelMember = await userClient.getMyChannelMember(channel.id);
return channelMember.mention_count === 0;
},
{timeout: pw.duration.one_min},
);
// # Use the channel card to navigate back into the original channel.
await recapChannelCard.clickChannelName();
// * Verify the user lands back in the source channel route.
await expect(page).toHaveURL(new RegExp(`/${team.name}/channels/${channel.name}$`));
});
async function setupRecapBridge(
pw: PlaywrightExtended,
adminClient: Client4,
{
available = true,
completions,
}: {
available?: boolean;
completions: Array<{completion?: string; error?: string; status_code?: number}>;
},
) {
await pw.enableAIBridgeTestMode(adminClient, {enableRecaps: true});
await pw.resetAIBridgeMock(adminClient);
const {agent, service} = await pw.createMockAIAgent(adminClient, {
agent: {
id: `recap-agent-${await pw.random.id()}`,
displayName: 'Recap Summary Agent',
username: `recap.summary.${await pw.random.id()}`,
is_default: true,
},
service: {
id: `recap-service-${await pw.random.id()}`,
name: 'Recap Summary Service',
type: 'anthropic',
},
});
await pw.configureAIBridgeMock(adminClient, {
status: {available},
agents: [agent],
services: [service],
agent_completions: {
recap_summary: completions,
},
record_requests: true,
});
return {agent, service};
}
async function createUnreadChannelFixture(
pw: PlaywrightExtended,
adminClient: Client4,
adminUserId: string,
userId: string,
teamId: string,
displayName: string,
sourceMessage: string,
) {
const channel = await adminClient.createChannel(
pw.random.channel({
teamId,
name: `recap${await pw.random.id()}`,
displayName,
unique: false,
}),
);
await adminClient.addToChannel(userId, channel.id);
await adminClient.createPost({
channel_id: channel.id,
user_id: adminUserId,
message: sourceMessage,
});
return channel;
}
async function createRecapAndWaitForStatus(
pw: PlaywrightExtended,
userClient: Client4,
recapTitle: string,
channelIds: string[],
agentId: string,
expectedStatus: string,
) {
const recap = await userClient.createRecap({
title: recapTitle,
channel_ids: channelIds,
agent_id: agentId,
});
await pw.waitUntil(
async () => {
const currentRecap = await userClient.getRecap(recap.id);
return currentRecap.status === expectedStatus;
},
{timeout: pw.duration.one_min},
);
return userClient.getRecap(recap.id);
}
async function waitForRecapStatus(
pw: PlaywrightExtended,
userClient: Client4,
recapTitle: string,
expectedStatus: string,
) {
await pw.waitUntil(
async () => {
const recaps = await userClient.getRecaps(0, 60);
return recaps.some((recap) => recap.title === recapTitle && recap.status === expectedStatus);
},
{timeout: pw.duration.one_min},
);
}
async function waitForRecordedRequestCount(pw: PlaywrightExtended, adminClient: Client4, requestCount: number) {
await pw.waitUntil(
async () => {
const bridgeState = await pw.getAIBridgeMock(adminClient);
return (
bridgeState.recorded_requests.filter((request) => request.operation === 'recap_summary').length ===
requestCount
);
},
{timeout: pw.duration.one_min},
);
}
async function markAllCurrentChannelsRead(userClient: Client4, teamId: string) {
const currentChannels = await userClient.getMyChannels(teamId);
await userClient.readMultipleChannels(currentChannels.map((channel: Channel) => channel.id));
}
@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAIBridgeTestHelper() {
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(putAIBridgeTestHelper)).Methods(http.MethodPut)
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(getAIBridgeTestHelper)).Methods(http.MethodGet)
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(deleteAIBridgeTestHelper)).Methods(http.MethodDelete)
}
func requireAIBridgeTestHelperEnabled(c *Context) {
if !*c.App.Config().ServiceSettings.EnableTesting {
c.Err = model.NewAppError("requireAIBridgeTestHelperEnabled", "api.ai_bridge_test_helper.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
}
}
func putAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
var config model.AIBridgeTestHelperConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
if appErr := c.App.SetAIBridgeTestHelperConfig(&config); appErr != nil {
c.Err = appErr
return
}
state := c.App.GetAIBridgeTestHelperState()
if err := json.NewEncoder(w).Encode(state); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
if err := json.NewEncoder(w).Encode(c.App.GetAIBridgeTestHelperState()); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func deleteAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
requireAIBridgeTestHelperEnabled(c)
if c.Err != nil {
return
}
c.App.ResetAIBridgeTestHelper()
ReturnStatusOK(w)
}
@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/mattermost/mattermost/server/public/model"
app "github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAIBridgeTestHelperAdminPUTGETDELETE(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
recordRequests := true
agentID := model.NewId()
serviceID := model.NewId()
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{
Available: true,
},
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: model.NewPointer(true),
EnableAIRecaps: model.NewPointer(true),
},
Agents: []model.BridgeAgentInfo{{
ID: agentID,
DisplayName: "Claude",
Username: "claude.bot",
ServiceID: serviceID,
ServiceType: "anthropic",
IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: serviceID,
Name: "Anthropic",
Type: "anthropic",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(app.BridgeOperationRewrite): {{
Completion: `{"rewritten_text":"Rewritten text"}`,
}},
},
RecordRequests: &recordRequests,
}
state, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), config)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.True(t, state.RecordRequests)
require.Len(t, state.Agents, 1)
assert.Equal(t, agentID, state.Agents[0].ID)
require.NotNil(t, state.FeatureFlags)
require.NotNil(t, state.FeatureFlags.EnableAIPluginBridge)
require.NotNil(t, state.FeatureFlags.EnableAIRecaps)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.True(t, state.Status.Available)
require.Len(t, state.Services, 1)
assert.Equal(t, serviceID, state.Services[0].ID)
require.NotNil(t, state.FeatureFlags)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
resp, err = th.SystemAdminClient.DeleteAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, state)
assert.Nil(t, state.Status)
assert.Empty(t, state.Agents)
assert.Empty(t, state.Services)
assert.Empty(t, state.AgentCompletions)
assert.Empty(t, state.RecordedRequests)
assert.False(t, state.RecordRequests)
require.NotNil(t, state.FeatureFlags)
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
}
func TestAIBridgeTestHelperRejectsNonAdmin(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
_, resp, err := th.Client.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
func TestAIBridgeTestHelperDisabledWithoutEnableTesting(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestAIBridgeTestHelperMocksRealEndpoints(t *testing.T) {
mainHelper.Parallel(t)
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.ServiceSettings.EnableTesting = true
}).InitBasic(t)
recordRequests := true
agentID := model.NewId()
serviceID := model.NewId()
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{
Available: true,
},
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: model.NewPointer(true),
EnableAIRecaps: model.NewPointer(true),
},
Agents: []model.BridgeAgentInfo{{
ID: agentID,
DisplayName: "Claude",
Username: "claude.bot",
ServiceID: serviceID,
ServiceType: "anthropic",
IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: serviceID,
Name: "Anthropic",
Type: "anthropic",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(app.BridgeOperationRewrite): {{
Completion: `{"rewritten_text":"Polished text"}`,
}},
},
RecordRequests: &recordRequests,
})
require.NoError(t, err)
CheckOKStatus(t, resp)
assert.True(t, th.App.Config().FeatureFlags.EnableAIPluginBridge)
assert.True(t, th.App.Config().FeatureFlags.EnableAIRecaps)
statusResp, httpResp, err := getAPIResponse[model.AgentsIntegrityResponse](t, th.Client, "/agents/status")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
assert.True(t, statusResp.Available)
agentsResp, httpResp, err := getAPIResponse[[]model.BridgeAgentInfo](t, th.Client, "/agents")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
require.Len(t, agentsResp, 1)
assert.Equal(t, agentID, agentsResp[0].ID)
servicesResp, httpResp, err := getAPIResponse[[]model.BridgeServiceInfo](t, th.Client, "/llmservices")
require.NoError(t, err)
CheckOKStatus(t, httpResp)
require.Len(t, servicesResp, 1)
assert.Equal(t, serviceID, servicesResp[0].ID)
rewriteReq := &model.RewriteRequest{
AgentID: agentID,
Message: "the status update",
Action: model.RewriteActionFixSpelling,
}
rewriteHTTPResp, err := th.Client.DoAPIPostJSON(context.Background(), "/posts/rewrite", rewriteReq)
require.NoError(t, err)
defer rewriteHTTPResp.Body.Close()
rewriteResp, rewriteModelResp, err := model.DecodeJSONFromResponse[model.RewriteResponse](rewriteHTTPResp)
require.NoError(t, err)
CheckOKStatus(t, rewriteModelResp)
assert.Equal(t, "Polished text", rewriteResp.RewrittenText)
state, helperResp, err := th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
require.NoError(t, err)
CheckOKStatus(t, helperResp)
require.Len(t, state.RecordedRequests, 1)
assert.Equal(t, string(app.BridgeOperationRewrite), state.RecordedRequests[0].Operation)
assert.Equal(t, agentID, state.RecordedRequests[0].AgentID)
}
func getAPIResponse[T any](tb testing.TB, client *model.Client4, route string) (T, *model.Response, error) {
tb.Helper()
httpResp, err := client.DoAPIGet(context.Background(), route, "")
if err != nil {
var zero T
return zero, model.BuildResponse(httpResp), err
}
defer httpResp.Body.Close()
return model.DecodeJSONFromResponse[T](httpResp)
}
+1
View File
@@ -344,6 +344,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitFile()
api.InitUpload()
api.InitSystem()
api.InitAIBridgeTestHelper()
api.InitLicense()
api.InitConfig()
api.InitWebhook()
+14 -73
View File
@@ -6,91 +6,39 @@ package app
import (
"net/http"
"github.com/Masterminds/semver/v3"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
aiPluginID = "mattermost-ai"
minAIPluginVersionForBridge = "1.5.0"
)
// GetBridgeClient returns a bridge client for making requests to the plugin bridge API
// GetBridgeClient remains as a compatibility helper for downstream enterprise code.
// New server code should use a.ch.agentsBridge instead of relying on the concrete bridge client.
func (a *App) GetBridgeClient(userID string) *agentclient.Client {
return agentclient.NewClientFromApp(a, userID)
}
// ServiceCompletion remains as a compatibility helper for downstream enterprise
// code that needs service-based bridge completions while using the shared
// AgentsBridge abstraction.
func (a *App) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
return a.ch.agentsBridge.ServiceCompletion(sessionUserID, serviceID, req)
}
// GetAIPluginBridgeStatus checks if the mattermost-ai plugin is active and supports the bridge API (v1.5.0+)
// It returns a boolean indicating availability, and a reason string (translation ID) if unavailable.
func (a *App) GetAIPluginBridgeStatus(rctx request.CTX) (bool, string) {
pluginsEnvironment := a.GetPluginsEnvironment()
if pluginsEnvironment == nil {
rctx.Logger().Debug("AI plugin bridge not available - plugin environment not initialized")
return false, "app.agents.bridge.not_available.plugin_env_not_initialized"
}
// Check if plugin is active
if !pluginsEnvironment.IsActive(aiPluginID) {
rctx.Logger().Debug("AI plugin bridge not available - plugin is not active or not installed",
mlog.String("plugin_id", aiPluginID),
)
return false, "app.agents.bridge.not_available.plugin_not_active"
}
// Get the plugin's manifest to check version
plugins := pluginsEnvironment.Active()
for _, plugin := range plugins {
if plugin.Manifest != nil && plugin.Manifest.Id == aiPluginID {
pluginVersion, err := semver.StrictNewVersion(plugin.Manifest.Version)
if err != nil {
rctx.Logger().Debug("AI plugin bridge not available - failed to parse plugin version",
mlog.String("plugin_id", aiPluginID),
mlog.String("version", plugin.Manifest.Version),
mlog.Err(err),
)
return false, "app.agents.bridge.not_available.plugin_version_parse_failed"
}
minVersion, err := semver.StrictNewVersion(minAIPluginVersionForBridge)
if err != nil {
return false, "app.agents.bridge.not_available.min_version_parse_failed"
}
if pluginVersion.LessThan(minVersion) {
rctx.Logger().Debug("AI plugin bridge not available - plugin version is too old",
mlog.String("plugin_id", aiPluginID),
mlog.String("current_version", plugin.Manifest.Version),
mlog.String("minimum_version", minAIPluginVersionForBridge),
)
return false, "app.agents.bridge.not_available.plugin_version_too_old"
}
return true, ""
}
}
return false, "app.agents.bridge.not_available.plugin_not_registered"
return a.ch.agentsBridge.Status(rctx)
}
// GetAgents retrieves all available agents from the bridge API
func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAgentInfo, *model.AppError) {
// Check if the AI plugin is active and supports the bridge API (v1.5.0+)
if available, _ := a.GetAIPluginBridgeStatus(rctx); !available {
return []agentclient.BridgeAgentInfo{}, nil
}
// Create bridge client
func (a *App) GetAgents(rctx request.CTX, userID string) ([]model.BridgeAgentInfo, *model.AppError) {
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.GetBridgeClient(sessionUserID)
agents, err := client.GetAgents(userID)
agents, err := a.ch.agentsBridge.GetAgents(sessionUserID, userID)
if err != nil {
rctx.Logger().Error("Failed to get agents from bridge",
mlog.Err(err),
@@ -132,20 +80,13 @@ func (a *App) GetUsersForAgents(rctx request.CTX, userID string) ([]*model.User,
}
// GetLLMServices retrieves all available LLM services from the bridge API
func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.BridgeServiceInfo, *model.AppError) {
// Check if the AI plugin is active and supports the bridge API (v1.5.0+)
if available, _ := a.GetAIPluginBridgeStatus(rctx); !available {
return []agentclient.BridgeServiceInfo{}, nil
}
// Create bridge client
func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]model.BridgeServiceInfo, *model.AppError) {
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.GetBridgeClient(sessionUserID)
services, err := client.GetServices(userID)
services, err := a.ch.agentsBridge.GetServices(sessionUserID, userID)
if err != nil {
rctx.Logger().Error("Failed to get LLM services from bridge",
mlog.Err(err),
+247
View File
@@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"github.com/blang/semver/v4"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
aiPluginID = "mattermost-ai"
minAIPluginVersionForBridge = "1.5.0"
)
type BridgeOperation string
const (
BridgeOperationAutoTranslate BridgeOperation = "auto_translate"
BridgeOperationRecapSummary BridgeOperation = "recap_summary"
BridgeOperationRewrite BridgeOperation = "rewrite"
)
type BridgeMessage struct {
Role string
Message string
FileIDs []string
}
type BridgeCompletionRequest struct {
Operation BridgeOperation
ClientOperation string
OperationSubType string
Messages []BridgeMessage
JSONOutputFormat map[string]any
UserID string
ChannelID string
}
type AgentsBridge interface {
Status(rctx request.CTX) (bool, string)
GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error)
GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error)
AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error)
ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error)
}
type liveAgentsBridge struct {
ch *Channels
}
func newLiveAgentsBridge(ch *Channels) AgentsBridge {
return &liveAgentsBridge{ch: ch}
}
func (b *liveAgentsBridge) Status(rctx request.CTX) (bool, string) {
return b.getLiveStatus(rctx)
}
func (b *liveAgentsBridge) GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error) {
if available, _ := b.getLiveStatus(request.EmptyContext(b.ch.srv.Log())); !available {
return []model.BridgeAgentInfo{}, nil
}
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
agents, err := client.GetAgents(userID)
if err != nil {
return nil, err
}
return toModelBridgeAgents(agents), nil
}
func (b *liveAgentsBridge) GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error) {
if available, _ := b.getLiveStatus(request.EmptyContext(b.ch.srv.Log())); !available {
return []model.BridgeServiceInfo{}, nil
}
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
services, err := client.GetServices(userID)
if err != nil {
return nil, err
}
return toModelBridgeServices(services), nil
}
func (b *liveAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
return client.AgentCompletion(agentID, toClientCompletionRequest(req))
}
func (b *liveAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
client := agentclient.NewClientFromApp(New(ServerConnector(b.ch)), sessionUserID)
return client.ServiceCompletion(serviceID, toClientCompletionRequest(req))
}
func (b *liveAgentsBridge) getLiveStatus(rctx request.CTX) (bool, string) {
pluginsEnvironment := b.ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
rctx.Logger().Debug("AI plugin bridge not available - plugin environment not initialized")
return false, "app.agents.bridge.not_available.plugin_env_not_initialized"
}
if !pluginsEnvironment.IsActive(aiPluginID) {
rctx.Logger().Debug("AI plugin bridge not available - plugin is not active or not installed",
mlog.String("plugin_id", aiPluginID),
)
return false, "app.agents.bridge.not_available.plugin_not_active"
}
plugins := pluginsEnvironment.Active()
for _, plugin := range plugins {
if plugin.Manifest == nil || plugin.Manifest.Id != aiPluginID {
continue
}
pluginVersion, err := semver.Parse(plugin.Manifest.Version)
if err != nil {
rctx.Logger().Debug("AI plugin bridge not available - failed to parse plugin version",
mlog.String("plugin_id", aiPluginID),
mlog.String("version", plugin.Manifest.Version),
mlog.Err(err),
)
return false, "app.agents.bridge.not_available.plugin_version_parse_failed"
}
minVersion, err := semver.Parse(minAIPluginVersionForBridge)
if err != nil {
return false, "app.agents.bridge.not_available.min_version_parse_failed"
}
if pluginVersion.LT(minVersion) {
rctx.Logger().Debug("AI plugin bridge not available - plugin version is too old",
mlog.String("plugin_id", aiPluginID),
mlog.String("current_version", plugin.Manifest.Version),
mlog.String("minimum_version", minAIPluginVersionForBridge),
)
return false, "app.agents.bridge.not_available.plugin_version_too_old"
}
return true, ""
}
return false, "app.agents.bridge.not_available.plugin_not_registered"
}
func toModelBridgeAgents(agents []agentclient.BridgeAgentInfo) []model.BridgeAgentInfo {
if len(agents) == 0 {
return []model.BridgeAgentInfo{}
}
converted := make([]model.BridgeAgentInfo, 0, len(agents))
for _, agent := range agents {
converted = append(converted, model.BridgeAgentInfo{
ID: agent.ID,
DisplayName: agent.DisplayName,
Username: agent.Username,
ServiceID: agent.ServiceID,
ServiceType: agent.ServiceType,
IsDefault: agent.IsDefault,
})
}
return converted
}
func toModelBridgeServices(services []agentclient.BridgeServiceInfo) []model.BridgeServiceInfo {
if len(services) == 0 {
return []model.BridgeServiceInfo{}
}
converted := make([]model.BridgeServiceInfo, 0, len(services))
for _, service := range services {
converted = append(converted, model.BridgeServiceInfo{
ID: service.ID,
Name: service.Name,
Type: service.Type,
})
}
return converted
}
func toBridgeClientPosts(messages []BridgeMessage) []agentclient.Post {
posts := make([]agentclient.Post, 0, len(messages))
for _, message := range messages {
posts = append(posts, agentclient.Post{
Role: message.Role,
Message: message.Message,
FileIDs: append([]string(nil), message.FileIDs...),
})
}
return posts
}
func toClientCompletionRequest(req BridgeCompletionRequest) agentclient.CompletionRequest {
return agentclient.CompletionRequest{
Posts: toBridgeClientPosts(req.Messages),
JSONOutputFormat: cloneJSONOutputFormat(req.JSONOutputFormat),
UserID: req.UserID,
ChannelID: req.ChannelID,
Operation: req.ClientOperation,
OperationSubType: req.OperationSubType,
}
}
func cloneJSONOutputFormat(jsonOutputFormat map[string]any) map[string]any {
if jsonOutputFormat == nil {
return nil
}
cloned := make(map[string]any, len(jsonOutputFormat))
for key, value := range jsonOutputFormat {
cloned[key] = cloneJSONValue(value)
}
return cloned
}
func cloneJSONValue(value any) any {
switch v := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(v))
for key, child := range v {
cloned[key] = cloneJSONValue(child)
}
return cloned
case []any:
cloned := make([]any, len(v))
for i, child := range v {
cloned[i] = cloneJSONValue(child)
}
return cloned
case []string:
return append([]string(nil), v...)
default:
return v
}
}
var _ AgentsBridge = (*liveAgentsBridge)(nil)
+186
View File
@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type bridgeCompleteCall struct {
sessionUserID string
agentID string
serviceID string
request BridgeCompletionRequest
}
type testAgentsBridge struct {
statusAvailable bool
statusReason string
statusFn func(rctx request.CTX) (bool, string)
getAgentsFn func(sessionUserID, userID string) ([]model.BridgeAgentInfo, error)
getServicesFn func(sessionUserID, userID string) ([]model.BridgeServiceInfo, error)
completeFn func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error)
completeSvcFn func(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error)
completeCalls []bridgeCompleteCall
}
func (b *testAgentsBridge) Status(rctx request.CTX) (bool, string) {
if b.statusFn != nil {
return b.statusFn(rctx)
}
return b.statusAvailable, b.statusReason
}
func (b *testAgentsBridge) GetAgents(sessionUserID, userID string) ([]model.BridgeAgentInfo, error) {
if b.getAgentsFn != nil {
return b.getAgentsFn(sessionUserID, userID)
}
return []model.BridgeAgentInfo{}, nil
}
func (b *testAgentsBridge) GetServices(sessionUserID, userID string) ([]model.BridgeServiceInfo, error) {
if b.getServicesFn != nil {
return b.getServicesFn(sessionUserID, userID)
}
return []model.BridgeServiceInfo{}, nil
}
func (b *testAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
b.completeCalls = append(b.completeCalls, bridgeCompleteCall{
sessionUserID: sessionUserID,
agentID: agentID,
request: req,
})
if b.completeFn != nil {
return b.completeFn(sessionUserID, agentID, req)
}
return "", nil
}
func (b *testAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
b.completeCalls = append(b.completeCalls, bridgeCompleteCall{
sessionUserID: sessionUserID,
serviceID: serviceID,
request: req,
})
if b.completeSvcFn != nil {
return b.completeSvcFn(sessionUserID, serviceID, req)
}
return "", nil
}
func TestServiceCompletionUsesAgentsBridge(t *testing.T) {
bridge := &testAgentsBridge{
completeSvcFn: func(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
return "translated", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
completion, err := th.App.ServiceCompletion("", "service-id", BridgeCompletionRequest{
Operation: BridgeOperationAutoTranslate,
ClientOperation: string(BridgeOperationAutoTranslate),
OperationSubType: "translate",
Messages: []BridgeMessage{
{Role: "user", Message: "hola"},
},
})
require.NoError(t, err)
assert.Equal(t, "translated", completion)
require.Len(t, bridge.completeCalls, 1)
assert.Equal(t, "service-id", bridge.completeCalls[0].serviceID)
assert.Equal(t, BridgeOperationAutoTranslate, bridge.completeCalls[0].request.Operation)
assert.Equal(t, "translate", bridge.completeCalls[0].request.OperationSubType)
}
func TestSetAIBridgeTestHelperSwapsBridge(t *testing.T) {
th := Setup(t).InitBasic(t)
recordRequests := true
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
Agents: []model.BridgeAgentInfo{{
ID: "agent-1", DisplayName: "Test Agent", Username: "test.agent",
ServiceID: "svc-1", ServiceType: "openai", IsDefault: true,
}},
Services: []model.BridgeServiceInfo{{
ID: "svc-1", Name: "Test Service", Type: "openai",
}},
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
string(BridgeOperationRewrite): {{Completion: "rewritten"}},
},
RecordRequests: &recordRequests,
}
appErr := th.App.SetAIBridgeTestHelperConfig(config)
require.Nil(t, appErr)
_, ok := th.App.Channels().agentsBridge.(*e2eAgentsBridge)
assert.True(t, ok, "bridge should be e2eAgentsBridge after SetAIBridgeTestHelperConfig")
rctx := request.EmptyContext(th.App.Srv().Log())
available, _ := th.App.Channels().agentsBridge.Status(rctx)
assert.True(t, available)
agents, err := th.App.Channels().agentsBridge.GetAgents("", "")
require.NoError(t, err)
require.Len(t, agents, 1)
assert.Equal(t, "agent-1", agents[0].ID)
services, err := th.App.Channels().agentsBridge.GetServices("", "")
require.NoError(t, err)
require.Len(t, services, 1)
assert.Equal(t, "svc-1", services[0].ID)
completion, err := th.App.Channels().agentsBridge.AgentCompletion("", "agent-1", BridgeCompletionRequest{
Operation: BridgeOperationRewrite,
})
require.NoError(t, err)
assert.Equal(t, "rewritten", completion)
state := th.App.GetAIBridgeTestHelperState()
require.Len(t, state.RecordedRequests, 1)
assert.Equal(t, string(BridgeOperationRewrite), state.RecordedRequests[0].Operation)
assert.Equal(t, "agent-1", state.RecordedRequests[0].AgentID)
}
func TestResetAIBridgeTestHelperRestoresLiveBridge(t *testing.T) {
th := Setup(t).InitBasic(t)
config := &model.AIBridgeTestHelperConfig{
Status: &model.AIBridgeTestHelperStatus{Available: true},
}
appErr := th.App.SetAIBridgeTestHelperConfig(config)
require.Nil(t, appErr)
_, ok := th.App.Channels().agentsBridge.(*e2eAgentsBridge)
require.True(t, ok)
th.App.ResetAIBridgeTestHelper()
_, ok = th.App.Channels().agentsBridge.(*e2eAgentsBridge)
assert.False(t, ok, "bridge should be restored to live after reset")
state := th.App.GetAIBridgeTestHelperState()
assert.Nil(t, state.Status)
assert.Empty(t, state.Agents)
assert.Empty(t, state.RecordedRequests)
}
var _ AgentsBridge = (*testAgentsBridge)(nil)
@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (a *App) SetAIBridgeTestHelperConfig(config *model.AIBridgeTestHelperConfig) *model.AppError {
bridge, err := newE2EAgentsBridge(config)
if err != nil {
return model.NewAppError("SetAIBridgeTestHelperConfig", "app.ai_bridge_test_helper.invalid_config", nil, err.Error(), http.StatusBadRequest)
}
a.ch.SetAgentsBridge(bridge)
if config.FeatureFlags != nil {
if appErr := a.setAIBridgeTestHelperFeatureFlags(config.FeatureFlags); appErr != nil {
return appErr
}
}
return nil
}
func (a *App) GetAIBridgeTestHelperState() *model.AIBridgeTestHelperState {
var state *model.AIBridgeTestHelperState
if e2e, ok := a.ch.agentsBridge.(*e2eAgentsBridge); ok {
state = e2e.GetState()
} else {
state = &model.AIBridgeTestHelperState{
RecordedRequests: []model.AIBridgeTestHelperRecordedRequest{},
}
}
state.FeatureFlags = &model.AIBridgeTestHelperFeatureFlags{
EnableAIPluginBridge: model.NewPointer(a.Config().FeatureFlags.EnableAIPluginBridge),
EnableAIRecaps: model.NewPointer(a.Config().FeatureFlags.EnableAIRecaps),
}
return state
}
func (a *App) ResetAIBridgeTestHelper() {
a.ch.SetAgentsBridge(newLiveAgentsBridge(a.ch))
}
func (a *App) setAIBridgeTestHelperFeatureFlags(featureFlags *model.AIBridgeTestHelperFeatureFlags) *model.AppError {
configStore := a.Srv().Platform().GetConfigStore()
configStore.SetReadOnlyFF(false)
defer configStore.SetReadOnlyFF(true)
a.UpdateConfig(func(cfg *model.Config) {
if featureFlags.EnableAIPluginBridge != nil {
cfg.FeatureFlags.EnableAIPluginBridge = *featureFlags.EnableAIPluginBridge
}
if featureFlags.EnableAIRecaps != nil {
cfg.FeatureFlags.EnableAIRecaps = *featureFlags.EnableAIRecaps
}
})
return nil
}
+12
View File
@@ -51,6 +51,8 @@ type Channels struct {
imageProxy *imageproxy.ImageProxy
agentsBridge AgentsBridge
// cached counts that are used during notice condition validation
cachedPostCount int64
cachedUserCount int64
@@ -101,6 +103,12 @@ func NewChannels(s *Server) (*Channels, error) {
interruptQuitChan: make(chan struct{}),
}
if s.agentsBridgeOverride != nil {
ch.agentsBridge = s.agentsBridgeOverride
} else {
ch.agentsBridge = newLiveAgentsBridge(ch)
}
// We are passing a partially filled Channels struct so that the enterprise
// methods can have access to app methods.
// Otherwise, passing server would mean it has to call s.Channels(),
@@ -221,6 +229,10 @@ func NewChannels(s *Server) (*Channels, error) {
return ch, nil
}
func (ch *Channels) SetAgentsBridge(bridge AgentsBridge) {
ch.agentsBridge = bridge
}
func (ch *Channels) Start() error {
// Start plugins
ctx := request.EmptyContext(ch.srv.Log())
+285
View File
@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"fmt"
"maps"
"net/http"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
type e2eAgentsBridge struct {
mut sync.Mutex
status *model.AIBridgeTestHelperStatus
agents *[]model.BridgeAgentInfo
services *[]model.BridgeServiceInfo
agentCompletions map[string][]model.AIBridgeTestHelperCompletion
recordRequests bool
recordedRequests []model.AIBridgeTestHelperRecordedRequest
}
func newE2EAgentsBridge(config *model.AIBridgeTestHelperConfig) (*e2eAgentsBridge, error) {
b := &e2eAgentsBridge{
agentCompletions: make(map[string][]model.AIBridgeTestHelperCompletion),
recordedRequests: []model.AIBridgeTestHelperRecordedRequest{},
}
if err := b.setConfig(config); err != nil {
return nil, err
}
return b, nil
}
func (b *e2eAgentsBridge) setConfig(config *model.AIBridgeTestHelperConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
normalizedCompletions := make(map[string][]model.AIBridgeTestHelperCompletion, len(config.AgentCompletions))
for operation, completions := range config.AgentCompletions {
if operation == "" {
return fmt.Errorf("agent completion operation key cannot be empty")
}
normalizedCompletions[operation] = make([]model.AIBridgeTestHelperCompletion, 0, len(completions))
for idx, completion := range completions {
hasCompletion := completion.Completion != ""
hasError := completion.Error != ""
if hasCompletion == hasError {
return fmt.Errorf("agent completion %q at index %d must set exactly one of completion or error", operation, idx)
}
if hasError && completion.StatusCode == 0 {
completion.StatusCode = http.StatusInternalServerError
}
normalizedCompletions[operation] = append(normalizedCompletions[operation], completion)
}
}
for idx, agent := range config.Agents {
if agent.ID == "" {
return fmt.Errorf("agent at index %d is missing id", idx)
}
if agent.Username == "" {
return fmt.Errorf("agent %q is missing username", agent.ID)
}
}
for idx, service := range config.Services {
if service.ID == "" {
return fmt.Errorf("service at index %d is missing id", idx)
}
if service.Name == "" {
return fmt.Errorf("service %q is missing name", service.ID)
}
}
b.mut.Lock()
defer b.mut.Unlock()
b.status = cloneAIBridgeStatus(config.Status)
b.agents = cloneBridgeAgentsPtr(config.Agents)
b.services = cloneBridgeServicesPtr(config.Services)
b.agentCompletions = normalizedCompletions
b.recordRequests = config.RecordRequests != nil && *config.RecordRequests
b.recordedRequests = []model.AIBridgeTestHelperRecordedRequest{}
return nil
}
func (b *e2eAgentsBridge) GetState() *model.AIBridgeTestHelperState {
b.mut.Lock()
defer b.mut.Unlock()
return &model.AIBridgeTestHelperState{
Status: cloneAIBridgeStatus(b.status),
Agents: cloneBridgeAgentsValue(b.agents),
Services: cloneBridgeServicesValue(b.services),
AgentCompletions: cloneAIBridgeCompletions(b.agentCompletions),
RecordRequests: b.recordRequests,
RecordedRequests: cloneRecordedRequests(b.recordedRequests),
}
}
func (b *e2eAgentsBridge) Status(_ request.CTX) (bool, string) {
b.mut.Lock()
defer b.mut.Unlock()
if b.status == nil {
return true, ""
}
return b.status.Available, b.status.Reason
}
func (b *e2eAgentsBridge) GetAgents(_, _ string) ([]model.BridgeAgentInfo, error) {
b.mut.Lock()
defer b.mut.Unlock()
if b.agents == nil {
return []model.BridgeAgentInfo{}, nil
}
return append([]model.BridgeAgentInfo(nil), (*b.agents)...), nil
}
func (b *e2eAgentsBridge) GetServices(_, _ string) ([]model.BridgeServiceInfo, error) {
b.mut.Lock()
defer b.mut.Unlock()
if b.services == nil {
return []model.BridgeServiceInfo{}, nil
}
return append([]model.BridgeServiceInfo(nil), (*b.services)...), nil
}
func (b *e2eAgentsBridge) AgentCompletion(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
b.mut.Lock()
defer b.mut.Unlock()
b.recordRequestLocked(sessionUserID, agentID, "", req)
return b.getCompletionLocked(string(req.Operation))
}
func (b *e2eAgentsBridge) ServiceCompletion(sessionUserID, serviceID string, req BridgeCompletionRequest) (string, error) {
b.mut.Lock()
defer b.mut.Unlock()
b.recordRequestLocked(sessionUserID, "", serviceID, req)
return b.getCompletionLocked(string(req.Operation))
}
func (b *e2eAgentsBridge) getCompletionLocked(operation string) (string, error) {
completions, ok := b.agentCompletions[operation]
if !ok || len(completions) == 0 {
return "", nil
}
completion := completions[0]
b.agentCompletions[operation] = completions[1:]
if completion.Error != "" {
if completion.StatusCode > 0 {
return "", fmt.Errorf("request failed with status %d: %s", completion.StatusCode, completion.Error)
}
return "", fmt.Errorf("%s", completion.Error)
}
return completion.Completion, nil
}
func (b *e2eAgentsBridge) recordRequestLocked(sessionUserID, agentID, serviceID string, req BridgeCompletionRequest) {
if !b.recordRequests {
return
}
recorded := model.AIBridgeTestHelperRecordedRequest{
Operation: string(req.Operation),
ClientOperation: req.ClientOperation,
OperationSubType: req.OperationSubType,
SessionUserID: sessionUserID,
UserID: req.UserID,
ChannelID: req.ChannelID,
AgentID: agentID,
ServiceID: serviceID,
Messages: toE2ERecordedMessages(req.Messages),
JSONOutputFormat: cloneJSONOutputFormat(req.JSONOutputFormat),
}
b.recordedRequests = append(b.recordedRequests, recorded)
}
func toE2ERecordedMessages(messages []BridgeMessage) []model.AIBridgeTestHelperMessage {
recorded := make([]model.AIBridgeTestHelperMessage, 0, len(messages))
for _, message := range messages {
recorded = append(recorded, model.AIBridgeTestHelperMessage{
Role: message.Role,
Message: message.Message,
FileIDs: append([]string(nil), message.FileIDs...),
})
}
return recorded
}
func cloneAIBridgeStatus(status *model.AIBridgeTestHelperStatus) *model.AIBridgeTestHelperStatus {
if status == nil {
return nil
}
cloned := *status
return &cloned
}
func cloneBridgeAgentsPtr(agents []model.BridgeAgentInfo) *[]model.BridgeAgentInfo {
if agents == nil {
return nil
}
cloned := append([]model.BridgeAgentInfo(nil), agents...)
return &cloned
}
func cloneBridgeServicesPtr(services []model.BridgeServiceInfo) *[]model.BridgeServiceInfo {
if services == nil {
return nil
}
cloned := append([]model.BridgeServiceInfo(nil), services...)
return &cloned
}
func cloneBridgeAgentsValue(agents *[]model.BridgeAgentInfo) []model.BridgeAgentInfo {
if agents == nil {
return nil
}
return append([]model.BridgeAgentInfo(nil), (*agents)...)
}
func cloneBridgeServicesValue(services *[]model.BridgeServiceInfo) []model.BridgeServiceInfo {
if services == nil {
return nil
}
return append([]model.BridgeServiceInfo(nil), (*services)...)
}
func cloneAIBridgeCompletions(agentCompletions map[string][]model.AIBridgeTestHelperCompletion) map[string][]model.AIBridgeTestHelperCompletion {
if agentCompletions == nil {
return nil
}
cloned := make(map[string][]model.AIBridgeTestHelperCompletion, len(agentCompletions))
for key, value := range agentCompletions {
cloned[key] = append([]model.AIBridgeTestHelperCompletion(nil), value...)
}
return cloned
}
func cloneRecordedRequests(recordedRequests []model.AIBridgeTestHelperRecordedRequest) []model.AIBridgeTestHelperRecordedRequest {
if recordedRequests == nil {
return nil
}
cloned := make([]model.AIBridgeTestHelperRecordedRequest, 0, len(recordedRequests))
for _, req := range recordedRequests {
clonedReq := req
clonedReq.Messages = append([]model.AIBridgeTestHelperMessage(nil), req.Messages...)
if req.JSONOutputFormat != nil {
clonedReq.JSONOutputFormat = make(map[string]any, len(req.JSONOutputFormat))
maps.Copy(clonedReq.JSONOutputFormat, req.JSONOutputFormat)
}
cloned = append(cloned, clonedReq)
}
return cloned
}
var _ AgentsBridge = (*e2eAgentsBridge)(nil)
+7
View File
@@ -118,6 +118,13 @@ func SkipPostInitialization() Option {
}
}
func WithAgentsBridge(bridge AgentsBridge) Option {
return func(s *Server) error {
s.agentsBridgeOverride = bridge
return nil
}
}
type (
AppOption func(a *App)
AppOptionCreator func() []AppOption
+11 -8
View File
@@ -18,7 +18,6 @@ import (
"maps"
"slices"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/i18n"
@@ -3254,19 +3253,23 @@ func (a *App) RewriteMessage(
systemPrompt := buildRewriteSystemPrompt(userLocale)
// Prepare completion request in the format expected by the client
client := a.GetBridgeClient(rctx.Session().UserId)
completionRequest := agentclient.CompletionRequest{
Posts: []agentclient.Post{
sessionUserID := ""
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
completionRequest := BridgeCompletionRequest{
Operation: BridgeOperationRewrite,
ClientOperation: "message_rewrite",
Messages: []BridgeMessage{
{Role: "system", Message: systemPrompt},
{Role: "user", Message: userPrompt},
},
UserID: rctx.Session().UserId,
Operation: "message_rewrite",
OperationSubType: normalizeRewriteAction(action),
UserID: sessionUserID,
}
completion, err := client.AgentCompletion(agentID, completionRequest)
completion, err := a.ch.agentsBridge.AgentCompletion(sessionUserID, agentID, completionRequest)
if err != nil {
return nil, model.NewAppError("RewriteMessage", "app.post.rewrite.agent_call_failed", nil, err.Error(), 500)
}
+29 -14
View File
@@ -206,8 +206,13 @@ func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID,
return result, postsErr
}
sourcePostIDs := extractPostIDs(posts)
// No posts to summarize - return success with 0 messages
if len(posts) == 0 {
if appErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, sourcePostIDs); appErr != nil {
return result, appErr
}
result.Success = true
return result, nil
}
@@ -221,23 +226,14 @@ func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID,
// Summarize posts
summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID)
if err != nil {
if saveErr := a.saveRecapChannelRecord(recapID, channel.Id, channel.DisplayName, nil, nil, sourcePostIDs); saveErr != nil {
return result, saveErr
}
return result, err
}
// Save recap channel
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapID,
ChannelId: channelID,
ChannelName: channel.DisplayName,
Highlights: summary.Highlights,
ActionItems: summary.ActionItems,
SourcePostIds: extractPostIDs(posts),
CreateAt: model.GetMillis(),
}
if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
if appErr := a.saveRecapChannelRecord(recapID, channelID, channel.DisplayName, summary.Highlights, summary.ActionItems, sourcePostIDs); appErr != nil {
return result, appErr
}
result.MessageCount = len(posts)
@@ -245,6 +241,25 @@ func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID,
return result, nil
}
func (a *App) saveRecapChannelRecord(recapID, channelID, channelName string, highlights, actionItems, sourcePostIDs []string) *model.AppError {
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recapID,
ChannelId: channelID,
ChannelName: channelName,
Highlights: highlights,
ActionItems: actionItems,
SourcePostIds: sourcePostIDs,
CreateAt: model.GetMillis(),
}
if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil {
return model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information
func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) {
// Get posts after lastViewedAt
+88 -6
View File
@@ -249,9 +249,9 @@ func TestProcessRecapChannel(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("process empty channel", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Ensure channel has no posts (it shouldn't in init)
channel := th.CreateChannel(t, th.BasicTeam)
// No posts added
@@ -259,28 +259,110 @@ func TestProcessRecapChannel(t *testing.T) {
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recapID := model.NewId()
agentID := "test-agent"
_, storeErr := th.App.Srv().Store().Recap().SaveRecap(&model.Recap{
Id: recapID,
UserId: th.BasicUser.Id,
Title: "Empty recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusProcessing,
BotID: agentID,
})
require.NoError(t, storeErr)
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
require.Nil(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Equal(t, 0, result.MessageCount)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
require.Len(t, recapChannels, 1)
assert.Equal(t, channel.Id, recapChannels[0].ChannelId)
assert.Empty(t, recapChannels[0].Highlights)
assert.Empty(t, recapChannels[0].ActionItems)
})
t.Run("process channel with posts", func(t *testing.T) {
// This test expects failure at SummarizePosts because we can't mock AI easily in integration test
t.Run("process channel with posts persists recap channel", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return `{"highlights":["A deterministic highlight"],"action_items":["A deterministic action item"]}`, nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
channel := th.CreateChannel(t, th.BasicTeam)
post := th.CreatePost(t, channel)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recapID := model.NewId()
agentID := "test-agent"
_, storeErr := th.App.Srv().Store().Recap().SaveRecap(&model.Recap{
Id: recapID,
UserId: th.BasicUser.Id,
Title: "Test recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusProcessing,
BotID: agentID,
})
require.NoError(t, storeErr)
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
require.Nil(t, err)
require.NotNil(t, result)
assert.True(t, result.Success)
assert.Equal(t, 1, result.MessageCount)
require.Len(t, bridge.completeCalls, 1)
assert.Equal(t, BridgeOperationRecapSummary, bridge.completeCalls[0].request.Operation)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
require.Len(t, recapChannels, 1)
assert.Equal(t, channel.Id, recapChannels[0].ChannelId)
assert.Equal(t, []string{"A deterministic highlight"}, recapChannels[0].Highlights)
assert.Equal(t, []string{"A deterministic action item"}, recapChannels[0].ActionItems)
assert.Equal(t, []string{post.Id}, recapChannels[0].SourcePostIds)
})
t.Run("malformed completion surfaces parse failure", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return "{invalid json", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
channel := th.CreateChannel(t, th.BasicTeam)
th.CreatePost(t, channel)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recapID := model.NewId()
agentID := "test-agent"
_, storeErr := th.App.Srv().Store().Recap().SaveRecap(&model.Recap{
Id: recapID,
UserId: th.BasicUser.Id,
Title: "Malformed recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusProcessing,
BotID: agentID,
})
require.NoError(t, storeErr)
result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID)
// It will fail at SummarizePosts agent call
require.NotNil(t, err)
assert.Equal(t, "app.ai.summarize.agent_call_failed", err.Id)
assert.Equal(t, "app.ai.summarize.parse_failed", err.Id)
assert.False(t, result.Success)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
require.Len(t, recapChannels, 1)
assert.Equal(t, channel.Id, recapChannels[0].ChannelId)
assert.Empty(t, recapChannels[0].Highlights)
assert.Empty(t, recapChannels[0].ActionItems)
assert.Len(t, recapChannels[0].SourcePostIds, 1)
})
}
+2
View File
@@ -149,6 +149,8 @@ type Server struct {
PushProxy einterfaces.PushProxyInterface
AutoTranslation einterfaces.AutoTranslationInterface
agentsBridgeOverride AgentsBridge
ch *Channels
}
+11 -8
View File
@@ -10,7 +10,6 @@ import (
"strings"
"time"
agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
@@ -78,17 +77,21 @@ Your response must be compacted valid JSON only, with no additional text, format
if session := rctx.Session(); session != nil {
sessionUserID = session.UserId
}
client := a.GetBridgeClient(sessionUserID)
completionRequest := agentclient.CompletionRequest{
Posts: []agentclient.Post{
requestUserID := userID
if sessionUserID != "" {
requestUserID = sessionUserID
}
completionRequest := BridgeCompletionRequest{
Operation: BridgeOperationRecapSummary,
ClientOperation: "recaps",
Messages: []BridgeMessage{
{Role: "system", Message: systemPrompt},
{Role: "user", Message: userPrompt},
},
JSONOutputFormat: summarizePostsJSONSchema,
UserID: sessionUserID,
Operation: "recaps",
OperationSubType: "summarize_channel",
UserID: requestUserID,
ChannelID: posts[0].ChannelId,
}
rctx.Logger().Debug("Calling AI agent for post summarization",
@@ -98,7 +101,7 @@ Your response must be compacted valid JSON only, with no additional text, format
mlog.Int("post_count", len(posts)),
)
completion, err := client.AgentCompletion(agentID, completionRequest)
completion, err := a.ch.agentsBridge.AgentCompletion(sessionUserID, agentID, completionRequest)
if err != nil {
return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.agent_call_failed", nil, err.Error(), http.StatusInternalServerError)
}
+125
View File
@@ -4,10 +4,12 @@
package app
import (
"errors"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildConversationText(t *testing.T) {
@@ -63,3 +65,126 @@ func TestBuildConversationText(t *testing.T) {
assert.Equal(t, "", result)
})
}
func TestSummarizePosts(t *testing.T) {
t.Run("successful recap completion parsing", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return `{"highlights":["Highlight 1"],"action_items":["Action 1"]}`, nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
posts := []*model.Post{{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Message: "Important update",
CreateAt: model.GetMillis(),
Props: model.StringInterface{
"username": th.BasicUser.Username,
},
}}
summary, appErr := th.App.SummarizePosts(ctx, th.BasicUser.Id, posts, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId())
require.Nil(t, appErr)
require.NotNil(t, summary)
assert.Equal(t, []string{"Highlight 1"}, summary.Highlights)
assert.Equal(t, []string{"Action 1"}, summary.ActionItems)
require.Len(t, bridge.completeCalls, 1)
assert.Equal(t, BridgeOperationRecapSummary, bridge.completeCalls[0].request.Operation)
assert.Equal(t, th.BasicUser.Id, bridge.completeCalls[0].sessionUserID)
assert.Equal(t, th.BasicUser.Id, bridge.completeCalls[0].request.UserID)
})
t.Run("null arrays normalize to empty slices", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return `{"highlights":null,"action_items":null}`, nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
posts := []*model.Post{{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Message: "Need to follow up",
CreateAt: model.GetMillis(),
Props: model.StringInterface{
"username": th.BasicUser.Username,
},
}}
summary, appErr := th.App.SummarizePosts(ctx, th.BasicUser.Id, posts, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId())
require.Nil(t, appErr)
require.NotNil(t, summary)
assert.Empty(t, summary.Highlights)
assert.Empty(t, summary.ActionItems)
assert.NotNil(t, summary.Highlights)
assert.NotNil(t, summary.ActionItems)
})
t.Run("bridge error returns agent call failed", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return "", errors.New("bridge failed")
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
posts := []*model.Post{{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Message: "Need help",
CreateAt: model.GetMillis(),
Props: model.StringInterface{
"username": th.BasicUser.Username,
},
}}
summary, appErr := th.App.SummarizePosts(ctx, th.BasicUser.Id, posts, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId())
require.Nil(t, summary)
require.NotNil(t, appErr)
assert.Equal(t, "app.ai.summarize.agent_call_failed", appErr.Id)
})
t.Run("invalid json returns parse failed", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return "{invalid json", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
posts := []*model.Post{{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Message: "Broken payload",
CreateAt: model.GetMillis(),
Props: model.StringInterface{
"username": th.BasicUser.Username,
},
}}
summary, appErr := th.App.SummarizePosts(ctx, th.BasicUser.Id, posts, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId())
require.Nil(t, summary)
require.NotNil(t, appErr)
assert.Equal(t, "app.ai.summarize.parse_failed", appErr.Id)
})
t.Run("empty posts short circuit without bridge call", func(t *testing.T) {
bridge := &testAgentsBridge{}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
summary, appErr := th.App.SummarizePosts(ctx, th.BasicUser.Id, []*model.Post{}, th.BasicChannel.DisplayName, th.BasicTeam.Name, model.NewId())
require.Nil(t, appErr)
require.NotNil(t, summary)
assert.Empty(t, summary.Highlights)
assert.Empty(t, summary.ActionItems)
assert.Len(t, bridge.completeCalls, 0)
})
}
+8
View File
@@ -211,6 +211,10 @@
"id": "api.admin.upload_brand_image.too_large.app_error",
"translation": "Unable to upload file. File is too large."
},
{
"id": "api.ai_bridge_test_helper.disabled.app_error",
"translation": "Bridge test helper is disabled."
},
{
"id": "api.back_to_app",
"translation": "Back to {{.SiteName}}"
@@ -5110,6 +5114,10 @@
"id": "app.ai.summarize.parse_failed",
"translation": "Failed to parse AI summarization response."
},
{
"id": "app.ai_bridge_test_helper.invalid_config",
"translation": "Invalid configuration for AI bridge test helper."
},
{
"id": "app.analytics.getanalytics.internal_error",
"translation": "Unable to get the analytics."
+15
View File
@@ -3,6 +3,21 @@
package model
type BridgeAgentInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
ServiceID string `json:"service_id"`
ServiceType string `json:"service_type"`
IsDefault bool `json:"is_default,omitempty"`
}
type BridgeServiceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type AgentsIntegrityResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
type AIBridgeTestHelperStatus struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type AIBridgeTestHelperCompletion struct {
Completion string `json:"completion,omitempty"`
Error string `json:"error,omitempty"`
StatusCode int `json:"status_code,omitempty"`
}
type AIBridgeTestHelperMessage struct {
Role string `json:"role"`
Message string `json:"message"`
FileIDs []string `json:"file_ids,omitempty"`
}
type AIBridgeTestHelperFeatureFlags struct {
EnableAIPluginBridge *bool `json:"enable_ai_plugin_bridge,omitempty"`
EnableAIRecaps *bool `json:"enable_ai_recaps,omitempty"`
}
type AIBridgeTestHelperConfig struct {
Status *AIBridgeTestHelperStatus `json:"status,omitempty"`
Agents []BridgeAgentInfo `json:"agents,omitempty"`
Services []BridgeServiceInfo `json:"services,omitempty"`
AgentCompletions map[string][]AIBridgeTestHelperCompletion `json:"agent_completions,omitempty"`
FeatureFlags *AIBridgeTestHelperFeatureFlags `json:"feature_flags,omitempty"`
RecordRequests *bool `json:"record_requests,omitempty"`
}
type AIBridgeTestHelperRecordedRequest struct {
Operation string `json:"operation"`
ClientOperation string `json:"client_operation,omitempty"`
OperationSubType string `json:"operation_sub_type,omitempty"`
SessionUserID string `json:"session_user_id,omitempty"`
UserID string `json:"user_id,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
ServiceID string `json:"service_id,omitempty"`
Messages []AIBridgeTestHelperMessage `json:"messages"`
JSONOutputFormat map[string]any `json:"json_output_format,omitempty"`
}
type AIBridgeTestHelperState struct {
Status *AIBridgeTestHelperStatus `json:"status,omitempty"`
Agents []BridgeAgentInfo `json:"agents,omitempty"`
Services []BridgeServiceInfo `json:"services,omitempty"`
AgentCompletions map[string][]AIBridgeTestHelperCompletion `json:"agent_completions,omitempty"`
FeatureFlags *AIBridgeTestHelperFeatureFlags `json:"feature_flags,omitempty"`
RecordRequests bool `json:"record_requests"`
RecordedRequests []AIBridgeTestHelperRecordedRequest `json:"recorded_requests"`
}
+31
View File
@@ -356,6 +356,10 @@ func (c *Client4) systemRoute() clientRoute {
return newClientRoute("system")
}
func (c *Client4) aiBridgeTestHelperRoute() clientRoute {
return c.systemRoute().Join("e2e", "ai_bridge")
}
func (c *Client4) cloudRoute() clientRoute {
return newClientRoute("cloud")
}
@@ -7577,6 +7581,33 @@ func (c *Client4) GetAppliedSchemaMigrations(ctx context.Context) ([]AppliedMigr
return DecodeJSONFromResponse[[]AppliedMigration](r)
}
func (c *Client4) SetAIBridgeTestHelper(ctx context.Context, config *AIBridgeTestHelperConfig) (*AIBridgeTestHelperState, *Response, error) {
r, err := c.doAPIPutJSON(ctx, c.aiBridgeTestHelperRoute(), config)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[*AIBridgeTestHelperState](r)
}
func (c *Client4) GetAIBridgeTestHelper(ctx context.Context) (*AIBridgeTestHelperState, *Response, error) {
r, err := c.doAPIGet(ctx, c.aiBridgeTestHelperRoute(), "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[*AIBridgeTestHelperState](r)
}
func (c *Client4) DeleteAIBridgeTestHelper(ctx context.Context) (*Response, error) {
r, err := c.doAPIDelete(ctx, c.aiBridgeTestHelperRoute())
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// Usage Section
// GetPostsUsage returns rounded off total usage of posts for the instance