From c81d0ddd73d1f41c6ac2811cd9f8a6821f576b29 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Mon, 30 Mar 2026 12:20:47 -0400 Subject: [PATCH] Ability to E2E AI Bridge features + Initial Recaps E2E (#35541) * Add shared AI bridge seam Co-authored-by: Nick Misasi * Add AI bridge test helper API Co-authored-by: Nick Misasi * Add AI bridge seam test coverage Co-authored-by: Nick Misasi * Add Playwright AI bridge recap helpers Co-authored-by: Nick Misasi * Fix recap channel persistence test Co-authored-by: Nick Misasi * Restore bridge client compatibility shim Co-authored-by: Nick Misasi * Expand recap card in Playwright spec Co-authored-by: Nick Misasi * Recaps e2e test coverage (#35543) * Add Recaps Playwright page object Co-authored-by: Nick Misasi * Expand AI recap Playwright coverage Co-authored-by: Nick Misasi * Format recap Playwright coverage Co-authored-by: Nick Misasi * Fix recap regeneration test flows Co-authored-by: Nick Misasi --------- Co-authored-by: Cursor Agent * Fix AI bridge lint and OpenAPI docs Co-authored-by: Nick Misasi * Fix recap lint shadowing Co-authored-by: Nick Misasi * Stabilize failed recap regeneration spec Co-authored-by: Nick Misasi * Fill AI bridge i18n strings Co-authored-by: Nick Misasi * 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) * 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 95c670863a55f1549d266baccc1e2fcf8a7cd74e. * 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 Co-authored-by: Claude Opus 4.6 (1M context) --- api/package-lock.json | 16 +- api/v4/source/definitions.yaml | 139 ++++ api/v4/source/system.yaml | 84 +++ .../playwright/lib/src/browser_context.ts | 2 + e2e-tests/playwright/lib/src/index.ts | 1 + .../playwright/lib/src/server/ai_bridge.ts | 194 ++++++ e2e-tests/playwright/lib/src/server/index.ts | 9 + e2e-tests/playwright/lib/src/test_fixture.ts | 21 + .../playwright/lib/src/ui/pages/index.ts | 3 + .../playwright/lib/src/ui/pages/recaps.ts | 286 +++++++++ .../functional/channels/ai/recaps.spec.ts | 594 ++++++++++++++++++ server/channels/api4/ai_bridge_test_helper.go | 73 +++ .../api4/ai_bridge_test_helper_test.go | 218 +++++++ server/channels/api4/api.go | 1 + server/channels/app/agents.go | 87 +-- server/channels/app/agents_bridge.go | 247 ++++++++ server/channels/app/agents_bridge_test.go | 186 ++++++ server/channels/app/ai_bridge_test_helper.go | 67 ++ server/channels/app/channels.go | 12 + server/channels/app/e2e_agents_bridge.go | 285 +++++++++ server/channels/app/options.go | 7 + server/channels/app/post.go | 19 +- server/channels/app/recap.go | 43 +- server/channels/app/recap_test.go | 94 ++- server/channels/app/server.go | 2 + server/channels/app/summarization.go | 19 +- server/channels/app/summarization_test.go | 125 ++++ server/i18n/en.json | 8 + server/public/model/agents.go | 15 + server/public/model/ai_bridge_test_helper.go | 58 ++ server/public/model/client4.go | 31 + 31 files changed, 2835 insertions(+), 111 deletions(-) create mode 100644 e2e-tests/playwright/lib/src/server/ai_bridge.ts create mode 100644 e2e-tests/playwright/lib/src/ui/pages/recaps.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts create mode 100644 server/channels/api4/ai_bridge_test_helper.go create mode 100644 server/channels/api4/ai_bridge_test_helper_test.go create mode 100644 server/channels/app/agents_bridge.go create mode 100644 server/channels/app/agents_bridge_test.go create mode 100644 server/channels/app/ai_bridge_test_helper.go create mode 100644 server/channels/app/e2e_agents_bridge.go create mode 100644 server/public/model/ai_bridge_test_helper.go diff --git a/api/package-lock.json b/api/package-lock.json index 9895037acdc..731e463c3f8 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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", diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 3c87cd34e5c..4dbac04b3e5 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -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: diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index cd0ab8a4d92..993fdde5005 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -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: diff --git a/e2e-tests/playwright/lib/src/browser_context.ts b/e2e-tests/playwright/lib/src/browser_context.ts index 3884f65955b..917d82bd95f 100644 --- a/e2e-tests/playwright/lib/src/browser_context.ts +++ b/e2e-tests/playwright/lib/src/browser_context.ts @@ -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, }; diff --git a/e2e-tests/playwright/lib/src/index.ts b/e2e-tests/playwright/lib/src/index.ts index 6cc34280939..dd1f278c4d4 100644 --- a/e2e-tests/playwright/lib/src/index.ts +++ b/e2e-tests/playwright/lib/src/index.ts @@ -13,6 +13,7 @@ export { ChannelsPage, LandingLoginPage, LoginPage, + RecapsPage, ResetPasswordPage, SignupPage, ScheduledPostsPage, diff --git a/e2e-tests/playwright/lib/src/server/ai_bridge.ts b/e2e-tests/playwright/lib/src/server/ai_bridge.ts new file mode 100644 index 00000000000..f31ceb1482c --- /dev/null +++ b/e2e-tests/playwright/lib/src/server/ai_bridge.ts @@ -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; +}; + +export type AIBridgeMockConfig = { + status?: AIBridgeMockStatus; + agents?: AIBridgeMockAgent[]; + services?: AIBridgeMockService[]; + agent_completions?: Partial>; + 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; + service?: Partial; + status?: AIBridgeMockStatus; + record_requests?: boolean; +}; + +const AI_BRIDGE_TEST_HELPER_ROUTE = '/system/e2e/ai_bridge'; + +async function doAdminFetch(adminClient: Client4, method: 'GET' | 'PUT' | 'DELETE', body?: unknown): Promise { + const route = `${adminClient.getBaseRoute()}${AI_BRIDGE_TEST_HELPER_ROUTE}`; + + return (adminClient as any).doFetch(route, { + method, + ...(body === undefined ? {} : {body: JSON.stringify(body)}), + }); +} + +function upsertById(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 { + 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 { + return doAdminFetch(adminClient, 'PUT', config); +} + +export async function getAIBridgeMock(adminClient: Client4): Promise { + return doAdminFetch(adminClient, 'GET'); +} + +export async function resetAIBridgeMock(adminClient: Client4): Promise { + 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, + }), + }; +} diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index f94d5068e3b..3d75c720425 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -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, diff --git a/e2e-tests/playwright/lib/src/test_fixture.ts b/e2e-tests/playwright/lib/src/test_fixture.ts index 0f001ece641..d8c25e9cc48 100644 --- a/e2e-tests/playwright/lib/src/test_fixture.ts +++ b/e2e-tests/playwright/lib/src/test_fixture.ts @@ -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; diff --git a/e2e-tests/playwright/lib/src/ui/pages/index.ts b/e2e-tests/playwright/lib/src/ui/pages/index.ts index 7c72fa7e0e3..c1fb4db5085 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/index.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/index.ts @@ -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, diff --git a/e2e-tests/playwright/lib/src/ui/pages/recaps.ts b/e2e-tests/playwright/lib/src/ui/pages/recaps.ts new file mode 100644 index 00000000000..cf40f97ea12 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/recaps.ts @@ -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, '\\$&'); +} diff --git a/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts b/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts new file mode 100644 index 00000000000..21541d12175 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/ai/recaps.spec.ts @@ -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)); +} diff --git a/server/channels/api4/ai_bridge_test_helper.go b/server/channels/api4/ai_bridge_test_helper.go new file mode 100644 index 00000000000..e11dc537c7d --- /dev/null +++ b/server/channels/api4/ai_bridge_test_helper.go @@ -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) +} diff --git a/server/channels/api4/ai_bridge_test_helper_test.go b/server/channels/api4/ai_bridge_test_helper_test.go new file mode 100644 index 00000000000..27516bf93d5 --- /dev/null +++ b/server/channels/api4/ai_bridge_test_helper_test.go @@ -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) +} diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 1ec29bb895e..1a44271616f 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -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() diff --git a/server/channels/app/agents.go b/server/channels/app/agents.go index e435b853589..6077865a358 100644 --- a/server/channels/app/agents.go +++ b/server/channels/app/agents.go @@ -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), diff --git a/server/channels/app/agents_bridge.go b/server/channels/app/agents_bridge.go new file mode 100644 index 00000000000..de3d0a27fc4 --- /dev/null +++ b/server/channels/app/agents_bridge.go @@ -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) diff --git a/server/channels/app/agents_bridge_test.go b/server/channels/app/agents_bridge_test.go new file mode 100644 index 00000000000..7103462ac41 --- /dev/null +++ b/server/channels/app/agents_bridge_test.go @@ -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) diff --git a/server/channels/app/ai_bridge_test_helper.go b/server/channels/app/ai_bridge_test_helper.go new file mode 100644 index 00000000000..ceaf0925266 --- /dev/null +++ b/server/channels/app/ai_bridge_test_helper.go @@ -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 +} diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index 2c12c363bbc..ecc65a15447 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -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()) diff --git a/server/channels/app/e2e_agents_bridge.go b/server/channels/app/e2e_agents_bridge.go new file mode 100644 index 00000000000..f0771c975f8 --- /dev/null +++ b/server/channels/app/e2e_agents_bridge.go @@ -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) diff --git a/server/channels/app/options.go b/server/channels/app/options.go index fd74616fa3a..914f62f6a2e 100644 --- a/server/channels/app/options.go +++ b/server/channels/app/options.go @@ -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 diff --git a/server/channels/app/post.go b/server/channels/app/post.go index c59cf7baa96..8732a943316 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -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) } diff --git a/server/channels/app/recap.go b/server/channels/app/recap.go index 743dc576c0c..31db75d15e4 100644 --- a/server/channels/app/recap.go +++ b/server/channels/app/recap.go @@ -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 diff --git a/server/channels/app/recap_test.go b/server/channels/app/recap_test.go index 2edb0db43fd..cc21ccef816 100644 --- a/server/channels/app/recap_test.go +++ b/server/channels/app/recap_test.go @@ -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) }) } diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 36a7a527c58..e3266930127 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -149,6 +149,8 @@ type Server struct { PushProxy einterfaces.PushProxyInterface AutoTranslation einterfaces.AutoTranslationInterface + agentsBridgeOverride AgentsBridge + ch *Channels } diff --git a/server/channels/app/summarization.go b/server/channels/app/summarization.go index 178e4cbb789..72a60c0783a 100644 --- a/server/channels/app/summarization.go +++ b/server/channels/app/summarization.go @@ -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) } diff --git a/server/channels/app/summarization_test.go b/server/channels/app/summarization_test.go index 3905066c09a..9b7b1efd651 100644 --- a/server/channels/app/summarization_test.go +++ b/server/channels/app/summarization_test.go @@ -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) + }) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 78624e8cc38..352f62da23e 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/agents.go b/server/public/model/agents.go index 205c201f2b5..c4504144943 100644 --- a/server/public/model/agents.go +++ b/server/public/model/agents.go @@ -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"` diff --git a/server/public/model/ai_bridge_test_helper.go b/server/public/model/ai_bridge_test_helper.go new file mode 100644 index 00000000000..027b2e95491 --- /dev/null +++ b/server/public/model/ai_bridge_test_helper.go @@ -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"` +} diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 13f18763516..32be876dfb4 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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