Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

1014 lines
34 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"os"
"strings"
"testing"
"time"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/i18n"
"github.com/hanzoteam/server/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateRecap(t *testing.T) {
t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
th := Setup(t).InitBasic(t)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
t.Run("create recap with valid channels", func(t *testing.T) {
channel2 := th.CreateChannel(t, th.BasicTeam)
channelIds := []string{th.BasicChannel.Id, channel2.Id}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "My Test Recap", channelIds, "test-agent-id")
require.Nil(t, err)
require.NotNil(t, recap)
assert.Equal(t, th.BasicUser.Id, recap.UserId)
assert.Equal(t, model.RecapStatusPending, recap.Status)
assert.Equal(t, "My Test Recap", recap.Title)
})
t.Run("create recap with channel user is not member of", func(t *testing.T) {
// Create a private channel and add only BasicUser2
privateChannel := th.CreatePrivateChannel(t, th.BasicTeam)
// Remove BasicUser if they were added automatically
_ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel)
// Ensure BasicUser2 is a member instead
th.AddUserToChannel(t, th.BasicUser2, privateChannel)
// Try to create recap as BasicUser who is not a member
channelIds := []string{privateChannel.Id}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "Test Recap", channelIds, "test-agent-id")
require.NotNil(t, err)
assert.Nil(t, recap)
assert.Equal(t, "app.recap.permission_denied", err.Id)
})
t.Run("cooldown error rounds up remaining minutes", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(2)
})
// Place the last recap 1s ago so the remaining cooldown sits near the top of the
// 2-minute band (~119s). This still exercises ceiling rounding while leaving ~59s of
// slack, so a slow/loaded CI run can't flip the rounded value down to "1 minute".
lastCreateAt := model.GetMillis() - int64(1*1000)
lastManualRecap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Completed Recap",
CreateAt: lastCreateAt,
UpdateAt: lastCreateAt,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 1,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, saveErr := th.App.Srv().Store().Recap().SaveRecap(lastManualRecap)
require.NoError(t, saveErr)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "Cooldown Recap", []string{th.BasicChannel.Id}, "test-agent-id")
require.NotNil(t, err)
require.Nil(t, recap)
assert.Equal(t, "app.recap.cooldown_active.app_error", err.Id)
assert.Contains(t, err.SystemMessage(i18n.GetUserTranslations("en")), "another recap in 2 minutes")
})
t.Run("cooldown still applies after soft deleting last recap", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(2)
})
lastCreateAt := model.GetMillis() - int64(30*1000)
lastManualRecap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Soft Deleted Completed Recap",
CreateAt: lastCreateAt,
UpdateAt: lastCreateAt,
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 1,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, saveErr := th.App.Srv().Store().Recap().SaveRecap(lastManualRecap)
require.NoError(t, saveErr)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
deleteErr := th.App.DeleteRecap(ctx, lastManualRecap.Id)
require.Nil(t, deleteErr)
recap, err := th.App.CreateRecap(ctx, "Cooldown Recap", []string{th.BasicChannel.Id}, "test-agent-id")
require.NotNil(t, err)
require.Nil(t, recap)
assert.Equal(t, "app.recap.cooldown_active.app_error", err.Id)
})
t.Run("create recap blocked by max channels per recap", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceChannelsPerRecap = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.MaxChannelsPerRecap = model.NewPointer(1)
})
channel2 := th.CreateChannel(t, th.BasicTeam)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "Too Many Channels", []string{th.BasicChannel.Id, channel2.Id}, "test-agent-id")
require.NotNil(t, err)
require.Nil(t, recap)
assert.Equal(t, "app.recap.max_channels_exceeded.app_error", err.Id)
})
t.Run("create recap blocked by max recaps per day", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1)
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(false)
})
existingRecap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Existing Today",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 1,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, saveErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap)
require.NoError(t, saveErr)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, err := th.App.CreateRecap(ctx, "Daily Limit Recap", []string{th.BasicChannel.Id}, "test-agent-id")
require.NotNil(t, err)
require.Nil(t, recap)
assert.Equal(t, "app.recap.max_recaps_reached.app_error", err.Id)
})
}
func TestCreateRecapMasterToggleDisabledBlocksCreation(t *testing.T) {
t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.EnableAIRecaps = true
cfg.AIRecapSettings.Enable = model.NewPointer(false)
cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1)
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(60)
})
existingRecap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Existing Today",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
TotalMessageCount: 1,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, saveErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap)
require.NoError(t, saveErr)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, appErr := th.App.CreateRecap(ctx, "Blocked Recap", []string{th.BasicChannel.Id}, "test-agent-id")
require.NotNil(t, appErr)
require.Nil(t, recap)
assert.Equal(t, "api.recap.disabled.app_error", appErr.Id)
}
func TestCreateRecapFeatureFlagDisabledBlocksCreation(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.EnableAIRecaps = false
cfg.AIRecapSettings.Enable = model.NewPointer(true)
})
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recap, appErr := th.App.CreateRecap(ctx, "Blocked Recap", []string{th.BasicChannel.Id}, "test-agent-id")
require.NotNil(t, appErr)
require.Nil(t, recap)
assert.Equal(t, "api.recap.disabled.app_error", appErr.Id)
}
func TestGetRecap(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
t.Run("get recap by owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Create recap channel
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recap.Id,
ChannelId: th.BasicChannel.Id,
ChannelName: th.BasicChannel.DisplayName,
Highlights: []string{"Test highlight"},
ActionItems: []string{"Test action"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel)
require.NoError(t, err)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
require.Nil(t, appErr)
require.NotNil(t, retrievedRecap)
assert.Equal(t, recap.Id, retrievedRecap.Id)
assert.Len(t, retrievedRecap.Channels, 1)
assert.Equal(t, recapChannel.ChannelName, retrievedRecap.Channels[0].ChannelName)
})
t.Run("get recap by non-owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Try to get as a different user - create context with BasicUser2's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id)
// Permissions are now checked in API layer, so App layer should return the recap
require.Nil(t, appErr)
require.NotNil(t, retrievedRecap)
assert.Equal(t, recap.Id, retrievedRecap.Id)
})
}
func TestGetRecapsForUser(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
t.Run("get recaps for user", func(t *testing.T) {
// Create multiple recaps for the user
for range 5 {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
}
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
recaps, err := th.App.GetRecapsForUser(ctx, 0, 10)
require.Nil(t, err)
assert.Len(t, recaps, 5)
})
t.Run("pagination works correctly", func(t *testing.T) {
userId := model.NewId()
// Create context with the test user's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: userId})
// Create 15 recaps
for range 15 {
recap := &model.Recap{
Id: model.NewId(),
UserId: userId,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
}
// Get first page
recapsPage1, err := th.App.GetRecapsForUser(ctx, 0, 10)
require.Nil(t, err)
assert.Len(t, recapsPage1, 10)
// Get second page
recapsPage2, err := th.App.GetRecapsForUser(ctx, 1, 10)
require.Nil(t, err)
assert.Len(t, recapsPage2, 5)
})
}
func TestMarkRecapAsRead(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
t.Run("mark recap as read by owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Mark as read
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
require.Nil(t, appErr)
require.NotNil(t, updatedRecap)
assert.Greater(t, updatedRecap.ReadAt, int64(0))
})
t.Run("mark recap as read by non-owner", func(t *testing.T) {
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Test Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
}
savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
// Try to mark as read as a different user - create context with BasicUser2's session
ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id})
updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap)
// Permissions are now checked in API layer, so App layer should allow it
require.Nil(t, appErr)
require.NotNil(t, updatedRecap)
assert.Greater(t, updatedRecap.ReadAt, int64(0))
})
}
func TestRegenerateRecapLimitEnforcement(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS")
th := Setup(t).InitBasic(t)
t.Run("regenerate recap blocked by max recaps per day", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.MaxRecapsPerDay = model.NewPointer(1)
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(false)
})
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Existing Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recap.Id,
ChannelId: th.BasicChannel.Id,
ChannelName: th.BasicChannel.DisplayName,
Highlights: []string{"highlight"},
ActionItems: []string{"action"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel)
require.NoError(t, err)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
regenerated, appErr := th.App.RegenerateRecap(ctx, th.BasicUser.Id, recap)
require.NotNil(t, appErr)
require.Nil(t, regenerated)
assert.Equal(t, "app.recap.max_recaps_reached.app_error", appErr.Id)
})
t.Run("regenerate recap blocked by cooldown", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AIRecapSettings.EnforceRecapsPerDay = model.NewPointer(false)
cfg.AIRecapSettings.EnforceCooldown = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.CooldownMinutes = model.NewPointer(60)
})
recap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Cooldown Existing Recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
DeleteAt: 0,
ReadAt: 0,
TotalMessageCount: 10,
Status: model.RecapStatusCompleted,
BotID: "test-agent-id",
}
_, err := th.App.Srv().Store().Recap().SaveRecap(recap)
require.NoError(t, err)
recapChannel := &model.RecapChannel{
Id: model.NewId(),
RecapId: recap.Id,
ChannelId: th.BasicChannel.Id,
ChannelName: th.BasicChannel.DisplayName,
Highlights: []string{"highlight"},
ActionItems: []string{"action"},
SourcePostIds: []string{model.NewId()},
CreateAt: model.GetMillis(),
}
err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel)
require.NoError(t, err)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
regenerated, appErr := th.App.RegenerateRecap(ctx, th.BasicUser.Id, recap)
require.NotNil(t, appErr)
require.Nil(t, regenerated)
assert.Equal(t, "app.recap.cooldown_active.app_error", appErr.Id)
})
}
func TestMarkRecapsAsViewed(t *testing.T) {
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
save := func(userID, status string, viewedAt int64) string {
r := &model.Recap{
Id: model.NewId(),
UserId: userID,
Title: "T",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: status,
ViewedAt: viewedAt,
TotalMessageCount: 1,
}
_, err := th.App.Srv().Store().Recap().SaveRecap(r)
require.NoError(t, err)
return r.Id
}
t.Run("marks completed and failed and ignores in-flight statuses", func(t *testing.T) {
userID := model.NewId()
completed := save(userID, model.RecapStatusCompleted, 0)
failed := save(userID, model.RecapStatusFailed, 0)
pending := save(userID, model.RecapStatusPending, 0)
processing := save(userID, model.RecapStatusProcessing, 0)
ctx := th.Context.WithSession(&model.Session{UserId: userID})
ids, appErr := th.App.MarkRecapsAsViewed(ctx)
require.Nil(t, appErr)
assert.ElementsMatch(t, []string{completed, failed}, ids)
r1, err := th.App.Srv().Store().Recap().GetRecap(pending)
require.NoError(t, err)
assert.Zero(t, r1.ViewedAt)
r2, err := th.App.Srv().Store().Recap().GetRecap(processing)
require.NoError(t, err)
assert.Zero(t, r2.ViewedAt)
})
t.Run("returns empty list when nothing to mark", func(t *testing.T) {
ctx := th.Context.WithSession(&model.Session{UserId: model.NewId()})
ids, appErr := th.App.MarkRecapsAsViewed(ctx)
require.Nil(t, appErr)
assert.Empty(t, ids)
})
t.Run("publishes a recap_updated websocket event per affected recap", func(t *testing.T) {
userID := th.BasicUser.Id
// Two completed recaps that need to be marked as viewed.
a := save(userID, model.RecapStatusCompleted, 0)
b := save(userID, model.RecapStatusCompleted, 0)
messages, closeWS := connectFakeWebSocket(t, th, userID, "", []model.WebsocketEventType{model.WebsocketEventRecapUpdated})
defer closeWS()
ctx := th.Context.WithSession(&model.Session{UserId: userID})
ids, appErr := th.App.MarkRecapsAsViewed(ctx)
require.Nil(t, appErr)
assert.ElementsMatch(t, []string{a, b}, ids)
seen := make(map[string]bool)
deadline := time.After(5 * time.Second)
for len(seen) < 2 {
select {
case msg := <-messages:
recapID, ok := msg.GetData()["recap_id"].(string)
require.True(t, ok, "recap_updated event missing recap_id")
seen[recapID] = true
case <-deadline:
require.Failf(t, "timed out waiting for recap_updated events", "received %d/2", len(seen))
}
}
assert.True(t, seen[a])
assert.True(t, seen[b])
})
}
func TestProcessRecapChannel(t *testing.T) {
t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
t.Run("process empty channel", func(t *testing.T) {
th := Setup(t).InitBasic(t)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
// Ensure channel has no posts (it shouldn't in init)
channel := th.CreateChannel(t, th.BasicTeam)
// No posts added
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 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)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
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)
// Enable AI Recaps feature flag
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
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)
require.NotNil(t, err)
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)
})
t.Run("denies channel when user lacks read permission", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
require.Fail(t, "bridge should not be called when user lacks channel access")
return "", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.EnableAIRecaps = true })
// Private channel owned by another user; BasicUser is not a member.
channel := th.CreatePrivateChannel(t, th.BasicTeam, func(c *model.Channel) {
c.CreatorId = th.BasicUser2.Id
})
th.CreatePost(t, channel, func(p *model.Post) { p.UserId = th.BasicUser2.Id })
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: "Unauthorized 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.NotNil(t, err)
assert.Equal(t, "app.recap.permission_denied", err.Id)
require.NotNil(t, result)
assert.False(t, result.Success)
assert.Empty(t, bridge.completeCalls)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
assert.Empty(t, recapChannels)
})
t.Run("max posts per day prevents additional post processing", func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
require.Fail(t, "bridge should not be called when post usage is exhausted")
return "", nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.EnableAIRecaps = true
cfg.AIRecapSettings.EnforcePostsPerDay = model.NewPointer(true)
cfg.AIRecapSettings.DefaultLimits.MaxPostsPerDay = model.NewPointer(1)
})
existingRecap := &model.Recap{
Id: model.NewId(),
UserId: th.BasicUser.Id,
Title: "Existing usage",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
TotalMessageCount: 1,
Status: model.RecapStatusCompleted,
BotID: "test-agent",
}
_, storeErr := th.App.Srv().Store().Recap().SaveRecap(existingRecap)
require.NoError(t, storeErr)
channel := th.CreateChannel(t, th.BasicTeam)
th.CreatePost(t, channel)
recapID := model.NewId()
agentID := "test-agent"
_, storeErr = th.App.Srv().Store().Recap().SaveRecap(&model.Recap{
Id: recapID,
UserId: th.BasicUser.Id,
Title: "Limited recap",
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
Status: model.RecapStatusProcessing,
BotID: agentID,
})
require.NoError(t, storeErr)
ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id})
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)
assert.Empty(t, bridge.completeCalls)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
require.Len(t, recapChannels, 1)
assert.Empty(t, recapChannels[0].SourcePostIds)
})
}
func TestProcessRecapChannelTokenLimit(t *testing.T) {
t.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true")
// 400 chars => ~100 estimated tokens per post; 5 posts => ~500 tokens.
longMessage := strings.Repeat("x", 400)
const postCount = 5
tests := []struct {
name string
enforceTokens bool
maxTokens int
wantMessageCount int
}{
{
name: "token limit reduces posts sent to LLM",
enforceTokens: true,
maxTokens: 150, // room for a single ~100-token post
wantMessageCount: 1,
},
{
name: "no enforcement keeps every post",
enforceTokens: false,
maxTokens: 150,
wantMessageCount: postCount,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
bridge := &testAgentsBridge{
completeFn: func(sessionUserID, agentID string, req BridgeCompletionRequest) (string, error) {
return `{"highlights":["h"],"action_items":["a"]}`, nil
},
}
th := Setup(t, WithAgentsBridge(bridge)).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.FeatureFlags.EnableAIRecaps = true
cfg.AIRecapSettings.EnforceTokensPerRecap = model.NewPointer(tc.enforceTokens)
cfg.AIRecapSettings.DefaultLimits.MaxTokensPerRecap = model.NewPointer(tc.maxTokens)
})
channel := th.CreateChannel(t, th.BasicTeam)
for range postCount {
th.CreateMessagePost(t, channel, longMessage)
}
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: "Token limit 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.True(t, result.Success)
assert.Equal(t, tc.wantMessageCount, result.MessageCount)
recapChannels, storeErr := th.App.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID)
require.NoError(t, storeErr)
require.Len(t, recapChannels, 1)
assert.Len(t, recapChannels[0].SourcePostIds, tc.wantMessageCount)
})
}
}
func TestRecapFetchStartAt(t *testing.T) {
now := time.Date(2026, time.April, 28, 12, 0, 0, 0, time.UTC)
lastViewedAt := now.Add(-2 * time.Hour).UnixMilli()
startAt, allowFallback := recapFetchStartAt("", lastViewedAt, now)
assert.Equal(t, lastViewedAt, startAt)
assert.True(t, allowFallback)
startAt, allowFallback = recapFetchStartAt(model.TimePeriodSinceLastRead, lastViewedAt, now)
assert.Equal(t, lastViewedAt, startAt)
assert.True(t, allowFallback)
startAt, allowFallback = recapFetchStartAt(model.TimePeriodLast24h, lastViewedAt, now)
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), startAt)
assert.False(t, allowFallback)
startAt, allowFallback = recapFetchStartAt(model.TimePeriodLastWeek, lastViewedAt, now)
assert.Equal(t, now.Add(-7*24*time.Hour).UnixMilli(), startAt)
assert.False(t, allowFallback)
}
func TestExtractPostIDs(t *testing.T) {
t.Run("extract post IDs from posts", func(t *testing.T) {
posts := []*model.Post{
{Id: "post1", Message: "test1"},
{Id: "post2", Message: "test2"},
{Id: "post3", Message: "test3"},
}
ids := extractPostIDs(posts)
assert.Len(t, ids, 3)
assert.Equal(t, "post1", ids[0])
assert.Equal(t, "post2", ids[1])
assert.Equal(t, "post3", ids[2])
})
t.Run("extract from empty posts", func(t *testing.T) {
posts := []*model.Post{}
ids := extractPostIDs(posts)
assert.Len(t, ids, 0)
})
}
func TestEstimateTokens(t *testing.T) {
t.Run("empty string", func(t *testing.T) {
tokens := estimateTokens("")
assert.Equal(t, 0, tokens)
})
t.Run("short text", func(t *testing.T) {
// 4 chars = 1 token (ceiling)
tokens := estimateTokens("test")
assert.Equal(t, 1, tokens)
})
t.Run("longer text", func(t *testing.T) {
// 20 chars -> (20+3)/4 = 5 tokens (ceiling division)
tokens := estimateTokens("12345678901234567890")
assert.Equal(t, 5, tokens)
})
t.Run("conservative ceiling division", func(t *testing.T) {
// 5 chars -> (5+3)/4 = 2 tokens
tokens := estimateTokens("hello")
assert.Equal(t, 2, tokens)
})
}
func TestEstimatePostTokens(t *testing.T) {
t.Run("estimates tokens from post message", func(t *testing.T) {
post := &model.Post{Message: "Hello world from Hanzo Team"} // 27 chars
tokens := estimatePostTokens(post)
// (27+3)/4 = 7 tokens
assert.Equal(t, 7, tokens)
})
}
func TestTrimPostsToTokenLimit(t *testing.T) {
// 40 chars => (40+3)/4 = 10 tokens per post.
msg40 := strings.Repeat("x", 40)
makePosts := func(ids ...string) []*model.Post {
posts := make([]*model.Post, len(ids))
for i, id := range ids {
posts[i] = &model.Post{Id: id, Message: msg40}
}
return posts
}
tests := []struct {
name string
posts []*model.Post
maxTokens int
wantIDs []string
wantTrimmed bool
}{
{
name: "no trim when under limit",
posts: makePosts("a", "b"),
maxTokens: 1000,
wantIDs: []string{"a", "b"},
wantTrimmed: false,
},
{
name: "keeps newest posts that fit",
posts: makePosts("newest", "middle", "oldest"),
maxTokens: 25, // room for 2 posts (20 tokens), not 3 (30 tokens)
wantIDs: []string{"newest", "middle"},
wantTrimmed: true,
},
{
name: "drops all when first post exceeds limit",
posts: makePosts("a", "b"),
maxTokens: 5, // single post is 10 tokens
wantIDs: []string{},
wantTrimmed: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, trimmed := trimPostsToTokenLimit(tc.posts, tc.maxTokens)
assert.Equal(t, tc.wantTrimmed, trimmed)
gotIDs := make([]string, len(got))
for i, p := range got {
gotIDs[i] = p.Id
}
assert.Equal(t, tc.wantIDs, gotIDs)
})
}
}