mattermost/server/channels/app/recap.go
Nick Misasi c81d0ddd73
Ability to E2E AI Bridge features + Initial Recaps E2E (#35541)
* Add shared AI bridge seam

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

* Add AI bridge test helper API

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

* Add AI bridge seam test coverage

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

* Add Playwright AI bridge recap helpers

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

* Fix recap channel persistence test

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

* Restore bridge client compatibility shim

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

* Expand recap card in Playwright spec

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

* Recaps e2e test coverage (#35543)

* Add Recaps Playwright page object

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

* Expand AI recap Playwright coverage

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

* Format recap Playwright coverage

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

* Fix recap regeneration test flows

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

---------

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

* Fix AI bridge lint and OpenAPI docs

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

* Fix recap lint shadowing

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

* Stabilize failed recap regeneration spec

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

* Fill AI bridge i18n strings

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

* Fix i18n

* Add service completion bridge path and operation tracking fields

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

Made-with: Cursor

* Fill empty i18n translation strings for enterprise keys

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

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

* Fix i18n

* Fix i18n again

* Rename Complete/CompleteService to AgentCompletion/ServiceCompletion

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

Made-with: Cursor

* Refactor

* Add e2eAgentsBridge implementation

The new file was missed from the prior refactor commit.

Made-with: Cursor

* Address CodeRabbit review feedback

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

Made-with: Cursor

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

Made-with: Cursor

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

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

Made-with: Cursor

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

Made-with: Cursor

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

This reverts commit 95c670863a.

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

Made-with: Cursor

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

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

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

Made-with: Cursor

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:20:47 +00:00

316 lines
10 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// CreateRecap creates a new recap job for the specified channels
func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
// Validate user is member of all channels
for _, channelID := range channelIDs {
if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok {
return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden)
}
}
timeNow := model.GetMillis()
// Create recap record
recap := &model.Recap{
Id: model.NewId(),
UserId: userID,
Title: title,
CreateAt: timeNow,
UpdateAt: timeNow,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 0,
Status: model.RecapStatusPending,
BotID: agentID,
}
savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap)
if err != nil {
return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Create background job
jobData := map[string]string{
"recap_id": recap.Id,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": agentID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
return savedRecap, nil
}
// GetRecap retrieves a recap by ID
func (a *App) GetRecap(rctx request.CTX, recapID string) (*model.Recap, *model.AppError) {
recap, err := a.Srv().Store().Recap().GetRecap(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
}
// Load channels
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("GetRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
return recap, nil
}
// GetRecapsForUser retrieves all recaps for a user
func (a *App) GetRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.Recap, *model.AppError) {
userID := rctx.Session().UserId
recaps, err := a.Srv().Store().Recap().GetRecapsForUser(userID, page, perPage)
if err != nil {
return nil, model.NewAppError("GetRecapsForUser", "app.recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return recaps, nil
}
// MarkRecapAsRead marks a recap as read
func (a *App) MarkRecapAsRead(rctx request.CTX, recap *model.Recap) (*model.Recap, *model.AppError) {
// Mark as read
if markErr := a.Srv().Store().Recap().MarkRecapAsRead(recap.Id); markErr != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.mark_read.app_error", nil, "", http.StatusInternalServerError).Wrap(markErr)
}
// Update the passed recap with read timestamp
recap.ReadAt = model.GetMillis()
recap.UpdateAt = recap.ReadAt
// Load channels if not already loaded
if recap.Channels == nil {
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recap.Id)
if err != nil {
return nil, model.NewAppError("MarkRecapAsRead", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
recap.Channels = channels
}
return recap, nil
}
// RegenerateRecap regenerates an existing recap
func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) {
recapID := recap.Id
// Get existing recap channels to extract channel IDs
channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
if err != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Extract channel IDs
channelIDs := make([]string, len(channels))
for i, channel := range channels {
channelIDs[i] = channel.ChannelId
}
// Delete existing recap channels
if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
// Update recap status to pending and reset read status
recap.Status = model.RecapStatusPending
recap.ReadAt = 0
recap.UpdateAt = model.GetMillis()
recap.TotalMessageCount = 0
if _, updateErr := a.Srv().Store().Recap().UpdateRecap(recap); updateErr != nil {
return nil, model.NewAppError("RegenerateRecap", "app.recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr)
}
// Create new job with same parameters
jobData := map[string]string{
"recap_id": recapID,
"user_id": userID,
"channel_ids": strings.Join(channelIDs, ","),
"agent_id": recap.BotID,
}
_, jobErr := a.CreateJob(rctx, &model.Job{
Type: model.JobTypeRecap,
Data: jobData,
})
if jobErr != nil {
return nil, jobErr
}
// Return updated recap
updatedRecap, getErr := a.GetRecap(rctx, recapID)
if getErr != nil {
return nil, getErr
}
return updatedRecap, nil
}
// DeleteRecap deletes a recap (soft delete)
func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError {
// Delete recap
if deleteErr := a.Srv().Store().Recap().DeleteRecap(recapID); deleteErr != nil {
return model.NewAppError("DeleteRecap", "app.recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr)
}
return nil
}
// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them,
// and saving the recap channel record. Returns the number of messages processed.
func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) {
result := &model.RecapChannelResult{
ChannelID: channelID,
Success: false,
}
// Get channel info
channel, err := a.GetChannel(rctx, channelID)
if err != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Get user's last viewed timestamp
lastViewedAt, lastViewedErr := a.Srv().Store().Channel().GetMemberLastViewedAt(rctx, channelID, userID)
if lastViewedErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr)
}
// Fetch posts for recap
posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100)
if postsErr != nil {
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
}
// Get team info for permalink generation
team, teamErr := a.GetTeam(channel.TeamId)
if teamErr != nil {
return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr)
}
// 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
}
if appErr := a.saveRecapChannelRecord(recapID, channelID, channel.DisplayName, summary.Highlights, summary.ActionItems, sourcePostIDs); appErr != nil {
return result, appErr
}
result.MessageCount = len(posts)
result.Success = true
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
options := model.GetPostsSinceOptions{
ChannelId: channelID,
Time: lastViewedAt,
}
postList, err := a.GetPostsSince(rctx, options)
if err != nil {
return nil, err
}
if len(postList.Posts) == 0 {
// If there are no unread posts, get the most recent 15 posts to include in the recap
postList, err = a.GetPosts(rctx, channelID, 0, 20)
if err != nil {
return nil, err
}
}
// Convert to slice and limit
posts := make([]*model.Post, 0, len(postList.Posts))
for _, postID := range postList.Order {
if post, ok := postList.Posts[postID]; ok {
posts = append(posts, post)
if len(posts) >= limit {
break
}
}
}
// Enrich with usernames
for _, post := range posts {
user, _ := a.GetUser(post.UserId)
if user != nil {
if post.Props == nil {
post.Props = make(model.StringInterface)
}
post.AddProp("username", user.Username)
}
}
return posts, nil
}
// extractPostIDs extracts post IDs from a slice of posts
func extractPostIDs(posts []*model.Post) []string {
ids := make([]string, len(posts))
for i, post := range posts {
ids[i] = post.Id
}
return ids
}