diff --git a/api/api.go b/api/api.go index 57fec9aa0..94483ce53 100644 --- a/api/api.go +++ b/api/api.go @@ -53,6 +53,7 @@ type Config interface { AllowUnsafeLinks() bool EmbeddingSearchConfig() embeddings.EmbeddingSearchConfig EnableChannelMentionToolCalling() bool + EnableAskAnotherUser() bool } type MCPClientManager interface { @@ -366,6 +367,8 @@ func (a *API) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Reques postRouter.POST("/regenerate", a.handleRegenerate) postRouter.POST("/tool_call", a.handleToolCall) postRouter.POST("/tool_result", a.handleToolResult) + postRouter.POST("/ask_user_response", a.handleAskUserResponse) + postRouter.POST("/ask_user_cancel", a.handleAskUserCancel) postRouter.POST("/postback_summary", a.handlePostbackSummary) postRouter.POST("/loop_in_agent", a.handleLoopInAgent) diff --git a/api/api_post.go b/api/api_post.go index 5ad1dd99e..1b49d1b27 100644 --- a/api/api_post.go +++ b/api/api_post.go @@ -405,6 +405,140 @@ func (a *API) handleToolResult(c *gin.Context) { c.Status(http.StatusOK) } +// handleAskUserResponse processes the target user's answer to an +// ask-another-user question card. Unlike handleToolCall it has no +// EnableChannelMentionToolCalling gate (the card lives in a DM even when the +// conversation is a channel thread) and no isConversationOwner check — the +// authoritative target check happens in the conversations layer against the +// card's ask_user_target_id prop. +func (a *API) handleAskUserResponse(c *gin.Context) { + userID := c.GetHeader("Mattermost-User-Id") + post := c.MustGet(ContextPostKey).(*model.Post) + channel := c.MustGet(ContextChannelKey).(*model.Channel) + + // F1 master switch (V2-C1): the experimental feature refuses answers + // while off, before the body is even read. A block left waiting stays + // waiting until the admin re-enables the toggle or the conversation is + // regenerated. + if !a.config.EnableAskAnotherUser() { + c.AbortWithError(http.StatusForbidden, errors.New("the AskAnotherUser feature is disabled")) + return + } + + var data conversations.AskUserResponse + if err := c.ShouldBindJSON(&data); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + + // Detach: the answer resumes the conversation with an async LLM follow-up + // stream that must outlive this request (see telemetry.DetachContext). + status, err := a.conversationsService.HandleAskUserResponse(telemetry.DetachContext(c.Request.Context()), userID, post, channel, data) + if err != nil { + c.AbortWithError(askUserResponseHTTPStatus(err), err) + return + } + + c.JSON(http.StatusOK, map[string]string{"status": status}) +} + +// askUserResponseHTTPStatus maps HandleAskUserResponse errors to HTTP +// statuses, mirroring toolApprovalHTTPStatus. +func askUserResponseHTTPStatus(err error) int { + switch { + case errors.Is(err, conversations.ErrInvalidAskAnswer): + return http.StatusBadRequest + case errors.Is(err, conversations.ErrNotAskTarget): + return http.StatusForbidden + case errors.Is(err, conversations.ErrAskConversationGone): + return http.StatusNotFound + case errors.Is(err, conversations.ErrAskNotPending): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + +// askUserCancelToolUseIDMaxLen bounds the cancel body's tool_use_id. It is a +// provider-issued id, not a Mattermost id, so there is no isValidId check — +// just a sanity cap (V2-C4). +const askUserCancelToolUseIDMaxLen = 128 + +// handleAskUserCancel lets the conversation initiator cancel an outstanding +// ask-another-user question on their anchor post (V2-C4). Unlike +// handleAskUserResponse it IS audited (a state-changing human decision, like +// its tool_call/tool_result siblings) and initiator-gated; like the answer +// endpoint it has no EnableChannelMentionToolCalling gate — cancel discloses +// nothing and must be able to unstick channel conversations. +func (a *API) handleAskUserCancel(c *gin.Context) { + userID := c.GetHeader("Mattermost-User-Id") + post := c.MustGet(ContextPostKey).(*model.Post) + channel := c.MustGet(ContextChannelKey).(*model.Channel) + + // Enrich the audit record as soon as the objects are bound so the + // permission fail paths below still carry post and channel. + rec := auditRec(c) + audit.AddParam(rec, audit.KeyPostID, post.Id) + audit.AddParam(rec, audit.KeyChannelID, channel.Id) + + // F1 master switch (V2-C1): cancel refuses while the feature is off, + // exactly like the answer endpoint. + if !a.config.EnableAskAnotherUser() { + c.AbortWithError(http.StatusForbidden, errors.New("the AskAnotherUser feature is disabled")) + return + } + + if !a.isConversationOwner(post, userID) { + c.AbortWithError(http.StatusForbidden, errors.New("only the conversation initiator can cancel the question")) + return + } + + var data struct { + ToolUseID string `json:"tool_use_id" binding:"required"` + } + if err := c.ShouldBindJSON(&data); err != nil { + c.AbortWithError(http.StatusBadRequest, err) + return + } + if len(data.ToolUseID) > askUserCancelToolUseIDMaxLen { + c.AbortWithError(http.StatusBadRequest, errors.New("tool_use_id exceeds the maximum length")) + return + } + + // Opaque block ID only — never question text or target identity. + audit.AddParam(rec, "tool_use_id", audit.TruncateIDs([]string{data.ToolUseID})) + + // Detach: the cancel resumes the conversation with an async LLM + // follow-up stream that must outlive this request (see + // telemetry.DetachContext). DetachContext keeps only the trace span, so + // the audit record is re-attached: the service's KeyAgentID enrichment + // runs synchronously before the middleware's deferred emit (V2-C10). + ctx := audit.WithRecord(telemetry.DetachContext(c.Request.Context()), rec) + if err := a.conversationsService.HandleAskUserCancel(ctx, userID, post, channel, data.ToolUseID); err != nil { + c.AbortWithError(askUserCancelHTTPStatus(err), err) + return + } + + c.JSON(http.StatusOK, map[string]string{"status": conversations.AskUserStatusCanceled}) +} + +// askUserCancelHTTPStatus maps HandleAskUserCancel errors to HTTP statuses, +// mirroring askUserResponseHTTPStatus (V2-C4). +func askUserCancelHTTPStatus(err error) int { + switch { + case errors.Is(err, conversations.ErrPostMissingConversationID): + return http.StatusBadRequest + case errors.Is(err, conversations.ErrNotRequester): + return http.StatusForbidden + case errors.Is(err, conversations.ErrAskConversationGone): + return http.StatusNotFound + case errors.Is(err, conversations.ErrAskNotPending): + return http.StatusConflict + default: + return http.StatusInternalServerError + } +} + // isConversationOwner checks whether the given user is the owner of the // conversation associated with the post (via the conversation_id prop). // diff --git a/api/api_post_test.go b/api/api_post_test.go index 3089fddb0..081d7c976 100644 --- a/api/api_post_test.go +++ b/api/api_post_test.go @@ -5,7 +5,9 @@ package api import ( "context" + "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -14,13 +16,17 @@ import ( "github.com/gin-gonic/gin" "github.com/mattermost/mattermost-plugin-agents/v2/audit" + "github.com/mattermost/mattermost-plugin-agents/v2/conversation" + "github.com/mattermost/mattermost-plugin-agents/v2/conversations" "github.com/mattermost/mattermost-plugin-agents/v2/enterprise" "github.com/mattermost/mattermost-plugin-agents/v2/llm" + mmapimocks "github.com/mattermost/mattermost-plugin-agents/v2/mmapi/mocks" "github.com/mattermost/mattermost-plugin-agents/v2/store" "github.com/mattermost/mattermost-plugin-agents/v2/streaming" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -278,6 +284,584 @@ func TestHandleStopLogsClusterPublishErrors(t *testing.T) { require.True(t, foundLog, "cluster publish failures must be logged so operators can diagnose dropped peer-cancels") } +// TestAskUserResponseHTTPStatus pins the sentinel-error → HTTP status mapping +// of the ask_user_response endpoint (C5). The behavior behind each sentinel is +// covered by the conversations-layer HandleAskUserResponse table. +func TestAskUserResponseHTTPStatus(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {name: "invalid answer", err: conversations.ErrInvalidAskAnswer, want: http.StatusBadRequest}, + {name: "wrapped invalid answer", err: fmt.Errorf("%w: no option selected", conversations.ErrInvalidAskAnswer), want: http.StatusBadRequest}, + {name: "not the asked target", err: conversations.ErrNotAskTarget, want: http.StatusForbidden}, + {name: "conversation gone", err: conversations.ErrAskConversationGone, want: http.StatusNotFound}, + {name: "already answered", err: conversations.ErrAskNotPending, want: http.StatusConflict}, + {name: "unexpected error", err: errors.New("db down"), want: http.StatusInternalServerError}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, askUserResponseHTTPStatus(tc.err)) + }) + } +} + +// TestHandleAskUserResponse drives POST /post/{postid}/ask_user_response +// through the full router: session auth, JSON binding, the conversations-layer +// target check, and the C5 status mapping all fire exactly as a real client +// would see them. The happy-path rows record the answer and write the C6/C7 +// tool_result turn; the follow-up stream itself is covered by the +// conversations-layer tests (here the anchor turn has no post, so the resume +// is skipped after the answer is recorded). +func TestHandleAskUserResponse(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + gin.DefaultWriter = io.Discard + + const ( + cardPostID = "card12345678901234567890ab" + dmChannelID = "dmch12345678901234567890ab" + convID = "conv12345678901234567890ab" + toolUseID = "ask-tool-use-1" + ) + + makeBlocks := func(t *testing.T, status string) json.RawMessage { + t.Helper() + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: toolUseID, + Name: "AskAnotherUser", + Input: json.RawMessage(`{"username":"target","question":"Which environment?"}`), + Status: status, + DeferredResult: true, + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + return content + } + + tests := []struct { + name string + omitSession bool + body string + notACard bool + targetID string // defaults to testUserID (the caller) + seedConv bool + blockStatus string // seeds an assistant turn when non-empty + seedResultJSON string + expectedStatus int + expectedBody string + expectResultTurn bool + }{ + { + name: "missing session header is unauthorized", + omitSession: true, + body: `{"action":"decline"}`, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "malformed body is a bad request", + body: `{`, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "non-target caller is forbidden", + body: `{"action":"answer","free_form":"hi"}`, + targetID: testOtherUserID, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusForbidden, + }, + { + name: "unknown action is a bad request", + body: `{"action":"maybe"}`, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "non-card post is a bad request", + body: `{"action":"decline"}`, + notACard: true, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "missing conversation is not found", + body: `{"action":"decline"}`, + expectedStatus: http.StatusNotFound, + }, + { + name: "already answered question conflicts", + body: `{"action":"decline"}`, + seedConv: true, + blockStatus: conversation.StatusSuccess, + seedResultJSON: `{"status":"answered","target_username":"target-user","selected":[],"free_form":"done"}`, + expectedStatus: http.StatusConflict, + }, + { + name: "late answer after cancel is a successful no-op", + body: `{"action":"answer","free_form":"too late"}`, + seedConv: true, + blockStatus: conversation.StatusSuccess, + seedResultJSON: `{"status":"canceled","target_username":"target-user"}`, + expectedStatus: http.StatusOK, + expectedBody: `"status":"canceled"`, + }, + { + name: "decline records the refusal", + body: `{"action":"decline"}`, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusOK, + expectedBody: `"status":"declined"`, + expectResultTurn: true, + }, + { + name: "answer records the response", + body: `{"action":"answer","free_form":"Use staging"}`, + seedConv: true, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusOK, + expectedBody: `"status":"answered"`, + expectResultTurn: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := SetupTestEnvironment(t) + defer e.Cleanup(t) + // The endpoint 403s while the master toggle is off + // (TestHandleAskUserResponseDisabled); these rows exercise the + // enabled behavior. + e.config.enableAskAnotherUser = true + + e.setupTestBot(llm.BotConfig{Name: "thebot", DisplayName: "The Bot"}) + + cardPost := &model.Post{ + Id: cardPostID, + UserId: testBotUserID, + ChannelId: dmChannelID, + Type: conversations.AskUserPostType, + } + if test.notACard { + cardPost.Type = "" + } + target := testUserID + if test.targetID != "" { + target = test.targetID + } + cardPost.AddProp(conversations.AskUserTargetIDProp, target) + cardPost.AddProp(conversations.AskUserConversationIDProp, convID) + cardPost.AddProp(conversations.AskUserToolUseIDProp, toolUseID) + + // Rebuild the conversations service around a mock mmapi client and + // an in-memory conversation store so the handler runs for real. + mmClient := mmapimocks.NewMockClient(t) + for i := 1; i <= 7; i++ { + args := make([]interface{}, i) + for j := range args { + args[j] = mock.Anything + } + mmClient.On("LogError", args...).Maybe().Return() + mmClient.On("LogDebug", args...).Maybe().Return() + } + mmClient.On("GetUser", testUserID).Maybe().Return(&model.User{Id: testUserID, Username: "target-user"}, nil) + mmClient.On("GetPost", cardPostID).Maybe().Return(cardPost, nil) + mmClient.On("UpdatePost", mock.AnythingOfType("*model.Post")).Maybe().Return(nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + + convStore := newMockConvServiceStore() + svc := conversations.New(nil, mmClient, nil, nil, e.bots, nil, nil, nil, nil, e.config) + svc.SetConversationService(conversation.NewService(convStore, nil, nil, e.bots)) + e.api.conversationsService = svc + + if test.seedConv { + require.NoError(t, convStore.CreateConversation(&store.Conversation{ + ID: convID, + UserID: testOtherUserID, // conversation initiator, not the asked target + BotID: testBotUserID, + })) + } + if test.blockStatus != "" { + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: convID, + Role: "assistant", + Content: makeBlocks(t, test.blockStatus), + Sequence: 1, + })) + } + if test.seedResultJSON != "" { + resultContent, marshalErr := json.Marshal([]conversation.ContentBlock{{ + Type: conversation.BlockTypeToolResult, + ToolUseID: toolUseID, + Content: test.seedResultJSON, + Status: conversation.StatusSuccess, + }}) + require.NoError(t, marshalErr) + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "seeded-result-turn", + ConversationID: convID, + Role: "tool_result", + Content: resultContent, + Sequence: 2, + })) + } + + e.mockAPI.On("GetPost", cardPostID).Return(cardPost, nil).Maybe() + e.mockAPI.On("GetChannel", dmChannelID).Return(&model.Channel{ + Id: dmChannelID, + Name: testBotUserID + "__" + testUserID, + Type: model.ChannelTypeDirect, + }, nil).Maybe() + e.mockAPI.On("HasPermissionToChannel", testUserID, dmChannelID, model.PermissionReadChannel).Return(true).Maybe() + + req := httptest.NewRequest(http.MethodPost, "/post/"+cardPostID+"/ask_user_response", strings.NewReader(test.body)) + if !test.omitSession { + req.Header.Add("Mattermost-User-ID", testUserID) + } + + rec := httptest.NewRecorder() + e.api.ServeHTTP(&plugin.Context{}, rec, req) + resp := rec.Result() + require.Equal(t, test.expectedStatus, resp.StatusCode) + + if test.expectedBody != "" { + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(bodyBytes), test.expectedBody) + } + + turns := convStore.turns[convID] + if test.expectResultTurn { + require.Len(t, turns, 2, "an accepted answer must append the tool_result turn") + require.Equal(t, "tool_result", turns[1].Role) + } else if test.blockStatus != "" { + expectedTurns := 1 + if test.seedResultJSON != "" { + expectedTurns++ + } + require.Len(t, turns, expectedTurns, "terminal requests must not write another result turn") + } + }) + } +} + +// TestHandleAskUserResponseDisabled pins the F1 master gate on the answer +// endpoint (V2-C1): while the toggle is off the endpoint refuses with 403 +// BEFORE the body is even bound — a malformed body that would otherwise be a +// 400 still gets the 403. +func TestHandleAskUserResponseDisabled(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + gin.DefaultWriter = io.Discard + + const ( + cardPostID = "card12345678901234567890ab" + dmChannelID = "dmch12345678901234567890ab" + ) + + tests := []struct { + name string + body string + }{ + {name: "valid body is refused", body: `{"action":"decline"}`}, + {name: "malformed body is refused before binding", body: `{`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := SetupTestEnvironment(t) + defer e.Cleanup(t) + // SetupTestEnvironment leaves enableAskAnotherUser at its + // default: false. + + e.setupTestBot(llm.BotConfig{Name: "thebot", DisplayName: "The Bot"}) + + cardPost := &model.Post{ + Id: cardPostID, + UserId: testBotUserID, + ChannelId: dmChannelID, + Type: conversations.AskUserPostType, + } + cardPost.AddProp(conversations.AskUserTargetIDProp, testUserID) + + e.mockAPI.On("GetPost", cardPostID).Return(cardPost, nil).Maybe() + e.mockAPI.On("GetChannel", dmChannelID).Return(&model.Channel{ + Id: dmChannelID, + Name: testBotUserID + "__" + testUserID, + Type: model.ChannelTypeDirect, + }, nil).Maybe() + e.mockAPI.On("HasPermissionToChannel", testUserID, dmChannelID, model.PermissionReadChannel).Return(true).Maybe() + + req := httptest.NewRequest(http.MethodPost, "/post/"+cardPostID+"/ask_user_response", strings.NewReader(test.body)) + req.Header.Add("Mattermost-User-ID", testUserID) + + rec := httptest.NewRecorder() + e.api.ServeHTTP(&plugin.Context{}, rec, req) + require.Equal(t, http.StatusForbidden, rec.Result().StatusCode) + }) + } +} + +// TestAskUserCancelHTTPStatus pins the sentinel-error → HTTP status mapping +// of the ask_user_cancel endpoint (V2-C4). The behavior behind each sentinel +// is covered by the conversations-layer HandleAskUserCancel table. +func TestAskUserCancelHTTPStatus(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {name: "post missing conversation reference", err: conversations.ErrPostMissingConversationID, want: http.StatusBadRequest}, + {name: "not the initiator", err: conversations.ErrNotRequester, want: http.StatusForbidden}, + {name: "wrapped not the initiator", err: fmt.Errorf("checked: %w", conversations.ErrNotRequester), want: http.StatusForbidden}, + {name: "conversation gone", err: conversations.ErrAskConversationGone, want: http.StatusNotFound}, + {name: "already resolved", err: conversations.ErrAskNotPending, want: http.StatusConflict}, + {name: "unexpected error", err: errors.New("db down"), want: http.StatusInternalServerError}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, askUserCancelHTTPStatus(tc.err)) + }) + } +} + +// TestHandleAskUserCancelHTTP drives POST /post/{postid}/ask_user_cancel +// through the full router: session auth, the V2-C1 master gate, the +// initiator-only gate, JSON binding, the conversations-layer resolution, the +// V2-C4 status mapping, and the V2-C10 audit record. The rows that reach the +// service assert the canceled tool_result turn; the card patch and follow-up +// stream are covered by the conversations-layer tables. +func TestHandleAskUserCancelHTTP(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + gin.DefaultWriter = io.Discard + + const ( + anchorPostID = "anch12345678901234567890ab" + channelID = "chan12345678901234567890ab" + convID = "conv12345678901234567890ab" + toolUseID = "ask-tool-use-1" + ) + + tests := []struct { + name string + omitSession bool + toggleOff bool + conversationOwner string // defaults to testUserID (the caller) + body string + blockStatus string // seeds an assistant turn when non-empty + expectedStatus int + expectResultTurn bool + expectAuditToolID bool + expectAgentID bool + }{ + { + name: "missing session header is unauthorized", + omitSession: true, + body: `{"tool_use_id":"` + toolUseID + `"}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "master toggle off is forbidden", + toggleOff: true, + body: `{"tool_use_id":"` + toolUseID + `"}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusForbidden, + }, + { + name: "non-initiator is forbidden", + conversationOwner: testOtherUserID, + body: `{"tool_use_id":"` + toolUseID + `"}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusForbidden, + }, + { + name: "malformed body is a bad request", + body: `{`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "missing tool_use_id is a bad request", + body: `{}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "oversized tool_use_id is a bad request", + body: `{"tool_use_id":"` + strings.Repeat("x", 129) + `"}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusBadRequest, + }, + { + name: "already resolved question conflicts", + body: `{"tool_use_id":"` + toolUseID + `"}`, + blockStatus: conversation.StatusSuccess, + expectedStatus: http.StatusConflict, + expectAuditToolID: true, + expectAgentID: true, + }, + { + name: "cancel resolves the waiting question", + body: `{"tool_use_id":"` + toolUseID + `"}`, + blockStatus: conversation.StatusWaiting, + expectedStatus: http.StatusOK, + expectResultTurn: true, + expectAuditToolID: true, + expectAgentID: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := SetupTestEnvironment(t) + defer e.Cleanup(t) + e.config.enableAskAnotherUser = !test.toggleOff + records := e.CaptureAuditRecords() + + e.setupTestBot(llm.BotConfig{Name: "thebot", DisplayName: "The Bot"}) + + anchorPost := &model.Post{ + Id: anchorPostID, + UserId: testBotUserID, + ChannelId: channelID, + } + anchorPost.AddProp(streaming.ConversationIDProp, convID) + + owner := testUserID + if test.conversationOwner != "" { + owner = test.conversationOwner + } + // The API-layer owner gate reads the env's conversation store; + // the service layer below re-checks against its own. + e.conversationStore.conversations[convID] = &store.Conversation{ + ID: convID, + UserID: owner, + BotID: testBotUserID, + } + + mmClient := mmapimocks.NewMockClient(t) + for i := 1; i <= 7; i++ { + args := make([]interface{}, i) + for j := range args { + args[j] = mock.Anything + } + mmClient.On("LogError", args...).Maybe().Return() + mmClient.On("LogWarn", args...).Maybe().Return() + mmClient.On("LogDebug", args...).Maybe().Return() + } + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + // No card pointer: the patch degrades to unpatched (its own + // conversations-layer row); the resume degrades the same way + // because the anchor post is unreadable through this mock. + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + mmClient.On("PublishWebSocketEvent", mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + + convStore := newMockConvServiceStore() + svc := conversations.New(nil, mmClient, nil, nil, e.bots, nil, nil, nil, nil, e.config) + svc.SetConversationService(conversation.NewService(convStore, nil, nil, e.bots)) + e.api.conversationsService = svc + + require.NoError(t, convStore.CreateConversation(&store.Conversation{ + ID: convID, + UserID: owner, + BotID: testBotUserID, + })) + if test.blockStatus != "" { + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: toolUseID, + Name: "AskAnotherUser", + Input: json.RawMessage(`{"username":"target","question":"Which environment?"}`), + Status: test.blockStatus, + DeferredResult: true, + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + postID := anchorPostID + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: convID, + PostID: &postID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + } + + e.mockAPI.On("GetPost", anchorPostID).Return(anchorPost, nil).Maybe() + e.mockAPI.On("GetChannel", channelID).Return(&model.Channel{ + Id: channelID, + Name: testBotUserID + "__" + testUserID, + Type: model.ChannelTypeDirect, + }, nil).Maybe() + e.mockAPI.On("HasPermissionToChannel", testUserID, channelID, model.PermissionReadChannel).Return(true).Maybe() + + req := httptest.NewRequest(http.MethodPost, "/post/"+anchorPostID+"/ask_user_cancel", strings.NewReader(test.body)) + if !test.omitSession { + req.Header.Add("Mattermost-User-ID", testUserID) + } + + rec := httptest.NewRecorder() + e.api.ServeHTTP(&plugin.Context{}, rec, req) + resp := rec.Result() + require.Equal(t, test.expectedStatus, resp.StatusCode) + + turns := convStore.turns[convID] + if test.expectResultTurn { + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(bodyBytes), `"status":"canceled"`) + + require.Len(t, turns, 2, "an accepted cancel must append the tool_result turn") + require.Equal(t, "tool_result", turns[1].Role) + var resultBlocks []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[1].Content, &resultBlocks)) + require.Len(t, resultBlocks, 1) + assert.JSONEq(t, `{"status":"canceled","target_username":"target","answer_received":false,"canceled_by":"requester"}`, resultBlocks[0].Content) + } else if test.blockStatus != "" { + require.Len(t, turns, 1, "rejected requests must not write result turns") + } + + // V2-C10: every routed request emits exactly one audit record + // carrying the decision objects — never question text or target + // identity. + require.Len(t, *records, 1, "exactly one audit record must be emitted") + auditRec := (*records)[0] + assert.Equal(t, AuditEventAskUserCancel, auditRec.EventName) + if test.omitSession { + assert.Empty(t, auditRec.Actor.UserId) + } else { + assert.Equal(t, testUserID, auditRec.Actor.UserId) + assert.Equal(t, anchorPostID, auditRec.EventData.Parameters[audit.KeyPostID]) + assert.Equal(t, channelID, auditRec.EventData.Parameters[audit.KeyChannelID]) + } + if test.expectAuditToolID { + assert.Equal(t, []string{toolUseID}, auditRec.EventData.Parameters["tool_use_id"]) + } + if test.expectAgentID { + assert.Equal(t, testBotUserID, auditRec.EventData.Parameters[audit.KeyAgentID], + "service-layer enrichment must reach the same record via the request context") + } + if test.expectedStatus == http.StatusOK { + assert.Equal(t, model.AuditStatusSuccess, auditRec.Status) + } else { + assert.Equal(t, model.AuditStatusFail, auditRec.Status) + assert.Equal(t, test.expectedStatus, auditRec.Error.Code) + } + }) + } +} + // TestToolApprovalAuditRecords proves the tool approval endpoints enrich the // middleware-created audit record with the objects of the human decision: the // approval post, its channel, the accepted tool-use block IDs, and — once the diff --git a/api/api_test.go b/api/api_test.go index c3afa025f..a47b21aeb 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -61,6 +61,7 @@ type TestEnvironment struct { type testConfigImpl struct { allowUnsafeLinks bool enableChannelMentionToolCalling bool + enableAskAnotherUser bool mcpConfig mcp.Config } @@ -84,6 +85,10 @@ func (tc *testConfigImpl) EnableChannelMentionToolCalling() bool { return tc.enableChannelMentionToolCalling } +func (tc *testConfigImpl) EnableAskAnotherUser() bool { + return tc.enableAskAnotherUser +} + func (tc *testConfigImpl) AllowNativeWebSearchInChannels() bool { return false } diff --git a/api/audit_events.go b/api/audit_events.go index 3303fe3ab..6834f62ad 100644 --- a/api/audit_events.go +++ b/api/audit_events.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" ) -// Audit event names for every state-changing operation in the plugin: 21 +// Audit event names for every state-changing operation in the plugin: 22 // routed events plus the non-gin MCP session grant. All are declared here, // including ones whose instrumentation lands in later changes, so call sites // never use inline string literals and parallel work never edits this file. @@ -54,6 +54,12 @@ const ( AuditEventToolCallApproval = "toolCallApproval" AuditEventToolResultApproval = "toolResultApproval" + // Ask-another-user cancel: the initiator's decision to resolve an + // outstanding question without an answer. The target's answer endpoint + // (handleAskUserResponse) is deliberately NOT audited — the answer body + // is user content. + AuditEventAskUserCancel = "askUserCancel" + // MCP session grant: an external MCP client obtained a dedicated session // holding API access as the user. Not a gin route — emitted directly by // delegateToMCPHandler when a session is newly created, so it has no @@ -113,5 +119,8 @@ func buildAuditEventRegistry(a *API) map[string]string { // Tool approval. handlerFuncName(a.handleToolCall): AuditEventToolCallApproval, handlerFuncName(a.handleToolResult): AuditEventToolResultApproval, + + // Ask-another-user cancel. + handlerFuncName(a.handleAskUserCancel): AuditEventAskUserCancel, } } diff --git a/api/audit_middleware_test.go b/api/audit_middleware_test.go index 6309b9a5f..819249483 100644 --- a/api/audit_middleware_test.go +++ b/api/audit_middleware_test.go @@ -249,6 +249,7 @@ func TestAuditRegistryAllRoutesEmit(t *testing.T) { {event: AuditEventUnregisterMCPPluginServer, method: http.MethodPost, path: "/bridge/v1/mcp/unregister", bridge: true}, {event: AuditEventToolCallApproval, method: http.MethodPost, path: "/post/postid/tool_call"}, {event: AuditEventToolResultApproval, method: http.MethodPost, path: "/post/postid/tool_result"}, + {event: AuditEventAskUserCancel, method: http.MethodPost, path: "/post/postid/ask_user_cancel"}, } // One case per registry row, no more and no fewer (the session grant is diff --git a/config/config.go b/config/config.go index 84da94253..419e9e703 100644 --- a/config/config.go +++ b/config/config.go @@ -31,6 +31,7 @@ type Config struct { AllowedUpstreamHostnames string `json:"allowedUpstreamHostnames"` AllowUnsafeLinks bool `json:"allowUnsafeLinks"` EnableChannelMentionToolCalling bool `json:"enableChannelMentionToolCalling"` + EnableAskAnotherUser bool `json:"enableAskAnotherUser"` AllowNativeWebSearchInChannels bool `json:"allowNativeWebSearchInChannels"` EmbeddingSearchConfig embeddings.EmbeddingSearchConfig `json:"embeddingSearchConfig"` MCP MCPConfig `json:"mcp"` @@ -172,6 +173,18 @@ func (c *Container) EnableChannelMentionToolCalling() bool { return cfg.EnableChannelMentionToolCalling } +// EnableAskAnotherUser reports whether the experimental AskAnotherUser tool +// is enabled (V2-C1). Default false: the tool is not registered and its +// answer/cancel endpoints refuse while off. +func (c *Container) EnableAskAnotherUser() bool { + cfg := c.cfg.Load() + if cfg == nil { + return false + } + + return cfg.EnableAskAnotherUser +} + func (c *Container) AllowNativeWebSearchInChannels() bool { cfg := c.cfg.Load() if cfg == nil { diff --git a/config/mcp_config.go b/config/mcp_config.go index ef2fa3ffb..ff46b69cc 100644 --- a/config/mcp_config.go +++ b/config/mcp_config.go @@ -44,6 +44,10 @@ type MCPConfig struct { PluginServers []PluginServerConfig `json:"plugin_servers,omitempty"` EmbeddedServer MCPEmbeddedServerConfig `json:"embeddedServer"` IdleTimeoutMinutes int `json:"idleTimeoutMinutes"` + // BuiltInTools holds admin policy overrides for built-in (non-MCP) tools + // such as AskAnotherUser and WebSearch. Entries merge over + // mcp.SeedBuiltInToolConfigs; unlisted tools default to (ask, enabled). + BuiltInTools []MCPToolConfig `json:"builtInTools,omitempty"` } // MCPServerConfig contains the configuration for a single MCP server diff --git a/conversation/approval_state.go b/conversation/approval_state.go index b59da3b5a..2695ac16b 100644 --- a/conversation/approval_state.go +++ b/conversation/approval_state.go @@ -72,6 +72,9 @@ func ComputePostApprovalState(turns []store.Turn, postID string) string { pendingToolUse = true case StatusSuccess, StatusError, StatusAutoApproved: executedToolUseIDs[b.ID] = struct{}{} + case StatusWaiting: + // Deferred call awaiting an out-of-band answer: no Accept/Reject + // controls (not pending) and no result to share (not executed). } } } diff --git a/conversation/approval_state_test.go b/conversation/approval_state_test.go index 168a5432d..2b74feda2 100644 --- a/conversation/approval_state_test.go +++ b/conversation/approval_state_test.go @@ -160,3 +160,76 @@ func TestComputePostApprovalState(t *testing.T) { }) } } + +// TestComputePostApprovalStateWaiting pins that a waiting (deferred) tool_use +// block counts as neither pending nor executed: alone it computes done so the +// webapp shows no Accept/Reject controls, and it never forces a result stage. +func TestComputePostApprovalStateWaiting(t *testing.T) { + tests := []struct { + name string + postID string + turns []store.Turn + want string + }{ + { + name: "waiting-only turn returns done", + postID: "p1", + turns: []store.Turn{ + {Role: "user", Sequence: 1, Content: blockJSON(t, nil)}, + {Role: "assistant", Sequence: 2, PostID: postPtr("p1"), Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolUse, ID: "tc_wait", Name: "AskAnotherUser", Status: StatusWaiting, DeferredResult: true}, + })}, + }, + want: ApprovalStageDone, + }, + { + name: "waiting plus pending returns call", + postID: "p1", + turns: []store.Turn{ + {Role: "user", Sequence: 1, Content: blockJSON(t, nil)}, + {Role: "assistant", Sequence: 2, PostID: postPtr("p1"), Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolUse, ID: "tc_wait", Name: "AskAnotherUser", Status: StatusWaiting, DeferredResult: true}, + {Type: BlockTypeToolUse, ID: "tc_pending", Name: "x", Status: StatusPending}, + })}, + }, + want: ApprovalStageCall, + }, + { + name: "waiting plus executed with undecided result returns result", + postID: "p1", + turns: []store.Turn{ + {Role: "user", Sequence: 1, Content: blockJSON(t, nil)}, + {Role: "assistant", Sequence: 2, PostID: postPtr("p1"), Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolUse, ID: "tc_wait", Name: "AskAnotherUser", Status: StatusWaiting, DeferredResult: true}, + {Type: BlockTypeToolUse, ID: "tc_done", Name: "x", Status: StatusSuccess}, + })}, + {Role: "tool_result", Sequence: 3, Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolResult, ToolUseID: "tc_done", Status: StatusSuccess}, + })}, + }, + want: ApprovalStageResult, + }, + { + name: "waiting plus executed with decided result returns done", + postID: "p1", + turns: []store.Turn{ + {Role: "user", Sequence: 1, Content: blockJSON(t, nil)}, + {Role: "assistant", Sequence: 2, PostID: postPtr("p1"), Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolUse, ID: "tc_wait", Name: "AskAnotherUser", Status: StatusWaiting, DeferredResult: true}, + {Type: BlockTypeToolUse, ID: "tc_done", Name: "x", Status: StatusSuccess}, + })}, + {Role: "tool_result", Sequence: 3, Content: blockJSON(t, []ContentBlock{ + {Type: BlockTypeToolResult, ToolUseID: "tc_done", Status: StatusSuccess, DecidedAt: Int64Ptr(1000)}, + })}, + }, + want: ApprovalStageDone, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ComputePostApprovalState(tc.turns, tc.postID) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/conversation/content_block.go b/conversation/content_block.go index 4db6c65a3..372d9ac07 100644 --- a/conversation/content_block.go +++ b/conversation/content_block.go @@ -34,6 +34,7 @@ const ( StatusError = "error" StatusSuccess = "success" StatusAutoApproved = "auto_approved" + StatusWaiting = "waiting" ) // ContentBlock is a flat struct representing any content block type. @@ -63,6 +64,9 @@ type ContentBlock struct { // auto-execution policy (see llm.ToolCall.WouldAutoExecute). WouldAutoExecute bool `json:"would_auto_execute,omitempty"` + // DeferredResult is the persisted form of llm.ToolCall.DeferredResult. + DeferredResult bool `json:"deferred_result,omitempty"` + // DecidedAt (tool_result blocks) records when the share/keep-private // decision was made — either by the user clicking Share or Keep Private // in a channel, or implicitly at creation time (DMs, rejected tools, diff --git a/conversation/convert.go b/conversation/convert.go index e3c6e95a9..c4957875a 100644 --- a/conversation/convert.go +++ b/conversation/convert.go @@ -81,12 +81,13 @@ func BlocksToPost( arguments = unsharedToolUseArgumentsRedaction } toolCall := llm.ToolCall{ - ID: block.ID, - Name: block.Name, - ServerOrigin: block.ServerOrigin, - Arguments: arguments, - MCPBareName: block.MCPBareName, - Status: StatusFromString(block.Status), + ID: block.ID, + Name: block.Name, + ServerOrigin: block.ServerOrigin, + Arguments: arguments, + MCPBareName: block.MCPBareName, + Status: StatusFromString(block.Status), + DeferredResult: block.DeferredResult, } if redactToolUse { toolCall.MCPBareName = "" @@ -260,14 +261,15 @@ func PostToBlocks(post llm.Post, shared bool) []ContentBlock { // 3. For each ToolUse: a tool_use block, optionally followed by a tool_result block for _, tc := range post.ToolUse { blocks = append(blocks, ContentBlock{ - Type: BlockTypeToolUse, - ID: tc.ID, - Name: tc.Name, - ServerOrigin: tc.ServerOrigin, - Input: tc.Arguments, - MCPBareName: tc.MCPBareName, - Status: StatusToString(tc.Status), - Shared: BoolPtr(shared), + Type: BlockTypeToolUse, + ID: tc.ID, + Name: tc.Name, + ServerOrigin: tc.ServerOrigin, + Input: tc.Arguments, + MCPBareName: tc.MCPBareName, + Status: StatusToString(tc.Status), + Shared: BoolPtr(shared), + DeferredResult: tc.DeferredResult, }) if tc.Result != "" { @@ -329,6 +331,8 @@ func StatusFromString(s string) llm.ToolCallStatus { return llm.ToolCallStatusSuccess case StatusAutoApproved: return llm.ToolCallStatusAutoApproved + case StatusWaiting: + return llm.ToolCallStatusWaiting default: return llm.ToolCallStatusPending } @@ -349,6 +353,8 @@ func StatusToString(s llm.ToolCallStatus) string { return StatusSuccess case llm.ToolCallStatusAutoApproved: return StatusAutoApproved + case llm.ToolCallStatusWaiting: + return StatusWaiting default: return StatusPending } diff --git a/conversation/convert_test.go b/conversation/convert_test.go index a99560973..2f64e4844 100644 --- a/conversation/convert_test.go +++ b/conversation/convert_test.go @@ -435,6 +435,7 @@ func TestStatusConversion(t *testing.T) { {StatusError, llm.ToolCallStatusError}, {StatusSuccess, llm.ToolCallStatusSuccess}, {StatusAutoApproved, llm.ToolCallStatusAutoApproved}, + {StatusWaiting, llm.ToolCallStatusWaiting}, } for _, tt := range tests { @@ -445,6 +446,15 @@ func TestStatusConversion(t *testing.T) { } } +// TestStatusRoundTripWaiting pins that a persisted waiting block never +// collapses back to pending on re-persist (which would resurrect the +// Accept/Reject UI and break the ErrStaleToolClick idempotency guarantee), +// and that the enum value the webapp mirrors stays 6. +func TestStatusRoundTripWaiting(t *testing.T) { + assert.Equal(t, StatusWaiting, StatusToString(StatusFromString(StatusWaiting))) + assert.Equal(t, llm.ToolCallStatus(6), llm.ToolCallStatusWaiting) +} + func TestStatusFromStringDefault(t *testing.T) { assert.Equal(t, llm.ToolCallStatusPending, StatusFromString("bogus_status")) } @@ -546,6 +556,56 @@ func TestPostToBlocksToPostRoundTrip(t *testing.T) { } } +func TestBlocksToPostCarriesWaitingAndDeferred(t *testing.T) { + blocks := []ContentBlock{{ + Type: BlockTypeToolUse, + ID: "tc1", + Name: "AskAnotherUser", + Input: json.RawMessage(`{"username":"bob","question":"which release?"}`), + Status: StatusWaiting, + DeferredResult: true, + }} + + t.Run("waiting status and deferred flag survive conversion", func(t *testing.T) { + post := BlocksToPost(blocks, "assistant", PostConversionOptions{}) + require.Len(t, post.ToolUse, 1) + assert.Equal(t, llm.ToolCallStatusWaiting, post.ToolUse[0].Status) + assert.True(t, post.ToolUse[0].DeferredResult) + assert.JSONEq(t, `{"username":"bob","question":"which release?"}`, string(post.ToolUse[0].Arguments)) + }) + + t.Run("redaction blanks arguments but keeps status and flag", func(t *testing.T) { + post := BlocksToPost(blocks, "assistant", PostConversionOptions{RedactUnshared: true}) + require.Len(t, post.ToolUse, 1) + assert.Equal(t, llm.ToolCallStatusWaiting, post.ToolUse[0].Status) + assert.True(t, post.ToolUse[0].DeferredResult) + assert.JSONEq(t, `{}`, string(post.ToolUse[0].Arguments)) + }) +} + +func TestPostToBlocksPersistsDeferredResult(t *testing.T) { + post := llm.Post{ + Role: llm.PostRoleBot, + ToolUse: []llm.ToolCall{{ + ID: "tc1", + Name: "AskAnotherUser", + Arguments: json.RawMessage(`{"username":"bob"}`), + Status: llm.ToolCallStatusWaiting, + DeferredResult: true, + }}, + } + + blocks := PostToBlocks(post, false) + require.Len(t, blocks, 1) + assert.Equal(t, StatusWaiting, blocks[0].Status) + assert.True(t, blocks[0].DeferredResult) + + roundTripped := BlocksToPost(blocks, "assistant", PostConversionOptions{}) + require.Len(t, roundTripped.ToolUse, 1) + assert.Equal(t, llm.ToolCallStatusWaiting, roundTripped.ToolUse[0].Status) + assert.True(t, roundTripped.ToolUse[0].DeferredResult) +} + // fakeReadCloser wraps a strings.Reader as io.ReadCloser so the mock GetFile // return type matches mmapi.Client.GetFile. type fakeReadCloser struct { diff --git a/conversation/service_test.go b/conversation/service_test.go index 1e64a958a..e26ff1b34 100644 --- a/conversation/service_test.go +++ b/conversation/service_test.go @@ -599,6 +599,72 @@ func TestBuildCompletionRequest_WithToolTurns(t *testing.T) { assert.Equal(t, "The weather is 72F and sunny.", req.Posts[3].Message) } +// TestBuildCompletionRequestWithWaitingBlock proves a waiting (deferred) +// tool_use block with no tool_result assembles into a request with exactly +// the same shape as the identical seed with a pending block — so a new +// completion mid-wait (initiator sends another message, regenerate) cannot +// break providers any differently than the long-standing pending case does. +func TestBuildCompletionRequestWithWaitingBlock(t *testing.T) { + svc, s := setupTestService(t) + + buildRequest := func(status string, deferred bool) *llm.CompletionRequest { + result, err := svc.CreateConversation(CreateConversationParams{ + UserID: model.NewId(), + BotID: model.NewId(), + Operation: "conversation", + SystemPrompt: "system", + UserMessage: "ask bob", + }) + require.NoError(t, err) + convID := result.ConversationID + + assistantBlocks := []ContentBlock{{ + Type: BlockTypeToolUse, + ID: "tc1", + Name: "AskAnotherUser", + Input: json.RawMessage(`{"username":"bob","question":"which release?"}`), + Status: status, + DeferredResult: deferred, + }} + assistantContent, err := json.Marshal(assistantBlocks) + require.NoError(t, err) + require.NoError(t, s.CreateTurn(&store.Turn{ + ID: model.NewId(), + ConversationID: convID, + Role: "assistant", + Content: assistantContent, + Sequence: 2, + CreatedAt: model.GetMillis(), + })) + + conv, err := s.GetConversation(convID) + require.NoError(t, err) + req, err := svc.BuildCompletionRequest(conv, &llm.Context{}) + require.NoError(t, err) + return req + } + + waitingReq := buildRequest(StatusWaiting, true) + pendingReq := buildRequest(StatusPending, false) + + require.Len(t, waitingReq.Posts, 3) + require.Len(t, waitingReq.Posts[2].ToolUse, 1) + assert.Equal(t, llm.ToolCallStatusWaiting, waitingReq.Posts[2].ToolUse[0].Status) + assert.True(t, waitingReq.Posts[2].ToolUse[0].DeferredResult) + assert.Empty(t, waitingReq.Posts[2].ToolUse[0].Result, "waiting call has no result yet") + + // Same request shape as the pending seed, modulo status and the deferred flag. + require.Len(t, pendingReq.Posts, len(waitingReq.Posts)) + for i := range pendingReq.Posts { + assert.Equal(t, pendingReq.Posts[i].Role, waitingReq.Posts[i].Role) + assert.Equal(t, pendingReq.Posts[i].Message, waitingReq.Posts[i].Message) + } + normalized := waitingReq.Posts[2].ToolUse[0] + normalized.Status = llm.ToolCallStatusPending + normalized.DeferredResult = false + assert.Equal(t, pendingReq.Posts[2].ToolUse[0], normalized) +} + func TestBuildCompletionRequest_StripsPersistedAssistantReasoning(t *testing.T) { svc, s := setupTestService(t) diff --git a/conversations/ask_another_user.go b/conversations/ask_another_user.go new file mode 100644 index 000000000..503615b8c --- /dev/null +++ b/conversations/ask_another_user.go @@ -0,0 +1,1041 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package conversations + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/mattermost/mattermost-plugin-agents/v2/audit" + "github.com/mattermost/mattermost-plugin-agents/v2/bots" + "github.com/mattermost/mattermost-plugin-agents/v2/conversation" + "github.com/mattermost/mattermost-plugin-agents/v2/i18n" + "github.com/mattermost/mattermost-plugin-agents/v2/llm" + "github.com/mattermost/mattermost-plugin-agents/v2/mmapi" + "github.com/mattermost/mattermost-plugin-agents/v2/mmtools" + "github.com/mattermost/mattermost-plugin-agents/v2/store" + "github.com/mattermost/mattermost-plugin-agents/v2/streaming" + "github.com/mattermost/mattermost-plugin-agents/v2/telemetry" + "github.com/mattermost/mattermost-plugin-agents/v2/toolrunner" + "github.com/mattermost/mattermost/server/public/model" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// AskUserPostType is the custom post type of the target-side question card. +const AskUserPostType = "custom_llm_ask_user" + +// Card post prop keys (C4). Mirrored as literals in the webapp. +const ( + AskUserStatusProp = "ask_user_status" + AskUserQuestionProp = "ask_user_question" + AskUserContextProp = "ask_user_context" + AskUserOptionsProp = "ask_user_options" + AskUserMultiSelectProp = "ask_user_multi_select" + AskUserAllowFreeFormProp = "ask_user_allow_free_form" + AskUserRequesterIDProp = "ask_user_requester_id" + AskUserTargetIDProp = "ask_user_target_id" + AskUserConversationIDProp = "ask_user_conversation_id" + AskUserToolUseIDProp = "ask_user_tool_use_id" + AskUserSourcePostIDProp = "ask_user_source_post_id" + AskUserAnsweredAtProp = "ask_user_answered_at" + AskUserAnswerPreviewProp = "ask_user_answer_preview" +) + +// v2 card prop keys (V2-C2), all written at dispatch time and frozen at ask +// time — no re-computation later. Mirrored as literals in the webapp; every +// v2 prop is optional on the read side (pre-v2 cards render the v1 layout). +const ( + AskUserRequesterKindProp = "ask_user_requester_kind" + AskUserRequesterUsernameProp = "ask_user_requester_username" + AskUserRequesterDisplayNameProp = "ask_user_requester_display_name" + AskUserRequesterPositionProp = "ask_user_requester_position" + AskUserAgentDisplayNameProp = "ask_user_agent_display_name" + AskUserDestinationTypeProp = "ask_user_destination_type" + AskUserDestinationChannelDisplayNameProp = "ask_user_destination_channel_display_name" + AskUserDestinationMemberCountProp = "ask_user_destination_member_count" + AskUserDestinationPolicyEnforcedProp = "ask_user_destination_policy_enforced" +) + +// ask_user_requester_kind values (V2-C2). +const ( + AskUserRequesterKindUser = "user" + AskUserRequesterKindBot = "bot" + AskUserRequesterKindUnknown = "unknown" +) + +// ask_user_destination_type values (V2-C2). GM gets its own type because the +// "~name" channel rendering is wrong for a group message's member-name list. +const ( + AskUserDestinationTypeDM = "dm" + AskUserDestinationTypeGM = "gm" + AskUserDestinationTypeChannel = "channel" +) + +const ( + AskUserStatusPending = "pending" + AskUserStatusAnswered = "answered" + AskUserStatusDeclined = "declined" + // AskUserStatusCanceled is the v2 terminal card state written only by + // HandleAskUserCancel: "This question is no longer needed." (V2-C6). + AskUserStatusCanceled = "canceled" + + AskUserActionAnswer = "answer" + AskUserActionDecline = "decline" +) + +// askCardKVPrefix keys the dispatch-time pointer from a tool_use id to the +// target's card post id, giving the cancel path its reverse lookup (V2-C4). +// KV is already the home of the ask claims; this is not a new store. +const askCardKVPrefix = "askcard_" + +// askCardPointerTTL bounds card-pointer accumulation. A question outstanding +// longer than this can still be canceled — the block resolves, only the +// card patch degrades to the target's neutral 409 path. +const askCardPointerTTL = 30 * 24 * time.Hour + +// askUserAnswerPreviewMaxLen caps the card's answer preview prop (C4). +const askUserAnswerPreviewMaxLen = 200 + +// ErrNotAskTarget is returned when a user other than the asked target attempts +// to answer an ask-another-user question. The HTTP layer maps this to 403 +// Forbidden. +var ErrNotAskTarget = errors.New("only the asked user can answer this question") + +// ErrAskConversationGone is returned when the conversation behind an +// ask-another-user card no longer exists (deleted, or the waiting tool call +// was superseded by a regenerate). The HTTP layer maps this to 404 Not Found. +var ErrAskConversationGone = errors.New("the conversation for this question no longer exists") + +// ErrAskNotPending is returned when the question was already answered or +// declined. Repeat submissions are safe and cheap. The HTTP layer maps this +// to 409 Conflict. +var ErrAskNotPending = errors.New("this question is no longer awaiting an answer") + +// ErrInvalidAskAnswer is returned when the submitted answer fails validation +// against the original question. The waiting state is left untouched so the +// target can answer again. The HTTP layer maps this to 400 Bad Request. +var ErrInvalidAskAnswer = errors.New("invalid answer for ask-another-user question") + +// AskUserResponse is the request body of POST /post/{postid}/ask_user_response (C5). +type AskUserResponse struct { + Action string `json:"action"` // AskUserActionAnswer | AskUserActionDecline + Selected []string `json:"selected"` + FreeForm string `json:"free_form"` +} + +// dispatchAskAnotherUser validates the tool arguments and target user and +// sends the question card as a DM from the bot to the target. Any returned +// error becomes the error tool result fed back to the model. +func (c *Conversations) dispatchAskAnotherUser(ctx context.Context, bot *bots.Bot, conv *store.Conversation, anchorPostID string, toolUseID string, rawArgs json.RawMessage) (err error) { + ctx, span := telemetry.Tracer().Start(ctx, "dispatch ask another user", + trace.WithAttributes( + telemetry.ToolName.String(mmtools.AskAnotherUserToolName), + telemetry.ToolID.String(toolUseID), + ), + ) + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + span.SetAttributes(telemetry.ToolStatus.String("error")) + } else { + span.SetAttributes(telemetry.ToolStatus.String("success")) + } + span.End() + }() + + // F1 master-switch backstop: pending blocks persisted before an admin + // flipped the toggle off must resolve to an error result the model can + // react to, instead of silently DMing a card from a disabled feature. + // Fails closed on a nil provider. + if c.configProvider == nil || !c.configProvider.EnableAskAnotherUser() { + return errors.New("the AskAnotherUser feature is disabled by the administrator") + } + + var args mmtools.AskAnotherUserArgs + if unmarshalErr := json.Unmarshal(rawArgs, &args); unmarshalErr != nil { + return fmt.Errorf("invalid AskAnotherUser arguments: %v", unmarshalErr) + } + + if validateErr := mmtools.ValidateAskAnotherUserArgs(args); validateErr != nil { + return validateErr + } + + // Anti-impersonation strip/reject (V2-C3). The sanitized args feed BOTH + // the card props and the plaintext fallback below — model text must + // never be able to fake the card's system chrome. + args, sanitizeErr := mmtools.SanitizeAskAnotherUserArgs(args) + if sanitizeErr != nil { + return sanitizeErr + } + + target, lookupErr := c.mmClient.GetUserByUsername(mmtools.CanonicalAskUsername(args.Username)) + if lookupErr != nil { + return fmt.Errorf("user %q not found", args.Username) + } + if target.IsBot { + return fmt.Errorf("%q is a bot and cannot be asked", args.Username) + } + if target.DeleteAt != 0 { + return fmt.Errorf("%q is deactivated", args.Username) + } + if target.Id == conv.UserID { + return errors.New("the requesting user cannot be the target; use the AskUserQuestion tool to ask them instead") + } + if accessErr := c.bots.CheckUsageRestrictionsForUser(bot, target.Id); accessErr != nil { + return fmt.Errorf("%q does not have access to this agent", args.Username) + } + + // Requester attribution is best-effort display data (C4/V2-C2): a human + // requester is fully identified (kind "user" + username/display/position + // props); an autonomous bot invoker is kind "bot"; a failed lookup is + // kind "unknown". Only kind "user" carries the v1 requester_id prop — + // bot and unknown stay consistently unattributed everywhere (webapp + // props and fallback message) instead of half-attributed. + requesterKind := AskUserRequesterKindUnknown + requesterID := "" + requesterUsername := "" + requesterDisplayName := "" + requesterPosition := "" + if requester, requesterErr := c.mmClient.GetUser(conv.UserID); requesterErr == nil { + if requester.IsBot { + requesterKind = AskUserRequesterKindBot + } else { + requesterKind = AskUserRequesterKindUser + requesterID = conv.UserID + requesterUsername = requester.Username + requesterDisplayName = requester.GetDisplayName(model.ShowFullName) + if requesterDisplayName == requesterUsername { + // The webapp skips duplicates anyway; keep the prop clean. + requesterDisplayName = "" + } + requesterPosition = requester.Position + } + } + + // F4c: the unattended/unknown attribution variants name the agent. + agentDisplayName := bot.GetMMBot().DisplayName + if agentDisplayName == "" { + agentDisplayName = bot.GetMMBot().Username + } + + dest := c.resolveAskDestination(conv, anchorPostID) + + sourcePostID := anchorPostID + if sourcePostID == "" && conv.RootPostID != nil { + sourcePostID = *conv.RootPostID + } + + options := make([]any, 0, len(args.Options)) + for _, opt := range args.Options { + // Props round-trip through JSON; write JSON-primitive shapes so + // reads see the same []any of maps that persistence returns. + options = append(options, map[string]any{ + "label": opt.Label, + "description": opt.Description, + }) + } + + // The plaintext/mobile fallback carries the same attribution, question, + // and destination disclosure as the webapp card (F-001/V2-C8), assembled + // as attribution + question + destination [+ policy] + hint. The + // attribution line comes FIRST so LLM-authored question text cannot + // spoof it. + message := c.buildAskUserCardFallback(target.Locale, args.Question, requesterKind, requesterUsername, agentDisplayName, dest) + + post := &model.Post{ + Type: AskUserPostType, + Message: message, + } + post.AddProp(AskUserStatusProp, AskUserStatusPending) + post.AddProp(AskUserQuestionProp, args.Question) + post.AddProp(AskUserContextProp, args.Context) + post.AddProp(AskUserOptionsProp, options) + post.AddProp(AskUserMultiSelectProp, args.MultiSelect) + post.AddProp(AskUserAllowFreeFormProp, args.FreeFormEnabled()) + post.AddProp(AskUserRequesterIDProp, requesterID) + post.AddProp(AskUserTargetIDProp, target.Id) + post.AddProp(AskUserConversationIDProp, conv.ID) + post.AddProp(AskUserToolUseIDProp, toolUseID) + post.AddProp(AskUserSourcePostIDProp, sourcePostID) + post.AddProp(AskUserRequesterKindProp, requesterKind) + post.AddProp(AskUserRequesterUsernameProp, requesterUsername) + post.AddProp(AskUserRequesterDisplayNameProp, requesterDisplayName) + post.AddProp(AskUserRequesterPositionProp, requesterPosition) + post.AddProp(AskUserAgentDisplayNameProp, agentDisplayName) + post.AddProp(AskUserDestinationTypeProp, dest.Type) + post.AddProp(AskUserDestinationChannelDisplayNameProp, dest.ChannelName) + post.AddProp(AskUserDestinationMemberCountProp, dest.MemberCount) + post.AddProp(AskUserDestinationPolicyEnforcedProp, dest.PolicyEnforced) + + // The card send is irreversible; if the initiating request was canceled + // while we validated, stop before DMing the target. + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("dispatch canceled before sending the question card: %w", ctxErr) + } + + if dmErr := c.mmClient.DM(bot.GetMMBot().UserId, target.Id, post); dmErr != nil { + return fmt.Errorf("failed to open a direct message with %q", args.Username) + } + + // Reverse pointer for the cancel path (V2-C4). Never a dispatch + // failure: a lost pointer degrades to "cancel resolves the block but + // cannot patch the card". + if kvErr := c.mmClient.KVSetWithExpiry(askCardKVPrefix+toolUseID, post.Id, askCardPointerTTL); kvErr != nil { + c.mmClient.LogWarn("Failed to store ask-user card pointer", + "tool_use_id", toolUseID, + "post_id", post.Id, + "error", kvErr.Error(), + ) + } + + return nil +} + +// askDestination is the dispatch-time snapshot of where the target's answer +// may end up (V2-C2). ChannelName and MemberCount are zero-valued for DMs +// and on lookup failures; MemberCount <= 0 means unknown. +type askDestination struct { + Type string + ChannelName string + MemberCount int64 + PolicyEnforced bool +} + +// resolveAskDestination implements the V2-C2 destination-resolution +// algorithm. Disclosure must never UNDERSTATE the audience, so every lookup +// failure degrades toward the broader claim: the generic "channel" +// destination with no name and no count. +func (c *Conversations) resolveAskDestination(conv *store.Conversation, anchorPostID string) askDestination { + channelID := "" + if conv.ChannelID != nil { + channelID = *conv.ChannelID + } + if channelID == "" && anchorPostID != "" { + if anchorPost, postErr := c.mmClient.GetPost(anchorPostID); postErr == nil { + channelID = anchorPost.ChannelId + } + } + if channelID == "" && conv.RootPostID != nil && *conv.RootPostID != "" { + if rootPost, postErr := c.mmClient.GetPost(*conv.RootPostID); postErr == nil { + channelID = rootPost.ChannelId + } + } + if channelID == "" { + return askDestination{Type: AskUserDestinationTypeChannel} + } + + channel, channelErr := c.mmClient.GetChannel(channelID) + if channelErr != nil { + return askDestination{Type: AskUserDestinationTypeChannel} + } + + switch channel.Type { + case model.ChannelTypeDirect: + return askDestination{Type: AskUserDestinationTypeDM} + case model.ChannelTypeGroup: + return askDestination{ + Type: AskUserDestinationTypeGM, + ChannelName: channel.DisplayName, + MemberCount: c.askDestinationMemberCount(channelID), + } + default: + return askDestination{ + Type: AskUserDestinationTypeChannel, + ChannelName: channel.DisplayName, + MemberCount: c.askDestinationMemberCount(channelID), + PolicyEnforced: channel.PolicyEnforced, + } + } +} + +// askDestinationMemberCount returns the channel's member count at dispatch +// time, or 0 (= unknown) when the stats read fails. +func (c *Conversations) askDestinationMemberCount(channelID string) int64 { + stats, statsErr := c.mmClient.GetChannelStats(channelID) + if statsErr != nil || stats == nil { + return 0 + } + return stats.MemberCount +} + +// buildAskUserCardFallback assembles the card's plaintext/mobile fallback +// message from the V2-C8 composable parts: +// attribution + "\n\n" + question + "\n\n" + destination [+ "\n" + policy] + +// "\n\n" + hint. Attribution first, so model text can never precede it. +func (c *Conversations) buildAskUserCardFallback(targetLocale, question, requesterKind, requesterUsername, agentDisplayName string, dest askDestination) string { + T := func(_ string, defaultMessage string, params ...any) string { + if len(params) == 0 { + return defaultMessage + } + return fmt.Sprintf(defaultMessage, params...) + } + if c.i18n != nil { + T = i18n.LocalizerFunc(c.i18n, c.fallbackLocale(targetLocale)) + } + + var attribution string + switch requesterKind { + case AskUserRequesterKindUser: + attribution = T("agents.ask_user_fallback_attrib_user", "Asked on behalf of @%s:", requesterUsername) + case AskUserRequesterKindBot: + attribution = T("agents.ask_user_fallback_attrib_bot", "Asked by the %s agent running unattended (no human requester):", agentDisplayName) + default: + attribution = T("agents.ask_user_fallback_attrib_unknown", "Asked via the %s agent (requester identity unavailable):", agentDisplayName) + } + + var destination string + switch dest.Type { + case AskUserDestinationTypeDM: + switch requesterKind { + case AskUserRequesterKindUser: + destination = T("agents.ask_user_fallback_dest_dm", "Your answer will be shared with @%s.", requesterUsername) + case AskUserRequesterKindBot: + destination = T("agents.ask_user_fallback_dest_dm_agent", "Your answer will be shared with the %s agent.", agentDisplayName) + default: + destination = T("agents.ask_user_fallback_dest_dm_unknown", "Your answer will be shared with the person who asked the agent.") + } + case AskUserDestinationTypeGM: + if dest.MemberCount > 0 { + destination = T("agents.ask_user_fallback_dest_gm", "Your answer may be shared with the %d members of a group message.", dest.MemberCount) + } else { + destination = T("agents.ask_user_fallback_dest_gm_no_count", "Your answer may be shared with the members of a group message.") + } + default: + switch { + case dest.ChannelName != "" && dest.MemberCount > 0: + destination = T("agents.ask_user_fallback_dest_channel", "Your answer may be shared with the %[1]d members of ~%[2]s.", dest.MemberCount, dest.ChannelName) + case dest.ChannelName != "": + destination = T("agents.ask_user_fallback_dest_channel_no_count", "Your answer may be shared with the members of ~%s.", dest.ChannelName) + default: + destination = T("agents.ask_user_fallback_dest_channel_unknown", "Your answer may be shared in the channel where the agent was asked.") + } + } + + // The policy line requires a known channel name — the flag comes from + // the same channel read (V2-C2). Unreachable access data is OMITTED, + // never rendered as "no restrictions". + if dest.PolicyEnforced && dest.ChannelName != "" { + destination += "\n" + T("agents.ask_user_fallback_policy", "Access to ~%s is restricted by an attribute-based access policy.", dest.ChannelName) + } + + hint := T("agents.ask_user_fallback_hint", "(Interactive answer card — open Mattermost in a browser or the desktop app to respond.)") + + return attribution + "\n\n" + question + "\n\n" + destination + "\n\n" + hint +} + +// newDeferredDispatcherForConversation builds the runner dispatcher for +// deferred-result tools in this conversation. anchorPostID may be empty on +// the initial-run path (the anchor post is not persisted as a turn yet); +// dispatch then falls back to conv.RootPostID for the card's permalink. +func (c *Conversations) newDeferredDispatcherForConversation(bot *bots.Bot, conv *store.Conversation, anchorPostID string) toolrunner.DeferredDispatcher { + return func(ctx context.Context, call llm.ToolCall) error { + if call.Name != mmtools.AskAnotherUserToolName { + return fmt.Errorf("no deferred dispatch implemented for tool %s", call.Name) + } + return c.dispatchAskAnotherUser(ctx, bot, conv, anchorPostID, call.ID, call.Arguments) + } +} + +// Ask claim stages. Each one-shot transition of a tool_use block gets its own +// claim key: the pending→waiting dispatch (HandleToolCall) and the +// waiting→resolved answer (HandleAskUserResponse) are independent +// transitions, so sharing one key would let the dispatch claim starve the +// later answer. +const ( + askClaimStageDispatch = "dispatch" + askClaimStageAnswer = "answer" +) + +// claimAskToolUse atomically claims the given one-shot transition for a +// tool_use block via KV compare-and-set (write-if-absent), returning whether +// THIS caller won the claim. The plain read-check-write on turn content is +// not atomic, so two concurrent submissions (two tabs/devices, or two HA +// nodes) can both pass the status check; exactly one of them wins this claim. +// A KV error counts as a failed claim: it is logged here and returned so the +// caller can surface it instead of proceeding to a possible double-write. +func (c *Conversations) claimAskToolUse(stage, toolUseID, resolution string) (bool, error) { + won, err := c.mmClient.KVCompareAndSet("askclaim_"+stage+"_"+toolUseID, nil, []byte(resolution)) + if err != nil { + c.mmClient.LogError("Failed to claim ask tool_use transition", + "stage", stage, + "tool_use_id", toolUseID, + "error", err.Error(), + ) + return false, err + } + return won, nil +} + +// askToolUseClaimResolution returns the resolution that won a one-shot +// transition claim. Recording the winner in the atomic claim closes the +// window where a losing answer could observe the claim before the winner's +// tool_result turn was persisted. +func (c *Conversations) askToolUseClaimResolution(stage, toolUseID string) (string, error) { + var resolution []byte + if err := c.mmClient.KVGet("askclaim_"+stage+"_"+toolUseID, &resolution); err != nil { + return "", err + } + return string(resolution), nil +} + +// publishConversationUpdated tells webapp clients to refetch the +// conversation. The webapp already listens for this event +// (custom_mattermost-ai_conversation_updated); this is its first publisher. +func (c *Conversations) publishConversationUpdated(convID, channelID string) { + c.mmClient.PublishWebSocketEvent("conversation_updated", + map[string]interface{}{"conversation_id": convID}, + &model.WebsocketBroadcast{ChannelId: channelID, ReliableClusterSend: true}) +} + +// HandleAskUserResponse processes the target user's answer (or decline) to an +// ask-another-user question card. It validates the caller is the asked +// target, resolves the answer into the C7 tool-result JSON, flips the waiting +// tool_use block, writes the tool_result turn, patches the card post, and — +// when no unresolved tool calls remain on the anchor turn — streams the +// follow-up LLM response in the original conversation. It returns the +// terminal card status; when a durable cancel result already won, it returns +// canceled without writing, patching, or streaming again. +// +// The card's DM channel (loaded by the HTTP middleware) is unused: target +// authorization runs against the card's ask_user_target_id prop instead. +func (c *Conversations) HandleAskUserResponse(ctx context.Context, userID string, cardPost *model.Post, _ *model.Channel, req AskUserResponse) (string, error) { + if req.Action != AskUserActionAnswer && req.Action != AskUserActionDecline { + return "", fmt.Errorf("%w: unknown action %q", ErrInvalidAskAnswer, req.Action) + } + if cardPost.Type != AskUserPostType { + return "", fmt.Errorf("%w: post is not an ask-user card", ErrInvalidAskAnswer) + } + + targetID, _ := cardPost.GetProp(AskUserTargetIDProp).(string) + if targetID == "" || targetID != userID { + return "", ErrNotAskTarget + } + + bot := c.bots.GetBotByID(cardPost.UserId) + if bot == nil { + return "", fmt.Errorf("unable to get bot") + } + + convID, _ := cardPost.GetProp(AskUserConversationIDProp).(string) + if convID == "" { + return "", fmt.Errorf("%w: card is missing its conversation reference", ErrInvalidAskAnswer) + } + toolUseID, _ := cardPost.GetProp(AskUserToolUseIDProp).(string) + if toolUseID == "" { + return "", fmt.Errorf("%w: card is missing its tool call reference", ErrInvalidAskAnswer) + } + + conv, err := c.convService.GetConversation(convID) + if err != nil { + if errors.Is(err, store.ErrConversationNotFound) { + return "", ErrAskConversationGone + } + return "", fmt.Errorf("failed to get conversation: %w", err) + } + if conv.DeleteAt != 0 { + return "", ErrAskConversationGone + } + + turns, err := c.convService.GetTurns(convID) + if err != nil { + return "", fmt.Errorf("failed to get turns: %w", err) + } + + turn, blocks, blockIdx := findToolUseBlock(turns, toolUseID) + if turn == nil { + // The waiting call vanished — e.g. superseded by a regenerate. + return "", ErrAskConversationGone + } + block := &blocks[blockIdx] + if block.Status != conversation.StatusWaiting { + if askUserResultWasCanceled(turns, toolUseID) { + return AskUserStatusCanceled, nil + } + return "", ErrAskNotPending + } + + // Chain into the originating run's trace when possible (mirrors + // rehydrateRunTrace, which cannot be reused: the card post carries + // ask_user_conversation_id rather than conversation_id). + if turn.PostID != nil { + if userTurn, turnErr := c.convService.GetInitiatingUserTurn(convID, *turn.PostID); turnErr == nil && userTurn != nil { + ctx = telemetry.WithTurnID(ctx, userTurn.ID) + } + } + ctx, span := telemetry.Tracer().Start(ctx, "handle ask user response", + trace.WithNewRoot(), + trace.WithAttributes( + telemetry.PostID.String(cardPost.Id), + telemetry.ToolID.String(toolUseID), + telemetry.UserID.String(userID), + ), + ) + defer span.End() + + // C7 result JSON needs the target's username; fall back to the asked + // username from the original arguments if the lookup fails. + targetUsername := "" + if targetUser, userErr := c.mmClient.GetUser(userID); userErr == nil { + targetUsername = targetUser.Username + } else { + var args mmtools.AskAnotherUserArgs + if unmarshalErr := json.Unmarshal(block.Input, &args); unmarshalErr == nil { + targetUsername = strings.TrimPrefix(args.Username, "@") + } + } + + declined := req.Action == AskUserActionDecline + resultJSON, resolveErr := mmtools.ResolveAskAnotherUserAnswer(block.Input, targetUsername, mmtools.AskAnotherUserAnswer{ + Selected: req.Selected, + FreeForm: req.FreeForm, + }, declined) + if resolveErr != nil { + // State untouched: the question stays waiting and answerable. + return "", fmt.Errorf("%w: %s", ErrInvalidAskAnswer, resolveErr.Error()) + } + + // Atomic claim: exactly one submission may resolve this question. The + // waiting-status check above stays as the first-line guard; this CAS + // closes the concurrent window in which two submissions both read the + // block as waiting and double-write the result turn. The claim comes + // AFTER answer validation so an invalid answer never burns it — the + // question must stay answerable after a validation error. + won, claimErr := c.claimAskToolUse(askClaimStageAnswer, toolUseID, req.Action) + if claimErr != nil { + return "", fmt.Errorf("failed to claim the question: %w", claimErr) + } + if !won { + // The claim records which resolution won, so a cancel is a successful + // no-op even before its tool_result turn has finished persisting. + resolution, resolutionErr := c.askToolUseClaimResolution(askClaimStageAnswer, toolUseID) + if resolutionErr != nil { + return "", fmt.Errorf("failed to inspect the resolved question: %w", resolutionErr) + } + if resolution == AskUserStatusCanceled { + return AskUserStatusCanceled, nil + } + return "", ErrAskNotPending + } + + // Flip the block BEFORE side effects so a concurrent second submission + // hits ErrAskNotPending instead of double-writing results (C5). + if declined { + block.Status = conversation.StatusRejected + } else { + block.Status = conversation.StatusSuccess + } + block.Shared = conversation.BoolPtr(true) + if persistErr := c.persistBlocks(turn.ID, blocks); persistErr != nil { + return "", fmt.Errorf("failed to persist answered status: %w", persistErr) + } + + if writeErr := c.writeAskResultTurn(convID, toolUseID, resultJSON); writeErr != nil { + return "", writeErr + } + + // Everything below is best-effort: the answer is recorded, so failures + // are logged rather than surfaced as request errors. + c.patchAskUserCard(cardPost.Id, declined, req) + + c.resumeAfterAskResolution(ctx, bot, conv, turns, blocks, turn) + + if declined { + return AskUserStatusDeclined, nil + } + return AskUserStatusAnswered, nil +} + +// writeAskResultTurn persists the tool_result turn for a resolved +// ask-another-user question. The result turn is StatusSuccess for declines +// and cancels too: both are valid tool results the model must consume, not +// errors (C6, V2-C5). The result is an initiator-visible terminal decision, +// so it is created Shared with DecidedAt set — no Share/Keep-Private stage. +func (c *Conversations) writeAskResultTurn(convID, toolUseID, resultJSON string) error { + now := model.GetMillis() + resultBlocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolResult, + ToolUseID: toolUseID, + Content: resultJSON, + Status: conversation.StatusSuccess, + Shared: conversation.BoolPtr(true), + DecidedAt: conversation.Int64Ptr(now), + }} + resultContent, marshalErr := json.Marshal(resultBlocks) + if marshalErr != nil { + return fmt.Errorf("failed to marshal tool result blocks: %w", marshalErr) + } + resultTurn := &store.Turn{ + ID: model.NewId(), + ConversationID: convID, + Role: "tool_result", + Content: resultContent, + CreatedAt: now, + } + if createErr := c.convService.CreateTurnAutoSequence(resultTurn); createErr != nil { + return fmt.Errorf("failed to create tool result turn: %w", createErr) + } + return nil +} + +// askUserResultWasCanceled checks the persisted tool result for the terminal +// resolution. A successful tool_use block alone is ambiguous because answers +// and cancels share that status and the same one-shot claim. +func askUserResultWasCanceled(turns []store.Turn, toolUseID string) bool { + for _, turn := range turns { + if turn.Role != "tool_result" { + continue + } + var blocks []conversation.ContentBlock + if err := json.Unmarshal(turn.Content, &blocks); err != nil { + continue + } + for _, block := range blocks { + if block.Type != conversation.BlockTypeToolResult || block.ToolUseID != toolUseID { + continue + } + var result struct { + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(block.Content), &result); err == nil && result.Status == AskUserStatusCanceled { + return true + } + } + } + return false +} + +// resumeAfterAskResolution is the shared tail of the answer and cancel +// paths: publish the conversation update, then stream the follow-up LLM +// response — but only when the resume invariant allows it. Best-effort by +// design: the resolution is already persisted, so failures here are logged, +// never surfaced. turns/blocks/turn are the caller's pre-resolution +// snapshot of the anchor turn. +func (c *Conversations) resumeAfterAskResolution(ctx context.Context, bot *bots.Bot, conv *store.Conversation, turns []store.Turn, blocks []conversation.ContentBlock, turn *store.Turn) { + convID := conv.ID + + var anchorPost *model.Post + if turn.PostID != nil { + if p, postErr := c.mmClient.GetPost(*turn.PostID); postErr == nil { + anchorPost = p + } else { + c.mmClient.LogError("Failed to get anchor post for ask-user follow-up", "error", postErr, "post_id", *turn.PostID) + } + } + + anchorChannelID := "" + switch { + case anchorPost != nil: + anchorChannelID = anchorPost.ChannelId + case conv.ChannelID != nil && *conv.ChannelID != "": + anchorChannelID = *conv.ChannelID + } + if anchorChannelID != "" { + c.publishConversationUpdated(convID, anchorChannelID) + } + + // C3 resume invariant: never stream while any pending, accepted, or + // waiting tool_use remains on the anchor turn (mixed batches resume via + // HandleToolCall or a later answer). + if hasUnresolvedToolUse(blocks) { + return + } + + // Channel mixed batches stage executed tool output behind the requester's + // Share/Keep-Private decision. While any of the anchor turn's results is + // still undecided, the resume belongs to HandleToolResult — streaming now + // would demote the anchor turn and orphan the pending share click. (DM + // results are always decided at creation, so this only gates channels.) + if anchorHasUndecidedResults(turns, blocks) { + return + } + + if anchorPost == nil { + c.mmClient.LogError("Cannot stream ask-user follow-up without the anchor post", "conversation_id", convID) + return + } + anchorChannel, channelErr := c.mmClient.GetChannel(anchorPost.ChannelId) + if channelErr != nil { + c.mmClient.LogError("Failed to get anchor channel for ask-user follow-up", "error", channelErr, "channel_id", anchorPost.ChannelId) + return + } + initiator, initiatorErr := c.mmClient.GetUser(conv.UserID) + if initiatorErr != nil { + c.mmClient.LogError("Failed to get initiating user for ask-user follow-up", "error", initiatorErr, "user_id", conv.UserID) + return + } + + isDM := mmapi.IsDMWith(bot.GetMMBot().UserId, anchorChannel) + if followErr := c.streamToolFollowUp(ctx, bot, initiator, anchorChannel, anchorPost, conv, isDM, nil); followErr != nil { + c.mmClient.LogError("Failed to stream ask-user follow-up", "error", followErr, "conversation_id", convID) + } +} + +// HandleAskUserCancel processes the conversation initiator's cancellation of +// an outstanding ask-another-user question (V2-C4). It resolves the waiting +// tool_use with a valid non-error {"status":"canceled",...} tool result, +// patches the target's card to the canceled terminal state (best-effort via +// the askcard_ KV pointer written at dispatch), and resumes the conversation +// under the same gates as the answer path. Cancel contends for the SAME +// one-shot claim as answer/decline, so exactly one resolution ever wins; the +// cancel loser surfaces ErrAskNotPending (409), while a response that loses +// to a durable cancel result returns a successful canceled no-op. +// +// The clicked post is the initiator's anchor post; its channel (loaded by +// the HTTP middleware) is unused — the resume path re-reads it. +func (c *Conversations) HandleAskUserCancel(ctx context.Context, userID string, post *model.Post, _ *model.Channel, toolUseID string) error { + convID, ok := post.GetProp(streaming.ConversationIDProp).(string) + if !ok || convID == "" { + return ErrPostMissingConversationID + } + + bot := c.bots.GetBotByID(post.UserId) + if bot == nil { + return fmt.Errorf("unable to get bot") + } + + // Enrich the request's audit record (nil outside an audited request) + // with which agent's question this human decision cancels. Question + // text, target identity, and result content never enter the record. + audit.AddParam(audit.RecordFromContext(ctx), audit.KeyAgentID, bot.GetMMBot().UserId) + + conv, err := c.convService.GetConversation(convID) + if err != nil { + if errors.Is(err, store.ErrConversationNotFound) { + return ErrAskConversationGone + } + return fmt.Errorf("failed to get conversation: %w", err) + } + if conv.DeleteAt != 0 { + return ErrAskConversationGone + } + // Only the conversation initiator can cancel; the API layer's + // isConversationOwner gate is re-checked here so the invariant holds for + // every caller of this method. + if conv.UserID != userID { + return ErrNotRequester + } + + turns, err := c.convService.GetTurns(convID) + if err != nil { + return fmt.Errorf("failed to get turns: %w", err) + } + + turn, blocks, blockIdx := findToolUseBlock(turns, toolUseID) + if turn == nil { + // The waiting call vanished — e.g. superseded by a regenerate. + return ErrAskConversationGone + } + block := &blocks[blockIdx] + // Only an AskAnotherUser deferred block still waiting on the clicked + // anchor post is cancelable; anything else is already resolved or not + // this endpoint's business (V2-C4). + if turn.PostID == nil || *turn.PostID != post.Id || + block.Name != mmtools.AskAnotherUserToolName || + !block.DeferredResult || + block.Status != conversation.StatusWaiting { + return ErrAskNotPending + } + + // Chain into the originating run's trace when possible (mirrors the + // answer path). + if userTurn, turnErr := c.convService.GetInitiatingUserTurn(convID, post.Id); turnErr == nil && userTurn != nil { + ctx = telemetry.WithTurnID(ctx, userTurn.ID) + } + ctx, span := telemetry.Tracer().Start(ctx, "handle ask user cancel", + trace.WithNewRoot(), + trace.WithAttributes( + telemetry.PostID.String(post.Id), + telemetry.ToolID.String(toolUseID), + telemetry.UserID.String(userID), + ), + ) + defer span.End() + + // Resolve BEFORE claiming, mirroring the answer path: the resolve is + // side-effect-free, so a failure here must never burn the one-shot + // claim and strand the block in a permanently-conflicting state. + resultJSON, resolveErr := mmtools.ResolveAskAnotherUserCancel(block.Input) + if resolveErr != nil { + return fmt.Errorf("failed to build cancel result: %w", resolveErr) + } + + // Atomic claim on the SAME key as answer/decline: exactly one of + // {answer, decline, cancel} resolves this question. Losing means the + // other resolution already happened. + won, claimErr := c.claimAskToolUse(askClaimStageAnswer, toolUseID, AskUserStatusCanceled) + if claimErr != nil { + return fmt.Errorf("failed to claim the question: %w", claimErr) + } + if !won { + return ErrAskNotPending + } + + // Flip the block BEFORE side effects, mirroring the answer path. No new + // content-block status (V2-C5): the canceled call completed with a + // valid result — "canceled" lives in the result JSON and the card prop. + block.Status = conversation.StatusSuccess + block.Shared = conversation.BoolPtr(true) + if persistErr := c.persistBlocks(turn.ID, blocks); persistErr != nil { + return fmt.Errorf("failed to persist canceled status: %w", persistErr) + } + + if writeErr := c.writeAskResultTurn(convID, toolUseID, resultJSON); writeErr != nil { + return writeErr + } + + // Everything below is best-effort: the cancel is recorded, so failures + // are logged rather than surfaced as request errors. + c.patchAskUserCardCanceled(toolUseID) + + c.resumeAfterAskResolution(ctx, bot, conv, turns, blocks, turn) + + return nil +} + +// patchAskUserCardCanceled flips the target's question card to the canceled +// terminal state ("This question is no longer needed."), located via the +// dispatch-time KV pointer. Best-effort: failures are logged, never +// surfaced — the conversation-side resolution has already happened, and a +// stale card still resolves late target responses through the durable +// canceled tool result. The plain message is rewritten too so pre-v2 webapps +// and plaintext clients see the terminal state (V2-C2 back-compat rule). +func (c *Conversations) patchAskUserCardCanceled(toolUseID string) { + var cardPostID string + if kvErr := c.mmClient.KVGet(askCardKVPrefix+toolUseID, &cardPostID); kvErr != nil || cardPostID == "" { + c.mmClient.LogWarn("No card pointer for canceled ask-user question; target card left unpatched", + "tool_use_id", toolUseID, + ) + return + } + + patched, getErr := c.mmClient.GetPost(cardPostID) + if getErr != nil { + c.mmClient.LogError("Failed to get ask-user card post for cancel patching", "error", getErr, "post_id", cardPostID) + return + } + + // Localize for the target (best-effort lookup for their locale). + locale := "" + if targetID, _ := patched.GetProp(AskUserTargetIDProp).(string); targetID != "" { + if target, targetErr := c.mmClient.GetUser(targetID); targetErr == nil { + locale = target.Locale + } + } + const canceledDefault = "This question is no longer needed." + message := canceledDefault + if c.i18n != nil { + T := i18n.LocalizerFunc(c.i18n, c.fallbackLocale(locale)) + message = T("agents.ask_user_card_canceled", canceledDefault) + } + + patched.AddProp(AskUserStatusProp, AskUserStatusCanceled) + patched.Message = message + if updateErr := c.mmClient.UpdatePost(patched); updateErr != nil { + c.mmClient.LogError("Failed to patch ask-user card post to canceled", "error", updateErr, "post_id", cardPostID) + } +} + +// patchAskUserCard updates the card post's props to reflect the recorded +// answer. Best-effort: failures are logged, never surfaced. +func (c *Conversations) patchAskUserCard(cardPostID string, declined bool, req AskUserResponse) { + patched, getErr := c.mmClient.GetPost(cardPostID) + if getErr != nil { + c.mmClient.LogError("Failed to get ask-user card post for patching", "error", getErr, "post_id", cardPostID) + return + } + + status := AskUserStatusAnswered + preview := "" + if declined { + status = AskUserStatusDeclined + } else { + preview = askUserAnswerPreview(req.Selected, req.FreeForm) + } + patched.AddProp(AskUserStatusProp, status) + patched.AddProp(AskUserAnsweredAtProp, model.GetMillis()) + patched.AddProp(AskUserAnswerPreviewProp, preview) + + if updateErr := c.mmClient.UpdatePost(patched); updateErr != nil { + c.mmClient.LogError("Failed to patch ask-user card post", "error", updateErr, "post_id", cardPostID) + } +} + +// askUserAnswerPreview renders the card's short answer summary: selected +// labels joined with ", ", then " — " and the free-form text when both are +// present, truncated to askUserAnswerPreviewMaxLen runes. +func askUserAnswerPreview(selected []string, freeForm string) string { + preview := strings.Join(selected, ", ") + freeForm = strings.TrimSpace(freeForm) + if freeForm != "" { + if preview != "" { + preview += " — " + freeForm + } else { + preview = freeForm + } + } + runes := []rune(preview) + if len(runes) > askUserAnswerPreviewMaxLen { + return string(runes[:askUserAnswerPreviewMaxLen]) + } + return preview +} + +// anchorHasUndecidedResults reports whether any tool_result belonging to one +// of the anchor turn's tool_use blocks still awaits its channel +// Share/Keep-Private decision (DecidedAt unset). turns may be a snapshot +// taken before this request's own tool_result turn was written — that result +// is created already decided, so its absence never gates. +func anchorHasUndecidedResults(turns []store.Turn, anchorBlocks []conversation.ContentBlock) bool { + anchorToolUseIDs := make(map[string]struct{}, len(anchorBlocks)) + for _, b := range anchorBlocks { + if b.Type == conversation.BlockTypeToolUse && b.ID != "" { + anchorToolUseIDs[b.ID] = struct{}{} + } + } + for _, turn := range turns { + var blocks []conversation.ContentBlock + if err := json.Unmarshal(turn.Content, &blocks); err != nil { + continue + } + for _, b := range blocks { + if b.Type != conversation.BlockTypeToolResult { + continue + } + if _, ok := anchorToolUseIDs[b.ToolUseID]; !ok { + continue + } + if b.DecidedAt == nil { + return true + } + } + } + return false +} + +// findToolUseBlock locates the assistant turn containing the tool_use block +// with the given ID, returning the turn, its unmarshaled blocks, and the +// block's index. Returns a nil turn when no assistant turn carries the block. +func findToolUseBlock(turns []store.Turn, toolUseID string) (*store.Turn, []conversation.ContentBlock, int) { + for i := range turns { + if turns[i].Role != "assistant" { + continue + } + var blocks []conversation.ContentBlock + if err := json.Unmarshal(turns[i].Content, &blocks); err != nil { + continue + } + for j := range blocks { + if blocks[j].Type == conversation.BlockTypeToolUse && blocks[j].ID == toolUseID { + return &turns[i], blocks, j + } + } + } + return nil, nil, -1 +} diff --git a/conversations/ask_another_user_test.go b/conversations/ask_another_user_test.go new file mode 100644 index 000000000..e77add66c --- /dev/null +++ b/conversations/ask_another_user_test.go @@ -0,0 +1,2233 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package conversations + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/mattermost/mattermost-plugin-agents/v2/bots" + "github.com/mattermost/mattermost-plugin-agents/v2/conversation" + "github.com/mattermost/mattermost-plugin-agents/v2/enterprise" + "github.com/mattermost/mattermost-plugin-agents/v2/llm" + "github.com/mattermost/mattermost-plugin-agents/v2/llmcontext" + "github.com/mattermost/mattermost-plugin-agents/v2/mcp" + "github.com/mattermost/mattermost-plugin-agents/v2/mmapi/mocks" + "github.com/mattermost/mattermost-plugin-agents/v2/mmtools" + "github.com/mattermost/mattermost-plugin-agents/v2/store" + "github.com/mattermost/mattermost-plugin-agents/v2/streaming" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// askAnotherUserBuilder builds a context builder whose built-in tool provider +// registers the AskAnotherUser tool, mirroring production MMToolProvider. +func askAnotherUserBuilder(t *testing.T) *llmcontext.Builder { + t.Helper() + + mockAPI := &plugintest.API{} + mockLicenseState(mockAPI, true) + mockAPI.On("GetTeam", "team-id").Return(&model.Team{Id: "team-id", Name: "team"}, nil).Maybe() + + return llmcontext.NewLLMContextBuilder( + pluginapi.NewClient(mockAPI, nil), + &toolLicenseBuiltinProvider{tools: []llm.Tool{mmtools.NewAskAnotherUserTool()}}, + &channelFollowUpTestMCPToolProvider{tools: []llm.Tool{loadedStateTool()}}, + &channelFollowUpTestConfig{}, + ) +} + +func newAskAnotherUserBotsService(t *testing.T, bot *bots.Bot) *bots.MMBots { + t.Helper() + + mockAPI := &plugintest.API{} + pluginAPI := pluginapi.NewClient(mockAPI, nil) + licenseChecker := enterprise.NewLicenseChecker(pluginAPI) + botsService := bots.New(mockAPI, pluginAPI, licenseChecker, nil, nil, &http.Client{}, nil) + botsService.SetBotsForTesting([]*bots.Bot{bot}) + return botsService +} + +func TestDispatchAskAnotherUserValidation(t *testing.T) { + validArgs := `{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}],"context":"Deciding where to deploy"}` + rootPostID := "root-post-id" + + cases := []struct { + name string + rawArgs string + anchorPostID string + rootPostID *string + restrictedAccess bool + target *model.User + targetLookupErr bool + requester *model.User + requesterLookupErr bool + dmErr error + wantErrContains string + wantDM bool + wantRequesterProp string + wantSourceProp string + wantLookupUsername string + }{ + { + name: "happy path sends card with all props", + rawArgs: validArgs, + anchorPostID: "anchor-post-id", + target: &model.User{Id: "bob-id", Username: "bob"}, + requester: &model.User{Id: "user-id", Username: "user"}, + wantDM: true, + wantRequesterProp: "user-id", + wantSourceProp: "anchor-post-id", + }, + { + name: "empty anchor falls back to conversation root post", + rawArgs: validArgs, + anchorPostID: "", + rootPostID: &rootPostID, + target: &model.User{Id: "bob-id", Username: "bob"}, + requester: &model.User{Id: "user-id", Username: "user"}, + wantDM: true, + wantRequesterProp: "user-id", + wantSourceProp: "root-post-id", + }, + { + name: "target user not found", + rawArgs: validArgs, + targetLookupErr: true, + wantErrContains: "not found", + }, + { + name: "target is a bot", + rawArgs: validArgs, + target: &model.User{Id: "bob-id", Username: "bob", IsBot: true}, + wantErrContains: "is a bot", + }, + { + name: "target deactivated", + rawArgs: validArgs, + target: &model.User{Id: "bob-id", Username: "bob", DeleteAt: 1}, + wantErrContains: "is deactivated", + }, + { + name: "target is the requesting user", + rawArgs: `{"username":"user","question":"Which environment?"}`, + target: &model.User{Id: "user-id", Username: "user"}, + wantErrContains: "use the AskUserQuestion tool", + }, + { + name: "target lacks access to the agent", + rawArgs: validArgs, + restrictedAccess: true, + target: &model.User{Id: "bob-id", Username: "bob"}, + wantErrContains: "does not have access", + }, + { + name: "DM creation failure", + rawArgs: validArgs, + target: &model.User{Id: "bob-id", Username: "bob"}, + requester: &model.User{Id: "user-id", Username: "user"}, + dmErr: errors.New("boom"), + wantErrContains: "failed to open a direct message", + wantDM: true, + }, + { + name: "invalid arguments", + rawArgs: `{"username":"bob","question":" "}`, + wantErrContains: "question must not be empty", + }, + { + name: "bot requester gets empty attribution", + rawArgs: validArgs, + anchorPostID: "anchor-post-id", + target: &model.User{Id: "bob-id", Username: "bob"}, + requester: &model.User{Id: "user-id", Username: "flowbot", IsBot: true}, + wantDM: true, + wantRequesterProp: "", + wantSourceProp: "anchor-post-id", + }, + { + // A failed requester lookup must not send a half-attributed + // card: both the requester prop and the fallback attribution + // stay empty, like an autonomous dispatch. + name: "requester lookup failure sends an unattributed card", + rawArgs: validArgs, + anchorPostID: "anchor-post-id", + target: &model.User{Id: "bob-id", Username: "bob"}, + requesterLookupErr: true, + wantDM: true, + wantRequesterProp: "", + wantSourceProp: "anchor-post-id", + }, + { + name: "whitespace-and-@ username is canonicalized for the lookup", + rawArgs: `{"username":" @bob ","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}],"context":"Deciding where to deploy"}`, + anchorPostID: "anchor-post-id", + target: &model.User{Id: "bob-id", Username: "bob"}, + requester: &model.User{Id: "user-id", Username: "user"}, + wantDM: true, + wantRequesterProp: "user-id", + wantSourceProp: "anchor-post-id", + wantLookupUsername: "bob", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + botConfig := llm.BotConfig{ + ID: "bot-id", + Name: "matty", + DisplayName: "Matty", + UserAccessLevel: llm.UserAccessLevelAll, + ChannelAccessLevel: llm.ChannelAccessLevelAll, + } + if tc.restrictedAccess { + // Allow-list with no users: every target is denied without + // needing team lookups. + botConfig.UserAccessLevel = llm.UserAccessLevelAllow + botConfig.UserIDs = nil + } + bot := bots.NewBot( + botConfig, + llm.ServiceConfig{DefaultModel: "test-model", Type: llm.ServiceTypeOpenAI}, + &model.Bot{UserId: "bot-id", Username: "matty", DisplayName: "Matty"}, + nil, + ) + + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + // Destination resolution degrades to the generic channel claim + // when the anchor post cannot be read; the destination matrix + // has its own table (TestDispatchAskAnotherUserDestinationProps). + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + var lookedUpUsername string + if tc.targetLookupErr { + mmClient.On("GetUserByUsername", mock.Anything).Return(nil, errors.New("store miss")).Once() + } else if tc.target != nil { + mmClient.On("GetUserByUsername", mock.Anything). + Run(func(args mock.Arguments) { lookedUpUsername = args.String(0) }). + Return(tc.target, nil).Once() + } + if tc.requesterLookupErr { + mmClient.On("GetUser", "user-id").Return(nil, errors.New("requester lookup down")).Once() + } else if tc.requester != nil { + mmClient.On("GetUser", "user-id").Return(tc.requester, nil).Once() + } + var sentPost *model.Post + if tc.wantDM { + mmClient.On("DM", "bot-id", tc.target.Id, mock.AnythingOfType("*model.Post")). + Run(func(args mock.Arguments) { + sentPost = args.Get(2).(*model.Post) + }).Return(tc.dmErr).Once() + } + + conv := &store.Conversation{ID: "conv-id", UserID: "user-id", BotID: "bot-id", RootPostID: tc.rootPostID} + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + err := c.dispatchAskAnotherUser(context.Background(), bot, conv, tc.anchorPostID, "ask-1", json.RawMessage(tc.rawArgs)) + + if tc.wantErrContains != "" { + require.ErrorContains(t, err, tc.wantErrContains) + if !tc.wantDM { + assert.Nil(t, sentPost, "no card may be sent on validation failure") + } + return + } + require.NoError(t, err) + require.NotNil(t, sentPost) + + assert.Equal(t, AskUserPostType, sentPost.Type) + assert.Contains(t, sentPost.Message, "Which environment?") + assert.Contains(t, sentPost.Message, "Interactive answer card") + // F-001: a human requester is named in the server-authored + // fallback so plaintext/mobile clients see attribution too, and + // the attribution precedes the LLM-authored question so injected + // question text cannot spoof it. Autonomous (bot) requesters + // stay unattributed. + if tc.wantRequesterProp != "" { + attribution := "Asked on behalf of @" + tc.requester.Username + require.Contains(t, sentPost.Message, attribution) + assert.Less(t, + strings.Index(sentPost.Message, attribution), + strings.Index(sentPost.Message, "Which environment?"), + "attribution must come before the question") + } else { + assert.NotContains(t, sentPost.Message, "Asked on behalf of") + } + assert.Equal(t, AskUserStatusPending, sentPost.GetProp(AskUserStatusProp)) + assert.Equal(t, "Which environment?", sentPost.GetProp(AskUserQuestionProp)) + assert.Equal(t, "Deciding where to deploy", sentPost.GetProp(AskUserContextProp)) + assert.Equal(t, []any{ + map[string]any{"label": "Prod", "description": ""}, + map[string]any{"label": "Staging", "description": ""}, + }, sentPost.GetProp(AskUserOptionsProp)) + assert.Equal(t, false, sentPost.GetProp(AskUserMultiSelectProp)) + assert.Equal(t, true, sentPost.GetProp(AskUserAllowFreeFormProp)) + assert.Equal(t, tc.wantRequesterProp, sentPost.GetProp(AskUserRequesterIDProp)) + assert.Equal(t, "bob-id", sentPost.GetProp(AskUserTargetIDProp)) + if tc.wantLookupUsername != "" { + assert.Equal(t, tc.wantLookupUsername, lookedUpUsername, + "the user lookup must use the canonical username") + } + assert.Equal(t, "conv-id", sentPost.GetProp(AskUserConversationIDProp)) + assert.Equal(t, "ask-1", sentPost.GetProp(AskUserToolUseIDProp)) + assert.Equal(t, tc.wantSourceProp, sentPost.GetProp(AskUserSourcePostIDProp)) + }) + } +} + +// TestDispatchAskAnotherUserDisabled pins the F1 dispatch backstop (V2-C1): +// with the master toggle off — or no config provider at all — a pending +// AskAnotherUser block resolves to an error result with zero side effects: +// no target lookup, no DM, no KV card pointer. The strict mock client is the +// no-side-effects assertion. +func TestDispatchAskAnotherUserDisabled(t *testing.T) { + cases := []struct { + name string + configProvider ConfigProvider + }{ + { + name: "toggle off resolves to an error result", + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: false}, + }, + { + name: "nil config provider fails closed", + configProvider: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bot := loadedStateBot(&loadedStateLLM{}) + mmClient := mocks.NewMockClient(t) + + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + configProvider: tc.configProvider, + } + conv := &store.Conversation{ID: "conv-id", UserID: "user-id", BotID: "bot-id"} + + err := c.dispatchAskAnotherUser(context.Background(), bot, conv, "anchor-post-id", "ask-1", + json.RawMessage(`{"username":"bob","question":"Which environment?"}`)) + + require.ErrorContains(t, err, "disabled by the administrator") + }) + } +} + +// TestDispatchWritesCardPointer pins the V2-C4 reverse pointer: a successful +// dispatch stores askcard_ → card post id so the cancel path can +// find the target's card, and a KV failure only degrades the future card +// patch — the dispatch itself still succeeds. +func TestDispatchWritesCardPointer(t *testing.T) { + cases := []struct { + name string + kvErr error + }{ + {name: "successful dispatch stores the card pointer"}, + {name: "pointer write failure does not fail the dispatch", kvErr: errors.New("kv down")}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bot := loadedStateBot(&loadedStateLLM{}) + + mmClient := mocks.NewMockClient(t) + for i := 1; i <= 7; i++ { + args := make([]interface{}, i) + for j := range args { + args[j] = mock.Anything + } + mmClient.On("LogWarn", args...).Maybe().Return() + } + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil).Once() + mmClient.On("GetUser", "user-id").Return(&model.User{Id: "user-id", Username: "user"}, nil).Once() + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")). + Run(func(args mock.Arguments) { + // Production DM fills the created post's ID in place. + args.Get(2).(*model.Post).Id = "card-post-id" + }).Return(nil).Once() + mmClient.On("KVSetWithExpiry", askCardKVPrefix+"ask-1", "card-post-id", askCardPointerTTL). + Return(tc.kvErr).Once() + + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + conv := &store.Conversation{ID: "conv-id", UserID: "user-id", BotID: "bot-id"} + + err := c.dispatchAskAnotherUser(context.Background(), bot, conv, "anchor-post-id", "ask-1", + json.RawMessage(`{"username":"bob","question":"Which environment?"}`)) + + require.NoError(t, err, "a lost card pointer must never fail the dispatch") + }) + } +} + +// TestDispatchAskAnotherUserDestinationProps pins the V2-C2 +// destination-resolution matrix: the card's destination props and the +// fallback's disclosure line reflect where the answer may end up, and every +// lookup failure degrades toward the BROADER audience claim (generic +// channel), never a narrower one. +func TestDispatchAskAnotherUserDestinationProps(t *testing.T) { + cases := []struct { + name string + convChannelID string // conversation-level channel; "" means nil (DM/thread conv) + anchorChannel string // channel resolved via the anchor post when convChannelID is "" + channel *model.Channel + channelErr bool + statsCount int64 + statsErr bool + wantType string + wantName string + wantCount int64 + wantPolicy bool + wantMsgContain string + }{ + { + name: "DM conversation discloses the dm destination", + convChannelID: "dm-chan", + channel: &model.Channel{Id: "dm-chan", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"}, + wantType: AskUserDestinationTypeDM, + wantMsgContain: "Your answer will be shared with @user.", + }, + { + name: "public channel carries name and member count", + convChannelID: "town-square", + channel: &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, DisplayName: "Town Square"}, + statsCount: 42, + wantType: AskUserDestinationTypeChannel, + wantName: "Town Square", + wantCount: 42, + wantMsgContain: "Your answer may be shared with the 42 members of ~Town Square.", + }, + { + name: "private channel carries name and member count", + convChannelID: "secret-chan", + channel: &model.Channel{Id: "secret-chan", Type: model.ChannelTypePrivate, DisplayName: "Secret Plans"}, + statsCount: 5, + wantType: AskUserDestinationTypeChannel, + wantName: "Secret Plans", + wantCount: 5, + wantMsgContain: "Your answer may be shared with the 5 members of ~Secret Plans.", + }, + { + name: "group message gets its own gm type", + convChannelID: "gm-chan", + channel: &model.Channel{Id: "gm-chan", Type: model.ChannelTypeGroup, DisplayName: "alice, bob, carol"}, + statsCount: 3, + wantType: AskUserDestinationTypeGM, + wantName: "alice, bob, carol", + wantCount: 3, + wantMsgContain: "Your answer may be shared with the 3 members of a group message.", + }, + { + name: "member-count failure degrades to an unknown count", + convChannelID: "town-square", + channel: &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, DisplayName: "Town Square"}, + statsErr: true, + wantType: AskUserDestinationTypeChannel, + wantName: "Town Square", + wantCount: 0, + wantMsgContain: "Your answer may be shared with the members of ~Town Square.", + }, + { + name: "channel lookup failure degrades to the generic channel claim", + convChannelID: "town-square", + channelErr: true, + wantType: AskUserDestinationTypeChannel, + wantMsgContain: "Your answer may be shared in the channel where the agent was asked.", + }, + { + name: "nil conversation channel resolves via the anchor post", + anchorChannel: "chan-x", + channel: &model.Channel{Id: "chan-x", Type: model.ChannelTypeOpen, DisplayName: "Anchor Channel"}, + statsCount: 7, + wantType: AskUserDestinationTypeChannel, + wantName: "Anchor Channel", + wantCount: 7, + wantMsgContain: "Your answer may be shared with the 7 members of ~Anchor Channel.", + }, + { + name: "no channel anywhere claims the broadest audience", + wantType: AskUserDestinationTypeChannel, + wantMsgContain: "Your answer may be shared in the channel where the agent was asked.", + }, + { + name: "policy-enforced channel sets the policy prop and line", + convChannelID: "abac-chan", + channel: &model.Channel{ + Id: "abac-chan", Type: model.ChannelTypePrivate, + DisplayName: "Compliance", PolicyEnforced: true, + }, + statsCount: 9, + wantType: AskUserDestinationTypeChannel, + wantName: "Compliance", + wantCount: 9, + wantPolicy: true, + wantMsgContain: "Access to ~Compliance is restricted by an attribute-based access policy.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + bot := loadedStateBot(&loadedStateLLM{}) + + mmClient := mocks.NewMockClient(t) + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil).Once() + mmClient.On("GetUser", "user-id").Return(&model.User{Id: "user-id", Username: "user"}, nil).Once() + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + + channelID := tc.convChannelID + if tc.anchorChannel != "" { + channelID = tc.anchorChannel + mmClient.On("GetPost", "anchor-post-id"). + Return(&model.Post{Id: "anchor-post-id", ChannelId: tc.anchorChannel}, nil).Once() + } else if tc.convChannelID == "" { + mmClient.On("GetPost", "anchor-post-id").Return(nil, errors.New("not found")).Once() + } + switch { + case tc.channelErr: + mmClient.On("GetChannel", channelID).Return(nil, errors.New("channel gone")).Once() + case tc.channel != nil: + mmClient.On("GetChannel", channelID).Return(tc.channel, nil).Once() + } + // The DM destination and failed lookups must never trigger a + // stats read; the strict mock enforces it by omission. + if tc.channel != nil && tc.channel.Type != model.ChannelTypeDirect { + if tc.statsErr { + mmClient.On("GetChannelStats", channelID).Return(nil, errors.New("stats down")).Once() + } else { + mmClient.On("GetChannelStats", channelID). + Return(&model.ChannelStats{ChannelId: channelID, MemberCount: tc.statsCount}, nil).Once() + } + } + var sentPost *model.Post + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")). + Run(func(args mock.Arguments) { sentPost = args.Get(2).(*model.Post) }). + Return(nil).Once() + + conv := &store.Conversation{ID: "conv-id", UserID: "user-id", BotID: "bot-id"} + if tc.convChannelID != "" { + conv.ChannelID = &tc.convChannelID + } + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + err := c.dispatchAskAnotherUser(context.Background(), bot, conv, "anchor-post-id", "ask-1", + json.RawMessage(`{"username":"bob","question":"Which environment?"}`)) + require.NoError(t, err) + require.NotNil(t, sentPost) + + assert.Equal(t, tc.wantType, sentPost.GetProp(AskUserDestinationTypeProp)) + assert.Equal(t, tc.wantName, sentPost.GetProp(AskUserDestinationChannelDisplayNameProp)) + assert.Equal(t, tc.wantCount, sentPost.GetProp(AskUserDestinationMemberCountProp)) + assert.Equal(t, tc.wantPolicy, sentPost.GetProp(AskUserDestinationPolicyEnforcedProp)) + assert.Contains(t, sentPost.Message, tc.wantMsgContain, + "the plaintext fallback must carry the same destination disclosure as the card") + if !tc.wantPolicy { + assert.NotContains(t, sentPost.Message, "attribute-based access policy", + "unreachable access data must be omitted, never rendered as unrestricted") + } + }) + } +} + +// TestDispatchAskAnotherUserRequesterKind pins the V2-C2 requester +// attribution props: a human requester is fully identified, an autonomous +// bot invoker and a failed lookup stay consistently unattributed (kind bot / +// unknown, empty identity props), and the agent display name prop is always +// set — falling back to the bot username when the display name is empty. +func TestDispatchAskAnotherUserRequesterKind(t *testing.T) { + cases := []struct { + name string + requester *model.User + requesterErr bool + botDisplayName string // defaults to "Matty" + wantKind string + wantRequesterID string + wantUsername string + wantDisplay string + wantPosition string + wantAgent string + wantMsgContain string + }{ + { + name: "human requester is fully identified", + requester: &model.User{ + Id: "user-id", Username: "user", + FirstName: "Ursula", LastName: "Example", Position: "SRE", + }, + wantKind: AskUserRequesterKindUser, + wantRequesterID: "user-id", + wantUsername: "user", + wantDisplay: "Ursula Example", + wantPosition: "SRE", + wantAgent: "Matty", + wantMsgContain: "Asked on behalf of @user:", + }, + { + name: "display name equal to the username is dropped", + requester: &model.User{Id: "user-id", Username: "user"}, + wantKind: AskUserRequesterKindUser, + wantRequesterID: "user-id", + wantUsername: "user", + wantDisplay: "", + wantAgent: "Matty", + wantMsgContain: "Asked on behalf of @user:", + }, + { + name: "bot requester is kind bot with no identity props", + requester: &model.User{Id: "flow-id", Username: "flowbot", IsBot: true}, + wantKind: AskUserRequesterKindBot, + wantAgent: "Matty", + wantMsgContain: "Asked by the Matty agent running unattended (no human requester):", + }, + { + name: "requester lookup failure is kind unknown", + requesterErr: true, + wantKind: AskUserRequesterKindUnknown, + wantAgent: "Matty", + wantMsgContain: "Asked via the Matty agent (requester identity unavailable):", + }, + { + name: "empty agent display name falls back to the bot username", + requesterErr: true, + botDisplayName: "-", // sentinel for "explicitly empty" + wantKind: AskUserRequesterKindUnknown, + wantAgent: "matty", + wantMsgContain: "Asked via the matty agent (requester identity unavailable):", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + displayName := "Matty" + if tc.botDisplayName == "-" { + displayName = "" + } + bot := bots.NewBot( + llm.BotConfig{ + ID: "bot-id", Name: "matty", DisplayName: displayName, + UserAccessLevel: llm.UserAccessLevelAll, + ChannelAccessLevel: llm.ChannelAccessLevelAll, + }, + llm.ServiceConfig{DefaultModel: "test-model", Type: llm.ServiceTypeOpenAI}, + &model.Bot{UserId: "bot-id", Username: "matty", DisplayName: displayName}, + nil, + ) + + mmClient := mocks.NewMockClient(t) + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil).Once() + if tc.requesterErr { + mmClient.On("GetUser", "user-id").Return(nil, errors.New("lookup down")).Once() + } else { + mmClient.On("GetUser", "user-id").Return(tc.requester, nil).Once() + } + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + var sentPost *model.Post + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")). + Run(func(args mock.Arguments) { sentPost = args.Get(2).(*model.Post) }). + Return(nil).Once() + + conv := &store.Conversation{ID: "conv-id", UserID: "user-id", BotID: "bot-id"} + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + err := c.dispatchAskAnotherUser(context.Background(), bot, conv, "anchor-post-id", "ask-1", + json.RawMessage(`{"username":"bob","question":"Which environment?"}`)) + require.NoError(t, err) + require.NotNil(t, sentPost) + + assert.Equal(t, tc.wantKind, sentPost.GetProp(AskUserRequesterKindProp)) + assert.Equal(t, tc.wantRequesterID, sentPost.GetProp(AskUserRequesterIDProp), + "only kind user may carry the requester_id prop") + assert.Equal(t, tc.wantUsername, sentPost.GetProp(AskUserRequesterUsernameProp)) + assert.Equal(t, tc.wantDisplay, sentPost.GetProp(AskUserRequesterDisplayNameProp)) + assert.Equal(t, tc.wantPosition, sentPost.GetProp(AskUserRequesterPositionProp)) + assert.Equal(t, tc.wantAgent, sentPost.GetProp(AskUserAgentDisplayNameProp)) + assert.Contains(t, sentPost.Message, tc.wantMsgContain, + "the fallback attribution must match the props") + }) + } +} + +func TestNewDeferredDispatcherRejectsUnknownTool(t *testing.T) { + c := &Conversations{} + dispatcher := c.newDeferredDispatcherForConversation(nil, &store.Conversation{}, "") + + err := dispatcher(context.Background(), llm.ToolCall{ID: "x", Name: "SomeOtherTool"}) + + require.ErrorContains(t, err, "no deferred dispatch implemented for tool SomeOtherTool") +} + +// TestHandleToolCallDeferredAccept covers the deferred-dispatch flow in +// HandleToolCall: accepting an AskAnotherUser call sends the card and parks +// the block in waiting (no tool result, no follow-up), dispatch failures +// convert to error results, unaccepted deferred calls reject like any other +// tool, a policy-marked block dispatches on resume without a click, and a +// mixed batch executes the normal tool but gates the follow-up on the +// waiting block. +func TestHandleToolCallDeferredAccept(t *testing.T) { + askInput := json.RawMessage(`{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}`) + dmChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + + cases := []struct { + name string + acceptedIDs []string + wouldAutoExecute bool + policyChecker mcp.ToolPolicyChecker + includeNormal bool + inChannel bool + dmErr error + wantDMCalls int + wantBlockStatus string + wantResultTurn bool + wantResultText string + wantResultErr bool + wantFollowUp bool + wantPublish bool + }{ + { + name: "accept dispatches card and parks waiting", + acceptedIDs: []string{"ask-1"}, + wantDMCalls: 1, + wantBlockStatus: conversation.StatusWaiting, + wantPublish: true, + }, + { + name: "dispatch failure records error result and streams follow-up", + acceptedIDs: []string{"ask-1"}, + dmErr: errors.New("boom"), + wantDMCalls: 1, + wantBlockStatus: conversation.StatusError, + wantResultTurn: true, + wantResultText: "failed to open a direct message", + wantResultErr: true, + wantFollowUp: true, + }, + { + // Rejected-only batches never stream a follow-up: nothing + // executed, matching the behavior for normal tools. + name: "unaccepted deferred call is rejected", + acceptedIDs: []string{}, + wantBlockStatus: conversation.StatusRejected, + wantResultTurn: true, + wantResultText: "Tool call rejected by user", + wantResultErr: true, + }, + { + name: "auto-exec resume dispatches without an accept click", + acceptedIDs: []string{}, + wouldAutoExecute: true, + policyChecker: mapPolicyChecker{ + "": {"AskAnotherUser": {policy: mcp.ToolPolicyAutoRunInDM, enabled: true}}, + }, + wantDMCalls: 1, + wantBlockStatus: conversation.StatusWaiting, + wantPublish: true, + }, + { + name: "mixed batch executes normal tool but gates follow-up on waiting", + acceptedIDs: []string{"tool-use-1", "ask-1"}, + includeNormal: true, + wantDMCalls: 1, + wantBlockStatus: conversation.StatusWaiting, + wantResultTurn: true, + wantResultText: "restored-result", + wantPublish: true, + }, + { + // Channel-anchored mixed batch: the normal tool's result stays + // undecided (share stage pending in HandleToolResult) and the + // waiting question gates the follow-up exactly as in DMs. + name: "channel mixed batch stages the share decision and gates follow-up on waiting", + acceptedIDs: []string{"tool-use-1", "ask-1"}, + includeNormal: true, + inChannel: true, + wantDMCalls: 1, + wantBlockStatus: conversation.StatusWaiting, + wantResultTurn: true, + wantResultText: "restored-result", + wantPublish: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + nextSeq := 1 + if tc.includeNormal { + seedLoadToolPair(t, convStore, conv.ID, "load-1", "jira__get_issue", &nextSeq) + } + + var blocks []conversation.ContentBlock + if tc.includeNormal { + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "tool-use-1", + Name: "jira__get_issue", + Input: json.RawMessage(`{}`), + Status: conversation.StatusPending, + Shared: conversation.BoolPtr(false), + }) + } + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: "AskAnotherUser", + Input: askInput, + Status: conversation.StatusPending, + DeferredResult: true, + WouldAutoExecute: tc.wouldAutoExecute, + Shared: conversation.BoolPtr(false), + }) + content, err := json.Marshal(blocks) + require.NoError(t, err) + approvalPostID := "approval-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &approvalPostID, + Role: "assistant", + Content: content, + Sequence: nextSeq, + })) + seededTurns := nextSeq + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + // Dispatch-time destination resolution degrades gracefully; the + // destination matrix has its own table. + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + + dmCalls := 0 + if tc.wantDMCalls > 0 { + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")). + Run(func(mock.Arguments) { dmCalls++ }). + Return(tc.dmErr). + Times(tc.wantDMCalls) + } + publishes := 0 + if tc.wantPublish { + mmClient.On("PublishWebSocketEvent", "conversation_updated", + map[string]interface{}{"conversation_id": conv.ID}, mock.Anything). + Run(func(mock.Arguments) { publishes++ }). + Return(). + Once() + } + + streamingService := &loadedStateStreamingService{} + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: streamingService, + toolPolicyChecker: tc.policyChecker, + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + approvalPost := &model.Post{Id: approvalPostID, UserId: "bot-id"} + approvalPost.AddProp(streaming.ConversationIDProp, conv.ID) + + channel := dmChannel + if tc.inChannel { + channel = &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, Name: "town-square", TeamId: "team-id"} + } + + require.NoError(t, c.HandleToolCall(context.Background(), "user-id", approvalPost, channel, tc.acceptedIDs, nil)) + streamingService.waitForStreaming() + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + wantTurns := seededTurns + if tc.wantResultTurn { + wantTurns++ + } + require.Len(t, turns, wantTurns, "no empty tool_result turn may be written for waiting-only batches") + + var updated []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[seededTurns-1].Content, &updated)) + askBlock := updated[len(updated)-1] + assert.Equal(t, tc.wantBlockStatus, askBlock.Status) + assert.True(t, askBlock.DeferredResult, "deferred flag must survive the persist") + if tc.wantBlockStatus == conversation.StatusWaiting { + require.NotNil(t, askBlock.Shared) + assert.False(t, *askBlock.Shared, "waiting blocks stay unshared until answered") + } + if tc.includeNormal { + assert.Equal(t, conversation.StatusSuccess, updated[0].Status, "normal tool executes despite the deferred sibling") + } + + if tc.wantResultTurn { + var resultBlocks []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[len(turns)-1].Content, &resultBlocks)) + require.Len(t, resultBlocks, 1, "waiting calls contribute no result; only the resolved call may appear") + assert.Contains(t, resultBlocks[0].Content, tc.wantResultText) + if tc.wantResultErr { + assert.Equal(t, conversation.StatusError, resultBlocks[0].Status) + } else { + assert.Equal(t, conversation.StatusSuccess, resultBlocks[0].Status) + } + if tc.includeNormal { + assert.Equal(t, "tool-use-1", resultBlocks[0].ToolUseID) + } + if tc.inChannel { + // Channel results stay undecided until the requester's + // Share/Keep-Private click in HandleToolResult. + assert.Nil(t, resultBlocks[0].DecidedAt, "channel results must await the share decision") + require.NotNil(t, resultBlocks[0].Shared) + assert.False(t, *resultBlocks[0].Shared, "channel results stay unshared until the share decision") + } + } + + assert.Equal(t, tc.wantDMCalls, dmCalls) + if tc.wantPublish { + assert.Equal(t, 1, publishes) + } + if tc.wantFollowUp { + assert.Len(t, lm.requests, 1, "expected a follow-up LLM request") + } else { + assert.Empty(t, lm.requests, "waiting/rejected-only batches must not stream a follow-up") + } + }) + } +} + +// TestHandleToolCallDeferredDoubleAccept pins the idempotency guarantee: the +// waiting flip is persisted before the card is sent, so a second Accept click +// finds no pending block and fails with ErrStaleToolClick — exactly one card +// is ever delivered. +func TestHandleToolCallDeferredDoubleAccept(t *testing.T) { + convStore, conv := loadedStateConversationStore() + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: "AskAnotherUser", + Input: json.RawMessage(`{"username":"bob","question":"Which environment?"}`), + Status: conversation.StatusPending, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + approvalPostID := "approval-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &approvalPostID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("GetPost", mock.Anything).Maybe().Return(nil, errors.New("not found")) + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil).Once() + dmCalls := 0 + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")). + Run(func(mock.Arguments) { dmCalls++ }). + Return(nil). + Once() + mmClient.On("PublishWebSocketEvent", "conversation_updated", mock.Anything, mock.Anything).Maybe().Return() + + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: &loadedStateStreamingService{}, + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + approvalPost := &model.Post{Id: approvalPostID, UserId: "bot-id"} + approvalPost.AddProp(streaming.ConversationIDProp, conv.ID) + dmChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + + require.NoError(t, c.HandleToolCall(context.Background(), "user-id", approvalPost, dmChannel, []string{"ask-1"}, nil)) + + err = c.HandleToolCall(context.Background(), "user-id", approvalPost, dmChannel, []string{"ask-1"}, nil) + require.ErrorIs(t, err, ErrStaleToolClick) + assert.Equal(t, 1, dmCalls, "a second Accept must never send a second card") +} + +// TestAskToolUseClaimRaces pins the atomic KV claim that closes the +// concurrent duplicate-submission window (two tabs/devices or two HA nodes) +// which the plain read-check-write status guards cannot: the claim loser +// must fail with the same sentinel as a sequential duplicate and write +// nothing — no second card, no second tool_result turn. A KV failure also +// blocks the transition (fail closed) but surfaces as a plain error rather +// than a misleading "already handled" sentinel. +func TestAskToolUseClaimRaces(t *testing.T) { + askInput := json.RawMessage(`{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}`) + + cases := []struct { + name string + action string // "dispatch" (HandleToolCall accept) | "answer" (HandleAskUserResponse) + claimErr error + wantErrIs error // nil means any non-sentinel error is acceptable + }{ + { + name: "concurrent duplicate accept loses the dispatch claim and sends no card", + action: "dispatch", + wantErrIs: ErrStaleToolClick, + }, + { + name: "KV failure on the dispatch claim fails the accept without sending a card", + action: "dispatch", + claimErr: errors.New("kv down"), + }, + { + name: "concurrent duplicate answer loses the answer claim and writes no result turn", + action: "answer", + wantErrIs: ErrAskNotPending, + }, + { + name: "KV failure on the answer claim fails the answer without writing", + action: "answer", + claimErr: errors.New("kv down"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + + seedStatus := conversation.StatusWaiting + if tc.action == "dispatch" { + seedStatus = conversation.StatusPending + } + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: "AskAnotherUser", + Input: askInput, + Status: seedStatus, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + anchorPostID := "anchor-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &anchorPostID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + // The claim loses; the mockery strict mock doubles as the + // "no side effects" assertion — any DM, card patch, result + // write, or publish after a lost claim fails the test. + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything). + Return(false, tc.claimErr).Once() + + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: &loadedStateStreamingService{}, + } + + switch tc.action { + case "dispatch": + approvalPost := &model.Post{Id: anchorPostID, UserId: "bot-id"} + approvalPost.AddProp(streaming.ConversationIDProp, conv.ID) + dmChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + err = c.HandleToolCall(context.Background(), "user-id", approvalPost, dmChannel, []string{"ask-1"}, nil) + case "answer": + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserConversationIDProp, conv.ID) + cardPost.AddProp(AskUserToolUseIDProp, "ask-1") + cardChannel := &model.Channel{Id: "card-dm", Type: model.ChannelTypeDirect, Name: "bob-id__bot-id"} + _, err = c.HandleAskUserResponse(context.Background(), "bob-id", cardPost, cardChannel, AskUserResponse{ + Action: AskUserActionAnswer, + Selected: []string{"Prod"}, + }) + } + + require.Error(t, err) + if tc.wantErrIs != nil { + require.ErrorIs(t, err, tc.wantErrIs) + } else { + // Infra failures must not masquerade as the duplicate + // sentinels the webapp treats as benign. + require.NotErrorIs(t, err, ErrStaleToolClick) + require.NotErrorIs(t, err, ErrAskNotPending) + } + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, 1, "a lost claim must not write a tool_result turn") + + assert.Empty(t, lm.requests, "a lost claim must not stream a follow-up") + }) + } +} + +// TestHandleAskUserResponse covers the target-side answer endpoint logic: +// answers and declines resolve the waiting call into a C7 tool result and +// resume the conversation; authorization, staleness, and validation failures +// map to the documented sentinels without consuming the question; and the +// follow-up is gated while other tool calls remain unresolved. +func TestHandleAskUserResponse(t *testing.T) { + askInput := `{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}` + + cases := []struct { + name string + caller string + action string + selected []string + freeForm string + seedStatus string + blockID string + notACard bool + cardConvID string + extraPending bool + channelAnchor bool + extraExecutedUndecided bool + cardPatchFails bool + wantErr error + wantBlockStatus string + wantResultJSON string + wantFollowUp bool + wantCardStatus string + wantPreview string + }{ + { + name: "answer resumes the conversation", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantFollowUp: true, + wantCardStatus: AskUserStatusAnswered, + wantPreview: "Prod", + }, + { + name: "free-form answer round-trips into the result", + action: AskUserActionAnswer, + freeForm: "Use staging please", + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":[],"free_form":"Use staging please"}`, + wantFollowUp: true, + wantCardStatus: AskUserStatusAnswered, + wantPreview: "Use staging please", + }, + { + name: "decline resumes with the decline marker", + action: AskUserActionDecline, + wantBlockStatus: conversation.StatusRejected, + wantResultJSON: `{"status":"declined","target_username":"bob"}`, + wantFollowUp: true, + wantCardStatus: AskUserStatusDeclined, + wantPreview: "", + }, + { + name: "non-target caller is forbidden", + caller: "mallory-id", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + wantErr: ErrNotAskTarget, + }, + { + name: "already answered question conflicts", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + seedStatus: conversation.StatusSuccess, + wantErr: ErrAskNotPending, + }, + { + name: "conversation gone", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + cardConvID: "missing-conv", + wantErr: ErrAskConversationGone, + }, + { + name: "tool call superseded by regenerate", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + blockID: "other-block", + wantErr: ErrAskConversationGone, + }, + { + name: "invalid answer leaves the question waiting", + action: AskUserActionAnswer, + selected: []string{"NotAnOption"}, + wantErr: ErrInvalidAskAnswer, + }, + { + name: "unknown action is invalid", + action: "shrug", + wantErr: ErrInvalidAskAnswer, + }, + { + name: "post that is not a card is invalid", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + notACard: true, + wantErr: ErrInvalidAskAnswer, + }, + { + name: "mixed batch gates the follow-up", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + extraPending: true, + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantFollowUp: false, + wantCardStatus: AskUserStatusAnswered, + wantPreview: "Prod", + }, + { + name: "card patch failure still records the answer", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + cardPatchFails: true, + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantFollowUp: true, + }, + { + // C6: channel-anchored answers are user-authored, so they are + // shared+decided immediately (no Share/Keep-Private stage) and + // the follow-up streams with isDM=false. + name: "channel anchor answer is shared and streams with no share stage", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + channelAnchor: true, + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantFollowUp: true, + wantCardStatus: AskUserStatusAnswered, + wantPreview: "Prod", + }, + { + name: "channel anchor decline is shared and streams with no share stage", + action: AskUserActionDecline, + channelAnchor: true, + wantBlockStatus: conversation.StatusRejected, + wantResultJSON: `{"status":"declined","target_username":"bob"}`, + wantFollowUp: true, + wantCardStatus: AskUserStatusDeclined, + wantPreview: "", + }, + { + // MAJOR-1 regression (answer-first ordering): while a sibling + // tool's channel result still awaits its Share/Keep-Private + // decision, the answer is recorded but the resume belongs to + // HandleToolResult — streaming now would demote the anchor turn + // and orphan the pending share click. + name: "channel anchor undecided share decision defers the follow-up", + action: AskUserActionAnswer, + selected: []string{"Prod"}, + channelAnchor: true, + extraExecutedUndecided: true, + wantBlockStatus: conversation.StatusSuccess, + wantResultJSON: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantFollowUp: false, + wantCardStatus: AskUserStatusAnswered, + wantPreview: "Prod", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + + blockID := "ask-1" + if tc.blockID != "" { + blockID = tc.blockID + } + seedStatus := conversation.StatusWaiting + if tc.seedStatus != "" { + seedStatus = tc.seedStatus + } + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: blockID, + Name: "AskAnotherUser", + Input: json.RawMessage(askInput), + Status: seedStatus, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }} + if tc.extraPending { + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "tool-use-2", + Name: "jira__get_issue", + Input: json.RawMessage(`{}`), + Status: conversation.StatusPending, + Shared: conversation.BoolPtr(false), + }) + } + if tc.extraExecutedUndecided { + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "tool-use-2", + Name: "jira__get_issue", + Input: json.RawMessage(`{}`), + Status: conversation.StatusSuccess, + Shared: conversation.BoolPtr(false), + }) + } + content, err := json.Marshal(blocks) + require.NoError(t, err) + anchorPostID := "anchor-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &anchorPostID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + seededTurns := 1 + if tc.extraExecutedUndecided { + // The executed sibling's channel result has no share decision + // yet (DecidedAt unset), mirroring HandleToolCall's output + // for a channel-anchored mixed batch. + undecided := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolResult, + ToolUseID: "tool-use-2", + Content: "restored-result", + Status: conversation.StatusSuccess, + Shared: conversation.BoolPtr(false), + }} + undecidedContent, marshalErr := json.Marshal(undecided) + require.NoError(t, marshalErr) + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "undecided-result-turn", + ConversationID: conv.ID, + Role: "tool_result", + Content: undecidedContent, + Sequence: 2, + })) + seededTurns = 2 + } + + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + if tc.notACard { + cardPost.Type = "" + } + cardConvID := conv.ID + if tc.cardConvID != "" { + cardConvID = tc.cardConvID + } + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserConversationIDProp, cardConvID) + cardPost.AddProp(AskUserToolUseIDProp, "ask-1") + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + anchorChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + if tc.channelAnchor { + anchorChannel = &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, Name: "town-square", TeamId: "team-id"} + } + anchorPost := &model.Post{Id: anchorPostID, UserId: "bot-id", ChannelId: anchorChannel.Id} + + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("LogError", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetPost", anchorPostID).Maybe().Return(anchorPost, nil) + mmClient.On("GetChannel", anchorChannel.Id).Maybe().Return(anchorChannel, nil) + if tc.cardPatchFails { + mmClient.On("GetPost", "card-post-id").Maybe().Return(nil, errors.New("card gone")) + } else { + mmClient.On("GetPost", "card-post-id").Maybe().Return(cardPost, nil) + } + var patchedPost *model.Post + mmClient.On("UpdatePost", mock.AnythingOfType("*model.Post")).Maybe(). + Run(func(args mock.Arguments) { patchedPost = args.Get(0).(*model.Post) }). + Return(nil) + publishes := 0 + mmClient.On("PublishWebSocketEvent", "conversation_updated", + map[string]interface{}{"conversation_id": conv.ID}, mock.Anything).Maybe(). + Run(func(mock.Arguments) { publishes++ }). + Return() + + streamingService := &loadedStateStreamingService{} + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: streamingService, + } + + caller := "bob-id" + if tc.caller != "" { + caller = tc.caller + } + cardChannel := &model.Channel{Id: "card-dm", Type: model.ChannelTypeDirect, Name: "bob-id__bot-id"} + req := AskUserResponse{Action: tc.action, Selected: tc.selected, FreeForm: tc.freeForm} + + _, err = c.HandleAskUserResponse(context.Background(), caller, cardPost, cardChannel, req) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + // Failure must leave the question in its seeded state with + // no tool_result turn. + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, seededTurns) + var unchanged []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[0].Content, &unchanged)) + assert.Equal(t, seedStatus, unchanged[0].Status) + assert.Zero(t, publishes) + return + } + require.NoError(t, err) + streamingService.waitForStreaming() + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, seededTurns+1) + + var updated []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[0].Content, &updated)) + assert.Equal(t, tc.wantBlockStatus, updated[0].Status) + require.NotNil(t, updated[0].Shared) + assert.True(t, *updated[0].Shared) + + var resultBlocks []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[len(turns)-1].Content, &resultBlocks)) + require.Len(t, resultBlocks, 1) + assert.Equal(t, conversation.BlockTypeToolResult, resultBlocks[0].Type) + assert.Equal(t, "ask-1", resultBlocks[0].ToolUseID) + // Declines are valid results the model must consume, not errors. + assert.Equal(t, conversation.StatusSuccess, resultBlocks[0].Status) + require.NotNil(t, resultBlocks[0].Shared) + assert.True(t, *resultBlocks[0].Shared) + assert.NotNil(t, resultBlocks[0].DecidedAt) + assert.JSONEq(t, tc.wantResultJSON, resultBlocks[0].Content) + + if tc.cardPatchFails { + assert.Nil(t, patchedPost, "patch failure must not update the card") + } else { + require.NotNil(t, patchedPost) + assert.Equal(t, tc.wantCardStatus, patchedPost.GetProp(AskUserStatusProp)) + assert.NotNil(t, patchedPost.GetProp(AskUserAnsweredAtProp)) + assert.Equal(t, tc.wantPreview, patchedPost.GetProp(AskUserAnswerPreviewProp)) + } + + assert.Equal(t, 1, publishes, "answer must refresh the initiator's conversation view") + + if tc.wantFollowUp { + assert.Len(t, lm.requests, 1, "expected a follow-up LLM request") + } else { + assert.Empty(t, lm.requests, "follow-up must wait for the remaining unresolved tool calls") + } + }) + } +} + +func TestHandleAskUserResponseTerminalOutcomes(t *testing.T) { + askInput := json.RawMessage(`{"username":"bob","question":"Which environment?","options":[{"label":"Prod"}]}`) + + cases := []struct { + name string + seedStatus string + seedResult string + cancelAtClaim bool + wantStatus string + wantErr error + }{ + { + name: "cancel then late answer is a successful no-op", + seedStatus: conversation.StatusSuccess, + seedResult: `{"status":"canceled","target_username":"bob"}`, + wantStatus: AskUserStatusCanceled, + }, + { + name: "cancel winning after the answer read is a successful no-op", + seedStatus: conversation.StatusWaiting, + seedResult: `{"status":"canceled","target_username":"bob"}`, + cancelAtClaim: true, + wantStatus: AskUserStatusCanceled, + }, + { + name: "duplicate answer remains a conflict", + seedStatus: conversation.StatusSuccess, + seedResult: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + wantErr: ErrAskNotPending, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: mmtools.AskAnotherUserToolName, + Input: askInput, + Status: tc.seedStatus, + DeferredResult: true, + Shared: conversation.BoolPtr(true), + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + + writeSeedResult := func() { + resultContent, marshalErr := json.Marshal([]conversation.ContentBlock{{ + Type: conversation.BlockTypeToolResult, + ToolUseID: "ask-1", + Content: tc.seedResult, + Status: conversation.StatusSuccess, + }}) + require.NoError(t, marshalErr) + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "canceled-result-turn", + ConversationID: conv.ID, + Role: "tool_result", + Content: resultContent, + Sequence: 2, + })) + } + if !tc.cancelAtClaim { + writeSeedResult() + } + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + mmClient := mocks.NewMockClient(t) + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + if tc.cancelAtClaim { + mmClient.On("KVCompareAndSet", "askclaim_answer_ask-1", mock.Anything, []byte(AskUserActionAnswer)). + Return(false, nil). + Once() + mmClient.On("KVGet", "askclaim_answer_ask-1", mock.AnythingOfType("*[]uint8")). + Run(func(args mock.Arguments) { + resolution := args.Get(1).(*[]byte) + *resolution = []byte(AskUserStatusCanceled) + }). + Return(nil). + Once() + } + + c := &Conversations{ + mmClient: mmClient, + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: &loadedStateStreamingService{}, + } + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserConversationIDProp, conv.ID) + cardPost.AddProp(AskUserToolUseIDProp, "ask-1") + + status, responseErr := c.HandleAskUserResponse( + context.Background(), + "bob-id", + cardPost, + &model.Channel{Id: "card-dm"}, + AskUserResponse{Action: AskUserActionAnswer, Selected: []string{"Prod"}}, + ) + + if tc.wantErr != nil { + require.ErrorIs(t, responseErr, tc.wantErr) + } else { + require.NoError(t, responseErr) + } + require.Equal(t, tc.wantStatus, status) + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + wantTurns := 2 + if tc.cancelAtClaim { + wantTurns = 1 + } + require.Len(t, turns, wantTurns, "the stale response must not append another tool_result turn") + assert.Empty(t, lm.requests, "the stale response must not stream a second follow-up") + }) + } +} + +// TestHandleAskUserCancel covers the initiator-side cancel endpoint logic +// (V2-C4): a cancel resolves the waiting block into the canceled tool result, +// patches the target's card via the dispatch-time KV pointer, and resumes the +// conversation under the same gates as the answer path; authorization and +// staleness failures map to the documented sentinels with zero writes. +func TestHandleAskUserCancel(t *testing.T) { + askInput := `{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}` + + cases := []struct { + name string + caller string + toolUseID string + seedStatus string + blockName string + notDeferred bool + turnPostID string + missingConvProp bool + convDeleted bool + claimTaken bool + pointerMissing bool + extraWaiting bool + channelAnchor bool + extraExecutedUndecided bool + wantErr error + wantFollowUp bool + }{ + { + name: "happy path cancels and resumes the DM conversation", + wantFollowUp: true, + }, + { + // Channel semantics: the cancel result itself is decided at + // creation, but a sibling's undecided Share/Keep-Private stage + // still defers the resume to HandleToolResult. + name: "channel anchor with an undecided sibling share defers the follow-up", + channelAnchor: true, + extraExecutedUndecided: true, + wantFollowUp: false, + }, + { + name: "non-initiator is forbidden", + caller: "mallory-id", + wantErr: ErrNotRequester, + }, + { + name: "post without a conversation reference is invalid", + missingConvProp: true, + wantErr: ErrPostMissingConversationID, + }, + { + name: "deleted conversation is gone", + convDeleted: true, + wantErr: ErrAskConversationGone, + }, + { + name: "unknown tool_use is gone", + toolUseID: "never-heard-of-it", + wantErr: ErrAskConversationGone, + }, + { + name: "already resolved block conflicts", + seedStatus: conversation.StatusSuccess, + wantErr: ErrAskNotPending, + }, + { + name: "block anchored on a different post conflicts", + turnPostID: "other-post-id", + wantErr: ErrAskNotPending, + }, + { + name: "waiting block of another tool conflicts", + blockName: "jira__get_issue", + wantErr: ErrAskNotPending, + }, + { + name: "waiting non-deferred block conflicts", + blockName: "AskAnotherUser", + notDeferred: true, + wantErr: ErrAskNotPending, + }, + { + name: "lost claim conflicts with zero writes", + claimTaken: true, + wantErr: ErrAskNotPending, + }, + { + name: "missing card pointer still resolves the block", + pointerMissing: true, + wantFollowUp: true, + }, + { + name: "mixed batch with another waiting block defers the follow-up", + extraWaiting: true, + wantFollowUp: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + if tc.convDeleted { + conv.DeleteAt = 1 + require.NoError(t, convStore.CreateConversation(conv)) // overwrite the seeded row + } + + blockName := "AskAnotherUser" + if tc.blockName != "" { + blockName = tc.blockName + } + seedStatus := conversation.StatusWaiting + if tc.seedStatus != "" { + seedStatus = tc.seedStatus + } + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: blockName, + Input: json.RawMessage(askInput), + Status: seedStatus, + DeferredResult: !tc.notDeferred, + Shared: conversation.BoolPtr(false), + }} + if tc.extraWaiting { + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "ask-2", + Name: "AskAnotherUser", + Input: json.RawMessage(askInput), + Status: conversation.StatusWaiting, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }) + } + if tc.extraExecutedUndecided { + blocks = append(blocks, conversation.ContentBlock{ + Type: conversation.BlockTypeToolUse, + ID: "tool-use-2", + Name: "jira__get_issue", + Input: json.RawMessage(`{}`), + Status: conversation.StatusSuccess, + Shared: conversation.BoolPtr(false), + }) + } + content, err := json.Marshal(blocks) + require.NoError(t, err) + anchorPostID := "anchor-post-id" + turnPostID := anchorPostID + if tc.turnPostID != "" { + turnPostID = tc.turnPostID + } + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &turnPostID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + seededTurns := 1 + if tc.extraExecutedUndecided { + undecided := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolResult, + ToolUseID: "tool-use-2", + Content: "restored-result", + Status: conversation.StatusSuccess, + Shared: conversation.BoolPtr(false), + }} + undecidedContent, marshalErr := json.Marshal(undecided) + require.NoError(t, marshalErr) + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "undecided-result-turn", + ConversationID: conv.ID, + Role: "tool_result", + Content: undecidedContent, + Sequence: 2, + })) + seededTurns = 2 + } + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + anchorChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + if tc.channelAnchor { + anchorChannel = &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, Name: "town-square", TeamId: "team-id"} + } + anchorPost := &model.Post{Id: anchorPostID, UserId: "bot-id", ChannelId: anchorChannel.Id} + if !tc.missingConvProp { + anchorPost.AddProp(streaming.ConversationIDProp, conv.ID) + } + + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserStatusProp, AskUserStatusPending) + + mmClient := mocks.NewMockClient(t) + for i := 1; i <= 7; i++ { + logArgs := make([]interface{}, i) + for j := range logArgs { + logArgs[j] = mock.Anything + } + mmClient.On("LogDebug", logArgs...).Maybe().Return() + mmClient.On("LogError", logArgs...).Maybe().Return() + mmClient.On("LogWarn", logArgs...).Maybe().Return() + } + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVCompareAndSet", "askclaim_answer_ask-1", mock.Anything, []byte(AskUserStatusCanceled)). + Maybe().Return(!tc.claimTaken, nil) + mmClient.On("KVGet", askCardKVPrefix+"ask-1", mock.Anything).Maybe(). + Run(func(args mock.Arguments) { + if !tc.pointerMissing { + *(args.Get(1).(*string)) = "card-post-id" + } + }).Return(nil) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetPost", anchorPostID).Maybe().Return(anchorPost, nil) + mmClient.On("GetPost", "card-post-id").Maybe().Return(cardPost, nil) + mmClient.On("GetChannel", anchorChannel.Id).Maybe().Return(anchorChannel, nil) + var patchedPost *model.Post + mmClient.On("UpdatePost", mock.AnythingOfType("*model.Post")).Maybe(). + Run(func(args mock.Arguments) { patchedPost = args.Get(0).(*model.Post) }). + Return(nil) + publishes := 0 + mmClient.On("PublishWebSocketEvent", "conversation_updated", + map[string]interface{}{"conversation_id": conv.ID}, mock.Anything).Maybe(). + Run(func(mock.Arguments) { publishes++ }). + Return() + + streamingService := &loadedStateStreamingService{} + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: streamingService, + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + caller := "user-id" + if tc.caller != "" { + caller = tc.caller + } + toolUseID := "ask-1" + if tc.toolUseID != "" { + toolUseID = tc.toolUseID + } + + err = c.HandleAskUserCancel(context.Background(), caller, anchorPost, anchorChannel, toolUseID) + + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + + // Failure must leave the block in its seeded state with no + // tool_result turn, no card patch, and no publish. + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, seededTurns, "a failed cancel must not write a tool_result turn") + var unchanged []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[0].Content, &unchanged)) + assert.Equal(t, seedStatus, unchanged[0].Status) + assert.Nil(t, patchedPost, "a failed cancel must not patch the card") + assert.Zero(t, publishes) + assert.Empty(t, lm.requests) + return + } + require.NoError(t, err) + streamingService.waitForStreaming() + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, seededTurns+1) + + var updated []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[0].Content, &updated)) + // V2-C5: no new content-block status — the canceled call + // completed with a valid result. + assert.Equal(t, conversation.StatusSuccess, updated[0].Status) + require.NotNil(t, updated[0].Shared) + assert.True(t, *updated[0].Shared) + + var resultBlocks []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[len(turns)-1].Content, &resultBlocks)) + require.Len(t, resultBlocks, 1) + assert.Equal(t, conversation.BlockTypeToolResult, resultBlocks[0].Type) + assert.Equal(t, "ask-1", resultBlocks[0].ToolUseID) + assert.Equal(t, conversation.StatusSuccess, resultBlocks[0].Status) + require.NotNil(t, resultBlocks[0].Shared) + assert.True(t, *resultBlocks[0].Shared) + assert.NotNil(t, resultBlocks[0].DecidedAt) + assert.JSONEq(t, `{"status":"canceled","target_username":"bob","answer_received":false,"canceled_by":"requester"}`, resultBlocks[0].Content) + + if tc.pointerMissing { + assert.Nil(t, patchedPost, "a lost pointer degrades to an unpatched card, not an error") + } else { + require.NotNil(t, patchedPost, "the target's card must be patched to canceled") + assert.Equal(t, AskUserStatusCanceled, patchedPost.GetProp(AskUserStatusProp)) + assert.Equal(t, "This question is no longer needed.", patchedPost.Message) + } + + assert.Equal(t, 1, publishes, "cancel must refresh the initiator's conversation view") + + if tc.wantFollowUp { + assert.Len(t, lm.requests, 1, "expected exactly one follow-up LLM request") + } else { + assert.Empty(t, lm.requests, "follow-up must wait for the remaining unresolved work") + } + }) + } +} + +// TestCancelAnswerRace pins V2-C4's single-resolution guarantee: answer and +// cancel contend for the same one-shot claim, so whichever lands first wins +// and exactly one tool_result turn is written. A late answer after cancel is a +// successful canceled no-op; a late cancel after answer remains a conflict. +func TestCancelAnswerRace(t *testing.T) { + askInput := `{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}` + + cases := []struct { + name string + cancelFirst bool + wantResult string + }{ + { + name: "cancel wins and a late answer is a graceful no-op", + cancelFirst: true, + wantResult: `{"status":"canceled","target_username":"bob","answer_received":false,"canceled_by":"requester"}`, + }, + { + name: "answer wins and a late cancel is a graceful no-op", + cancelFirst: false, + wantResult: `{"status":"answered","target_username":"bob","selected":["Prod"],"free_form":""}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + blocks := []conversation.ContentBlock{{ + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: "AskAnotherUser", + Input: json.RawMessage(askInput), + Status: conversation.StatusWaiting, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }} + content, err := json.Marshal(blocks) + require.NoError(t, err) + anchorPostID := "anchor-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &anchorPostID, + Role: "assistant", + Content: content, + Sequence: 1, + })) + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + anchorChannel := &model.Channel{Id: "dm-channel", Type: model.ChannelTypeDirect, Name: "bot-id__user-id"} + anchorPost := &model.Post{Id: anchorPostID, UserId: "bot-id", ChannelId: anchorChannel.Id} + anchorPost.AddProp(streaming.ConversationIDProp, conv.ID) + + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserConversationIDProp, conv.ID) + cardPost.AddProp(AskUserToolUseIDProp, "ask-1") + + mmClient := mocks.NewMockClient(t) + for i := 1; i <= 7; i++ { + logArgs := make([]interface{}, i) + for j := range logArgs { + logArgs[j] = mock.Anything + } + mmClient.On("LogDebug", logArgs...).Maybe().Return() + mmClient.On("LogError", logArgs...).Maybe().Return() + mmClient.On("LogWarn", logArgs...).Maybe().Return() + } + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + // The one-shot claim: the winner takes it, any later contender + // loses. The status guard usually fires first for the loser; + // the Maybe'd false return covers the pure-race window. + mmClient.On("KVCompareAndSet", "askclaim_answer_ask-1", mock.Anything, mock.Anything). + Return(true, nil).Once() + mmClient.On("KVCompareAndSet", "askclaim_answer_ask-1", mock.Anything, mock.Anything). + Maybe().Return(false, nil) + mmClient.On("KVGet", askCardKVPrefix+"ask-1", mock.Anything).Maybe(). + Run(func(args mock.Arguments) { *(args.Get(1).(*string)) = "card-post-id" }). + Return(nil) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetPost", anchorPostID).Maybe().Return(anchorPost, nil) + mmClient.On("GetPost", "card-post-id").Maybe().Return(cardPost, nil) + mmClient.On("GetChannel", anchorChannel.Id).Maybe().Return(anchorChannel, nil) + mmClient.On("UpdatePost", mock.AnythingOfType("*model.Post")).Maybe().Return(nil) + mmClient.On("PublishWebSocketEvent", "conversation_updated", mock.Anything, mock.Anything).Maybe().Return() + + streamingService := &loadedStateStreamingService{} + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: streamingService, + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + cancel := func() error { + return c.HandleAskUserCancel(context.Background(), "user-id", anchorPost, anchorChannel, "ask-1") + } + answer := func() (string, error) { + cardChannel := &model.Channel{Id: "card-dm", Type: model.ChannelTypeDirect, Name: "bob-id__bot-id"} + return c.HandleAskUserResponse(context.Background(), "bob-id", cardPost, cardChannel, AskUserResponse{ + Action: AskUserActionAnswer, + Selected: []string{"Prod"}, + }) + } + + if tc.cancelFirst { + require.NoError(t, cancel()) + streamingService.waitForStreaming() + + status, responseErr := answer() + require.NoError(t, responseErr) + require.Equal(t, AskUserStatusCanceled, status) + } else { + status, responseErr := answer() + require.NoError(t, responseErr) + require.Equal(t, AskUserStatusAnswered, status) + streamingService.waitForStreaming() + + require.ErrorIs(t, cancel(), ErrAskNotPending) + } + + turns, turnsErr := convStore.GetTurnsForConversation(conv.ID) + require.NoError(t, turnsErr) + require.Len(t, turns, 2, "exactly one resolution may write a tool_result turn") + var resultBlocks []conversation.ContentBlock + require.NoError(t, json.Unmarshal(turns[1].Content, &resultBlocks)) + require.Len(t, resultBlocks, 1) + assert.JSONEq(t, tc.wantResult, resultBlocks[0].Content) + + assert.Len(t, lm.requests, 1, "the resume must stream exactly once, for the winner") + }) + } +} + +// TestChannelMixedBatchShareAnswerOrdering is the MAJOR-1 regression: a +// channel conversation with a mixed batch (normal tool + AskAnotherUser) +// must stream the resume exactly once, only after BOTH the requester's +// Share/Keep-Private decision and the target's answer are in — in either +// order. A premature stream would feed the model a dangling waiting +// tool_use and demote the anchor turn, orphaning the other half's resume. +func TestChannelMixedBatchShareAnswerOrdering(t *testing.T) { + askInput := json.RawMessage(`{"username":"bob","question":"Which environment?","options":[{"label":"Prod"},{"label":"Staging"}]}`) + + cases := []struct { + name string + shareFirst bool + firstMsg string + }{ + { + name: "share before answer defers the stream to the answer", + shareFirst: true, + firstMsg: "the Share click must not stream while the question is still waiting", + }, + { + name: "answer before share defers the stream to the share click", + shareFirst: false, + firstMsg: "the answer must not stream while the share decision is still outstanding", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + convStore, conv := loadedStateConversationStore() + nextSeq := 1 + seedLoadToolPair(t, convStore, conv.ID, "load-1", "jira__get_issue", &nextSeq) + + blocks := []conversation.ContentBlock{ + { + Type: conversation.BlockTypeToolUse, + ID: "tool-use-1", + Name: "jira__get_issue", + Input: json.RawMessage(`{}`), + Status: conversation.StatusPending, + Shared: conversation.BoolPtr(false), + }, + { + Type: conversation.BlockTypeToolUse, + ID: "ask-1", + Name: "AskAnotherUser", + Input: askInput, + Status: conversation.StatusPending, + DeferredResult: true, + Shared: conversation.BoolPtr(false), + }, + } + content, err := json.Marshal(blocks) + require.NoError(t, err) + anchorPostID := "approval-post-id" + require.NoError(t, convStore.CreateTurn(&store.Turn{ + ID: "assistant-turn", + ConversationID: conv.ID, + PostID: &anchorPostID, + Role: "assistant", + Content: content, + Sequence: nextSeq, + })) + + lm := &loadedStateLLM{} + bot := loadedStateBot(lm) + + channel := &model.Channel{Id: "town-square", Type: model.ChannelTypeOpen, Name: "town-square", TeamId: "team-id"} + anchorPost := &model.Post{Id: anchorPostID, UserId: "bot-id", ChannelId: channel.Id} + anchorPost.AddProp(streaming.ConversationIDProp, conv.ID) + + cardPost := &model.Post{Id: "card-post-id", UserId: "bot-id", Type: AskUserPostType} + cardPost.AddProp(AskUserTargetIDProp, "bob-id") + cardPost.AddProp(AskUserConversationIDProp, conv.ID) + cardPost.AddProp(AskUserToolUseIDProp, "ask-1") + + mmClient := mocks.NewMockClient(t) + mmClient.On("LogDebug", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("LogError", mock.Anything, mock.Anything).Maybe().Return() + mmClient.On("GetConfig").Maybe().Return(&model.Config{}) + mmClient.On("KVGet", mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("KVCompareAndSet", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(true, nil) + mmClient.On("GetUser", "user-id").Maybe().Return(&model.User{Id: "user-id", Username: "user"}, nil) + mmClient.On("GetUser", "bob-id").Maybe().Return(&model.User{Id: "bob-id", Username: "bob"}, nil) + mmClient.On("GetUserByUsername", "bob").Return(&model.User{Id: "bob-id", Username: "bob"}, nil).Once() + mmClient.On("DM", "bot-id", "bob-id", mock.AnythingOfType("*model.Post")).Return(nil).Once() + mmClient.On("GetPost", anchorPostID).Maybe().Return(anchorPost, nil) + mmClient.On("GetPost", "card-post-id").Maybe().Return(cardPost, nil) + mmClient.On("GetChannel", channel.Id).Maybe().Return(channel, nil) + mmClient.On("GetChannelStats", channel.Id).Maybe().Return(nil, errors.New("stats unavailable")) + mmClient.On("UpdatePost", mock.AnythingOfType("*model.Post")).Maybe().Return(nil) + mmClient.On("KVSetWithExpiry", mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil) + mmClient.On("PublishWebSocketEvent", "conversation_updated", mock.Anything, mock.Anything).Maybe().Return() + + streamingService := &loadedStateStreamingService{} + c := &Conversations{ + mmClient: mmClient, + contextBuilder: askAnotherUserBuilder(t), + bots: newAskAnotherUserBotsService(t, bot), + convService: conversation.NewService(convStore, nil, nil, nil), + streamingService: streamingService, + configProvider: &channelFollowUpTestConfig{enableAskAnotherUser: true}, + } + + // Stage 1: the initiator accepts both. The normal tool executes + // with an undecided channel share result, the ask parks waiting, + // and nothing streams. + require.NoError(t, c.HandleToolCall(context.Background(), "user-id", anchorPost, channel, []string{"tool-use-1", "ask-1"}, nil)) + require.Empty(t, lm.requests, "a mixed batch with a waiting question must not stream from HandleToolCall") + + share := func() error { + return c.HandleToolResult(context.Background(), "user-id", anchorPost, channel, []string{"tool-use-1"}) + } + answer := func() error { + cardChannel := &model.Channel{Id: "card-dm", Type: model.ChannelTypeDirect, Name: "bob-id__bot-id"} + _, responseErr := c.HandleAskUserResponse(context.Background(), "bob-id", cardPost, cardChannel, AskUserResponse{ + Action: AskUserActionAnswer, + Selected: []string{"Prod"}, + }) + return responseErr + } + + first, second := answer, share + if tc.shareFirst { + first, second = share, answer + } + + require.NoError(t, first()) + require.Empty(t, lm.requests, tc.firstMsg) + + require.NoError(t, second()) + streamingService.waitForStreaming() + require.Len(t, lm.requests, 1, "the resume must stream exactly once, after both the share decision and the answer") + }) + } +} + +func TestAskUserAnswerPreview(t *testing.T) { + long := make([]rune, 0, 300) + for i := 0; i < 300; i++ { + long = append(long, 'é') + } + + cases := []struct { + name string + selected []string + freeForm string + want string + }{ + {name: "labels only", selected: []string{"A", "B"}, want: "A, B"}, + {name: "free-form only", freeForm: "hello", want: "hello"}, + {name: "labels and free-form", selected: []string{"A"}, freeForm: "extra", want: "A — extra"}, + {name: "whitespace free-form dropped", selected: []string{"A"}, freeForm: " ", want: "A"}, + {name: "empty", want: ""}, + {name: "long preview truncates to 200 runes", freeForm: string(long), want: string(long[:200])}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, askUserAnswerPreview(tc.selected, tc.freeForm)) + }) + } +} diff --git a/conversations/bot_channel_tool_filter.go b/conversations/bot_channel_tool_filter.go index 1b659922f..df44edbc2 100644 --- a/conversations/bot_channel_tool_filter.go +++ b/conversations/bot_channel_tool_filter.go @@ -92,6 +92,9 @@ func botChannelAutoEverywhereKeepTool(checker mcp.ToolPolicyChecker, tool llm.To return false } if tool.ServerOrigin == "" { + // Built-in tools never survive unattended activate_ai flows. In + // particular AskAnotherUser must never fire from a flow with no + // accountable human behind it. return false } policy, enabled := checker.GetToolPolicy(tool.ServerOrigin, llm.BareMCPToolName(tool.Name)) diff --git a/conversations/bot_channel_tool_filter_test.go b/conversations/bot_channel_tool_filter_test.go index 4c2b39af5..e4335000a 100644 --- a/conversations/bot_channel_tool_filter_test.go +++ b/conversations/bot_channel_tool_filter_test.go @@ -58,12 +58,17 @@ func (p *channelFollowUpTestMCPToolProvider) GetToolsForUser(context.Context, st type channelFollowUpTestConfig struct { enableChannelMentionToolCalling bool + enableAskAnotherUser bool } func (c *channelFollowUpTestConfig) EnableChannelMentionToolCalling() bool { return c.enableChannelMentionToolCalling } +func (c *channelFollowUpTestConfig) EnableAskAnotherUser() bool { + return c.enableAskAnotherUser +} + func (c *channelFollowUpTestConfig) AllowNativeWebSearchInChannels() bool { return false } diff --git a/conversations/conversations.go b/conversations/conversations.go index 2a00c26df..92bda8db1 100644 --- a/conversations/conversations.go +++ b/conversations/conversations.go @@ -30,6 +30,7 @@ const AnalysisTypeProp = "prompt_type" // ConfigProvider provides configuration values for conversation behavior type ConfigProvider interface { EnableChannelMentionToolCalling() bool + EnableAskAnotherUser() bool AllowNativeWebSearchInChannels() bool MCP() mcp.Config } @@ -212,7 +213,18 @@ func (c *Conversations) ProcessDMRequest( return nil, fmt.Errorf("failed to build completion request: %w", err) } - runner := toolrunner.New(lm, toolrunner.WithMaxRounds(maxToolTurns)) + runnerOpts := []toolrunner.Option{toolrunner.WithMaxRounds(maxToolTurns)} + // The initial-DM entry point receives only the language model, so the + // deferred dispatcher's bot is resolved from the conversation. When the + // bot cannot be resolved, run without a dispatcher — the runner's + // no-dispatcher fallback emits deferred batches pending instead. + if c.bots != nil { + if bot := c.bots.GetBotByID(conv.BotID); bot != nil { + runnerOpts = append(runnerOpts, toolrunner.WithDeferredDispatcher( + c.newDeferredDispatcherForConversation(bot, conv, ""))) + } + } + runner := toolrunner.New(lm, runnerOpts...) runResult, err := runner.Run(ctx, *completionReq, c.shouldAutoExecuteTool(llmCtx, true), func(turns []toolrunner.ToolTurn) { if writeErr := c.convService.WriteToolTurns(convID, turns, true); writeErr != nil { c.mmClient.LogError("Failed to write tool turns", "error", writeErr, "conversation_id", convID) diff --git a/conversations/handle_messages.go b/conversations/handle_messages.go index e02dc7c9c..494ae76a6 100644 --- a/conversations/handle_messages.go +++ b/conversations/handle_messages.go @@ -370,7 +370,9 @@ func (c *Conversations) handleMentionViaConversation( } } - runner := toolrunner.New(bot.LLM(), toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns())) + runner := toolrunner.New(bot.LLM(), + toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns()), + toolrunner.WithDeferredDispatcher(c.newDeferredDispatcherForConversation(bot, convResult.Conversation, ""))) // Channel mention: isDM=false gates auto-exec to auto_run_everywhere only. autoExec := c.shouldAutoExecuteTool(llmContext, false) result, runErr := runner.Run(ctx, *completionRequest, func(tc llm.ToolCall) bool { diff --git a/conversations/regeneration.go b/conversations/regeneration.go index f2b6a2368..b11d29a5b 100644 --- a/conversations/regeneration.go +++ b/conversations/regeneration.go @@ -303,7 +303,9 @@ func (c *Conversations) regenerateViaConversation( } } - runner := toolrunner.New(bot.LLM(), toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns())) + runner := toolrunner.New(bot.LLM(), + toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns()), + toolrunner.WithDeferredDispatcher(c.newDeferredDispatcherForConversation(bot, conv, post.Id))) runResult, runErr := runner.Run(ctx, *completionReq, c.shouldAutoExecuteTool(llmContext, isDM), func(turns []toolrunner.ToolTurn) { shared := isDM || c.allToolsAutoRunEverywhere(turns, llmContext) if writeErr := c.convService.WriteToolTurns(conv.ID, turns, shared); writeErr != nil { diff --git a/conversations/test_helpers_test.go b/conversations/test_helpers_test.go index f4a0dca05..4ae13b578 100644 --- a/conversations/test_helpers_test.go +++ b/conversations/test_helpers_test.go @@ -162,6 +162,10 @@ func (c *fakeMMClient) GetChannel(channelID string) (*model.Channel, error) { return nil, errors.New("channel not found") } +func (c *fakeMMClient) GetChannelStats(string) (*model.ChannelStats, error) { + return nil, errors.New("not implemented") +} + func (c *fakeMMClient) GetDirectChannel(string, string) (*model.Channel, error) { return nil, errors.New("not implemented") } @@ -268,12 +272,17 @@ func (p *testToolProvider) GetTools(_ *bots.Bot, _ *llm.Context) []llm.Tool { // testToolCallingConfig implements conversations.ConfigProvider for testing type testToolCallingConfig struct { enableChannelMentionToolCalling bool + enableAskAnotherUser bool } func (c *testToolCallingConfig) EnableChannelMentionToolCalling() bool { return c.enableChannelMentionToolCalling } +func (c *testToolCallingConfig) EnableAskAnotherUser() bool { + return c.enableAskAnotherUser +} + func (c *testToolCallingConfig) AllowNativeWebSearchInChannels() bool { return false } diff --git a/conversations/tool_approval.go b/conversations/tool_approval.go index 62f0e5f3a..7db61e6e0 100644 --- a/conversations/tool_approval.go +++ b/conversations/tool_approval.go @@ -198,6 +198,54 @@ func (c *Conversations) HandleToolCall(ctx context.Context, userID string, post } switch { + case block.DeferredResult && (slices.Contains(acceptedToolIDs, block.ID) || + (block.WouldAutoExecute && autoExec(llm.ToolCall{Name: block.Name, ServerOrigin: block.ServerOrigin}))): + // Deferred-result tool (AskAnotherUser): dispatch the side effect + // and park the block in waiting. Flip-and-persist BEFORE + // dispatching so a second Accept click can never send a second + // card (findPendingToolTurn requires a pending block → repeat + // click gets ErrStaleToolClick). + block.Status = conversation.StatusWaiting + if persistErr := c.persistBlocks(pendingTurn.ID, pendingBlocks); persistErr != nil { + return fmt.Errorf("failed to persist waiting status: %w", persistErr) + } + // Atomic dispatch claim: the persist above stops sequential + // repeat clicks (findPendingToolTurn no longer sees a pending + // block), and this CAS closes the concurrent window (two tabs, + // two HA nodes) in which both requests read the block as pending + // — only the claim winner sends the card. + won, claimErr := c.claimAskToolUse(askClaimStageDispatch, block.ID, askClaimStageDispatch) + if claimErr != nil { + return fmt.Errorf("failed to claim deferred dispatch: %w", claimErr) + } + if !won { + return ErrStaleToolClick + } + if dispatchErr := c.dispatchAskAnotherUser(ctx, bot, conv, post.Id, block.ID, block.Input); dispatchErr != nil { + // Compensate: waiting → error, and surface the failure as an + // error tool result so the model can retry. + block.Status = conversation.StatusError + if persistErr := c.persistBlocks(pendingTurn.ID, pendingBlocks); persistErr != nil { + // Double fault: the block is persisted as waiting even + // though no card went out, so nothing will ever resolve + // it. Name the stuck IDs so an operator can find it. + c.mmClient.LogError("AskAnotherUser dispatch and compensating persist both failed; tool_use block stranded in waiting", + "tool_use_id", block.ID, + "conversation_id", convID, + "dispatch_error", dispatchErr.Error(), + "persist_error", persistErr.Error(), + ) + return fmt.Errorf("failed to persist dispatch failure: %w", persistErr) + } + executedAny = true + toolResults = append(toolResults, toolrunner.ToolResult{ + ToolCallID: block.ID, + Name: block.Name, + Result: dispatchErr.Error(), + IsError: true, + }) + } + // Success: no tool result yet; the follow-up is gated below. case slices.Contains(acceptedToolIDs, block.ID) && block.UserInteraction != "": acceptedToolNames = append(acceptedToolNames, block.Name) // Shared so the channel-visible follow-up may reference the answer. @@ -294,69 +342,86 @@ func (c *Conversations) HandleToolCall(ctx context.Context, userID string, post audit.AddParam(auditRec, "rejected_tools", rejectedToolNames) // Update the assistant turn with resolved statuses. - updatedContent, err := json.Marshal(pendingBlocks) - if err != nil { - return fmt.Errorf("failed to marshal updated blocks: %w", err) - } - if updateErr := c.convService.UpdateTurnContent(pendingTurn.ID, updatedContent); updateErr != nil { - return fmt.Errorf("failed to update turn with resolved statuses: %w", updateErr) + if persistErr := c.persistBlocks(pendingTurn.ID, pendingBlocks); persistErr != nil { + return fmt.Errorf("failed to update turn with resolved statuses: %w", persistErr) } // Write tool results as a tool_result turn. DecidedAt is set when no // share/keep-private decision remains (see terminal below); other channel // results stay undecided until the requester clicks Share or Keep Private. - toolUseStatusByID := make(map[string]string, len(pendingBlocks)) - interactionByID := make(map[string]bool, len(pendingBlocks)) - for _, b := range pendingBlocks { - if b.Type == conversation.BlockTypeToolUse { - toolUseStatusByID[b.ID] = b.Status - interactionByID[b.ID] = b.UserInteraction != "" - } - } - now := model.GetMillis() + // A successfully dispatched deferred call produces no result, so guard the + // turn write: an empty tool_result turn would pollute GetTurns. needsShareDecision := false - resultBlocks := make([]conversation.ContentBlock, 0, len(toolResults)) - for _, tr := range toolResults { - status := conversation.StatusSuccess - if tr.IsError { - status = conversation.StatusError - } - // Interaction results (answered or skipped) are user-authored, so they - // are terminal and shared with no separate share/keep-private step. - terminal := isDM || interactionByID[tr.ToolCallID] || autoExecutedNow[tr.ToolCallID] - rb := conversation.ContentBlock{ - Type: conversation.BlockTypeToolResult, - ToolUseID: tr.ToolCallID, - Content: tr.Result, - Status: status, - Shared: conversation.BoolPtr(terminal), - } - if terminal || toolUseStatusByID[tr.ToolCallID] == conversation.StatusRejected { - rb.DecidedAt = conversation.Int64Ptr(now) - } else { - needsShareDecision = true - } - resultBlocks = append(resultBlocks, rb) - } - resultContent, err := json.Marshal(resultBlocks) - if err != nil { - return fmt.Errorf("failed to marshal tool result blocks: %w", err) - } - resultTurn := &store.Turn{ - ID: model.NewId(), - ConversationID: convID, - Role: "tool_result", - Content: resultContent, - CreatedAt: model.GetMillis(), + if len(toolResults) > 0 { + toolUseStatusByID := make(map[string]string, len(pendingBlocks)) + interactionByID := make(map[string]bool, len(pendingBlocks)) + for _, b := range pendingBlocks { + if b.Type == conversation.BlockTypeToolUse { + toolUseStatusByID[b.ID] = b.Status + interactionByID[b.ID] = b.UserInteraction != "" + } + } + now := model.GetMillis() + resultBlocks := make([]conversation.ContentBlock, 0, len(toolResults)) + for _, tr := range toolResults { + status := conversation.StatusSuccess + if tr.IsError { + status = conversation.StatusError + } + // Interaction results (answered or skipped) are user-authored, so they + // are terminal and shared with no separate share/keep-private step. + terminal := isDM || interactionByID[tr.ToolCallID] || autoExecutedNow[tr.ToolCallID] + rb := conversation.ContentBlock{ + Type: conversation.BlockTypeToolResult, + ToolUseID: tr.ToolCallID, + Content: tr.Result, + Status: status, + Shared: conversation.BoolPtr(terminal), + } + if terminal || toolUseStatusByID[tr.ToolCallID] == conversation.StatusRejected { + rb.DecidedAt = conversation.Int64Ptr(now) + } else { + needsShareDecision = true + } + resultBlocks = append(resultBlocks, rb) + } + resultContent, marshalErr := json.Marshal(resultBlocks) + if marshalErr != nil { + return fmt.Errorf("failed to marshal tool result blocks: %w", marshalErr) + } + resultTurn := &store.Turn{ + ID: model.NewId(), + ConversationID: convID, + Role: "tool_result", + Content: resultContent, + CreatedAt: model.GetMillis(), + } + if err := c.convService.CreateTurnAutoSequence(resultTurn); err != nil { + return fmt.Errorf("failed to create tool result turn: %w", err) + } } - if err := c.convService.CreateTurnAutoSequence(resultTurn); err != nil { - return fmt.Errorf("failed to create tool result turn: %w", err) + + // A waiting block means a question card just went out: refresh the + // initiator's conversation view so the Accept/Reject controls leave the + // pending state live. + hasWaiting := slices.ContainsFunc(pendingBlocks, func(b conversation.ContentBlock) bool { + return b.Type == conversation.BlockTypeToolUse && b.Status == conversation.StatusWaiting + }) + if hasWaiting { + c.publishConversationUpdated(convID, channel.Id) } if !executedAny { return nil } + // A deferred call parked in waiting blocks the follow-up: the answer + // handler streams it once the last outstanding block resolves (C3 resume + // invariant: no pending, accepted, or waiting blocks may remain). + if hasUnresolvedToolUse(pendingBlocks) { + return nil + } + // In channels the follow-up is a channel-visible post that may paraphrase tool // output, so it must not stream until the requester approves sharing in // HandleToolResult. When no share decision remains (every executed result @@ -369,6 +434,35 @@ func (c *Conversations) HandleToolCall(ctx context.Context, userID string, post return c.streamToolFollowUp(ctx, bot, user, channel, post, conv, isDM, llmContext) } +// persistBlocks marshals blocks and writes them onto the turn. +func (c *Conversations) persistBlocks(turnID string, blocks []conversation.ContentBlock) error { + content, err := json.Marshal(blocks) + if err != nil { + return fmt.Errorf("failed to marshal blocks: %w", err) + } + return c.convService.UpdateTurnContent(turnID, content) +} + +// hasUnresolvedToolUse reports whether any tool_use block still awaits +// resolution: pending (needs an approval click), accepted (decision recorded +// but not yet executed), or waiting (deferred question outstanding). This is +// the C3 resume invariant shared by HandleToolCall, HandleToolResult, and +// HandleAskUserResponse: a follow-up must never stream while such a block +// remains on the anchor turn. +func hasUnresolvedToolUse(blocks []conversation.ContentBlock) bool { + for _, b := range blocks { + if b.Type != conversation.BlockTypeToolUse { + continue + } + if b.Status == conversation.StatusPending || + b.Status == conversation.StatusAccepted || + b.Status == conversation.StatusWaiting { + return true + } + } + return false +} + // resolveInteractionAnswers validates the user's answers for every accepted // pending user-interaction block and returns the tool result content keyed by // block ID. Any invalid or missing answer fails the whole request with @@ -454,6 +548,7 @@ func (c *Conversations) HandleToolResult(ctx context.Context, userID string, pos // actually executed to decide whether a follow-up stream is warranted. clickedPostToolUseIDs := make(map[string]struct{}) clickedPostHasExecutedTool := false + anchorHasUnresolvedToolUse := false acceptedToolNames := []string{} rejectedToolNames := []string{} acceptedRemoteMCPTool := false @@ -465,6 +560,9 @@ func (c *Conversations) HandleToolResult(ctx context.Context, userID string, pos if unmarshalErr := json.Unmarshal(turn.Content, &blocks); unmarshalErr != nil { continue } + if hasUnresolvedToolUse(blocks) { + anchorHasUnresolvedToolUse = true + } for _, b := range blocks { if b.Type != conversation.BlockTypeToolUse || b.ID == "" { continue @@ -584,6 +682,17 @@ func (c *Conversations) HandleToolResult(ctx context.Context, userID string, pos return nil } + // C3 resume invariant: the share decision above is recorded, but the + // follow-up must not stream while any tool_use on the anchor turn is + // still pending, accepted, or waiting — e.g. a mixed batch whose + // AskAnotherUser question is unanswered. Streaming now would feed the + // model a dangling tool_use and demote the anchor turn, orphaning the + // eventual answer's resume; HandleAskUserResponse streams once the last + // block resolves. + if anchorHasUnresolvedToolUse { + return nil + } + user, err := c.mmClient.GetUser(userID) if err != nil { return fmt.Errorf("unable to get user: %w", err) @@ -659,7 +768,9 @@ func (c *Conversations) streamToolFollowUp( } } - runner := toolrunner.New(bot.LLM(), toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns())) + runner := toolrunner.New(bot.LLM(), + toolrunner.WithMaxRounds(bot.GetConfig().EffectiveMaxToolTurns()), + toolrunner.WithDeferredDispatcher(c.newDeferredDispatcherForConversation(bot, conv, post.Id))) runResult, err := runner.Run(ctx, *completionReq, c.shouldAutoExecuteTool(llmContext, isDM), func(turns []toolrunner.ToolTurn) { shared := isDM || c.allToolsAutoRunEverywhere(turns, llmContext) if writeErr := c.convService.WriteToolTurns(conv.ID, turns, shared); writeErr != nil { diff --git a/conversations/tool_policy_test.go b/conversations/tool_policy_test.go index 765606a8b..4e9cb707f 100644 --- a/conversations/tool_policy_test.go +++ b/conversations/tool_policy_test.go @@ -8,6 +8,7 @@ import ( "github.com/mattermost/mattermost-plugin-agents/v2/llm" "github.com/mattermost/mattermost-plugin-agents/v2/mcp" + "github.com/mattermost/mattermost-plugin-agents/v2/mmtools" "github.com/mattermost/mattermost-plugin-agents/v2/toolrunner" "github.com/stretchr/testify/assert" ) @@ -115,6 +116,43 @@ func TestShouldAutoExecuteToolMetaToolDoesNotAuthorizeBusinessTool(t *testing.T) assert.False(t, c.shouldAutoExecuteTool(nil, true)(llm.ToolCall{Name: "jira__get_issue"})) } +// TestShouldAutoExecuteTool_DeferredToolFollowsPolicy pins that deferred-result +// tools (AskAnotherUser) follow the normal policy matrix keyed on the +// empty-string origin — they must NOT be exempted like UserInteraction tools, +// because "auto-execute" for a deferred tool means dispatching the card, not +// skipping a question. +func TestShouldAutoExecuteTool_DeferredToolFollowsPolicy(t *testing.T) { + cases := []struct { + name string + isDM bool + policy string + enabled bool + want bool + }{ + {name: "DM + auto_run_in_dm enabled -> dispatch without approval", isDM: true, policy: mcp.ToolPolicyAutoRunInDM, enabled: true, want: true}, + {name: "channel + auto_run_in_dm -> approve (DM-only policy)", isDM: false, policy: mcp.ToolPolicyAutoRunInDM, enabled: true, want: false}, + {name: "DM + disabled -> approve", isDM: true, policy: mcp.ToolPolicyAutoRunInDM, enabled: false, want: false}, + {name: "DM + ask -> approve", isDM: true, policy: mcp.ToolPolicyAsk, enabled: true, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &Conversations{ + // Built-in tools carry the empty-string origin. + toolPolicyChecker: mapPolicyChecker{ + "": {mmtools.AskAnotherUserToolName: {policy: tc.policy, enabled: tc.enabled}}, + }, + } + llmCtx := &llm.Context{Tools: llm.NewToolStore()} + llmCtx.Tools.AddTools([]llm.Tool{mmtools.NewAskAnotherUserTool()}) + + got := c.shouldAutoExecuteTool(llmCtx, tc.isDM)(llm.ToolCall{Name: mmtools.AskAnotherUserToolName}) + + assert.Equal(t, tc.want, got) + }) + } +} + // TestShouldAutoExecuteTool_AutoExecuteBuiltIn pins that auto-execute // built-ins (e.g. CreateFile) bypass approval in both DMs and channels — even // with no policy checker wired up — while an MCP tool carrying the flag must diff --git a/docs/admin_guide.md b/docs/admin_guide.md index 6a5f5009f..e7bb15101 100644 --- a/docs/admin_guide.md +++ b/docs/admin_guide.md @@ -282,6 +282,41 @@ To obtain Google Custom Search credentials: - Search results include clickable citations that link back to source websites - Domain denylisting applies to all providers and is enforced for _web page fetching only_. +### Ask another user (experimental) + +The built-in `AskAnotherUser` tool lets an agent send a clarifying question to another Mattermost user as an interactive direct-message card, on behalf of the person who asked the agent. The whole capability is gated by the experimental **Enable Agents to Ask Other Users** setting in the **AI Functions** panel of the plugin's System Console page, and is **off by default**. + +Consider the implications before enabling: the question text is written by the AI model, so users can receive AI-authored messages they never asked for; answers flow back into the originating conversation and may appear in channel-visible follow-up messages; and this expands the surface for prompt-injection and social-engineering attempts. Question cards mitigate this by naming the requester, disclosing where the answer will go, and visually separating AI-generated text from system text (see [asking another user](features/multiplayer_tool_calling.md#10-asking-another-user-the-target-as-a-second-actor) in the multiplayer reference for the full trust model). + +While the setting is off, the tool isn't offered to models at all, and the answer and cancel endpoints refuse requests. If you turn the setting off while a question is outstanding, the target can no longer answer and the initiator can no longer cancel — the conversation stays parked on the waiting tool call. To unstick it, either re-enable the setting so the outstanding question can be resolved, or have the initiator regenerate the conversation (which supersedes the waiting call). + +When the feature is on, the standard approval policies apply per the [built-in tool policies](#built-in-tool-policies) section; the toggle is the master switch, and off wins over any configured policy. + +### Built-in tool policies + +Built-in (non-MCP) tools — such as `AskAnotherUser` and the built-in web search tool — follow the same three approval policies as MCP tools (`ask`, `auto_run_in_dm`, `auto_run_everywhere`; see the [multiplayer tool calling](features/multiplayer_tool_calling.md) reference). Unconfigured built-in tools default to policy `ask`, which always requires the initiator's approval. `AskAnotherUser` ships with an explicit `ask` default and is additionally gated by the [Ask another user](#ask-another-user-experimental) master switch — its policy only matters while that setting is on. + +There is no System Console panel for built-in tool policies in this release. Configure them through the plugin's admin config API: `GET` the current configuration from `/plugins/mattermost-ai/admin/config`, add or edit the `mcp.builtInTools` array, and `PUT` the **full** configuration object back (the endpoint replaces the stored configuration; don't send a partial body). Example fragment of the configuration object: + +```json +{ + "mcp": { + "builtInTools": [ + {"name": "AskAnotherUser", "policy": "auto_run_in_dm", "enabled": true} + ] + } +} +``` + +Semantics: + +- `policy` accepts `ask`, `auto_run_in_dm`, and `auto_run_everywhere`; invalid values fall back to `ask`. +- `enabled: false` prevents the tool from auto-running; combined with `ask`, the tool still shows the Accept/Reject card. This setting doesn't remove built-in tools from the Agent's catalog. +- Entries here override the shipped defaults; built-in tools you don't list keep the default `ask` policy. +- `AskAnotherUser` never runs from automated `activate_ai` bot flows regardless of policy (see [bot-triggered flows](features/multiplayer_tool_calling.md#9-bot-triggered-flows) in the multiplayer reference). + +For the question-card experience on the receiving end of `AskAnotherUser`, see [Answer a question an agent asks you](user_guide.md#answer-a-question-an-agent-asks-you) in the user guide. + ### Embed search configuration To enable semantic search capabilities, you'll need to enable the `pgvector` extension in your PostgreSQL database, then configure embeddings provider settings including the provider (OpenAI, etc.), model for embeddings, and dimensions that match your chosen embedding model. Embedding search requires a license (see [license requirements](#license-requirements)) and is available as an [experimental](https://docs.mattermost.com/manage/feature-labels.html#experimental) feature. Performance may vary with large datasets. @@ -555,7 +590,7 @@ When users report repeated tool failures, use **LLM Trace** and debug logging to ## Integrations -Integrations are available in direct messages by default. If you enable the experimental **Enable Channel Mention Tool Calling** setting, @mentioning an agent in a public channel can also allow tool calling there. Native provider web search in public and private channels is controlled separately by **Allow native web search in channels**. +Integrations are available in direct messages by default. If you enable the experimental **Enable Channel Mention Tool Calling** setting, @mentioning an agent in a public channel can also allow tool calling there. Native provider web search in public and private channels is controlled separately by **Allow native web search in channels**, and the ability of agents to send question cards to other users is controlled by the experimental **Enable Agents to Ask Other Users** setting (see [Ask another user](#ask-another-user-experimental)). ### File creation by agents diff --git a/docs/features/multiplayer_tool_calling.md b/docs/features/multiplayer_tool_calling.md index 030f812bf..840848d43 100644 --- a/docs/features/multiplayer_tool_calling.md +++ b/docs/features/multiplayer_tool_calling.md @@ -14,9 +14,9 @@ This document is the canonical reference for those rules. It is aimed at: End-user instructions (how to click **Accept** or **Reject** in the moment) live in the [user guide](../user_guide.md#use-tools). This document explains the model behind those clicks. -## 2. The four roles +## 2. The five roles -Every tool call in a channel involves up to four distinct roles. These terms are used consistently throughout the rest of this document. +Every tool call in a channel involves up to five distinct roles. These terms are used consistently throughout the rest of this document. | Role | Definition | |---|---| @@ -24,6 +24,7 @@ Every tool call in a channel involves up to four distinct roles. These terms are | **Approver** | The user permitted to click **Accept** or **Reject** on a pending tool-call card. In multiplayer tool calling, **the approver is always the initiator** — never another channel member, never an admin. | | **Executor** | The identity Mattermost uses when actually running the tool — opening the HTTP request, hitting the MCP server, reading channel posts, etc. The executor inherits the initiator's user identity and the initiator's per-user OAuth tokens for OAuth-backed MCP servers. | | **Observer** (or **onlooker**) | Any other channel member who can read the channel post containing the Agent's response, but who did not trigger it. Observers are read-only with respect to the tool call: they cannot approve, cannot reject, and (by default) cannot see tool arguments or private results. | +| **Target** | The user an Agent asks a question of via the `AskAnotherUser` tool. The target is not the initiator and may not even be in the originating channel. The target owns exactly one decision: answer or decline the question delivered to their DM. See §10. | Concrete example: Maya `@`-mentions `@copilot` in `~team-eng` and asks it to look up a Jira ticket. Raj is also in `~team-eng` and watches the conversation unfold. @@ -169,7 +170,45 @@ In practice this means: This is the intentional behavior. If a workflow needs an Agent to invoke `ask`-policy tools on a recurring schedule, the right answer is for an admin to either (a) reclassify those tools as `auto_run_everywhere` after a security review, or (b) keep a human in the loop. Bots cannot promote themselves into the approver role. -## 10. Operational guidance for admins +## 10. Asking another user: the target as a second actor + +The built-in `AskAnotherUser` tool lets an Agent ask a specific other user a clarifying question mid-task. The capability is experimental and **off by default**: an admin must enable the **Enable Agents to Ask Other Users** setting before the tool is offered to models at all, and while the setting is off the answer and cancel endpoints refuse — the switch is superordinate to any configured tool policy. When enabled, this is a deliberate extension of the multiplayer model: it introduces a second first-class human actor — the **target** — alongside the initiator. While the question is outstanding, the conversation blocks (the tool call sits in a waiting state and the Agent produces no follow-up); once the target answers or declines — or the initiator cancels — the conversation resumes with the outcome recorded as an ordinary tool result. + +### Delivery is DM-only + +The question is delivered as an interactive card in the target's DM with the Agent bot — never posted into the originating channel, even when the initiating conversation is a channel thread. The card carries a permalink back to the initiating conversation and a set of system-authored trust lines (below). All of them are computed server-side at dispatch time, carried in post props, and rendered by the client — never authored by the model. + +### Disclosure and anti-impersonation + +The card visually separates what the model wrote from what the system asserts. The model-authored region — question, context, answer options — renders inside a contained block captioned **AI-generated content**; every trust-bearing line renders outside it. The server additionally strips model attempts to forge system lines: question or context lines matching reserved system phrasing ("Asked on behalf of…", "Your answer will be shared…", and similar) are dropped before dispatch, and option labels or descriptions containing them are rejected as error tool results the model can react to. + +The system lines themselves, frozen at ask time: + +- **Attribution.** "Asked on behalf of @initiator" for a human requester, with the requester's display name and job title underneath when available. Autonomous runs are labeled explicitly ("Asked by the {agent} agent running unattended (no human requester)"), and a failed requester lookup is distinguished from that ("Asked via the {agent} agent (requester identity unavailable)") — a card never implies a human vouched for the question when none did. +- **Destination disclosure.** A DM-initiated ask says the answer "will be shared with @initiator"; a channel-initiated ask says it "may be shared with the N members of ~channel-name", with the name and member count resolved best-effort at dispatch. Every lookup failure degrades toward the *broader* audience claim (a generic "shared in the channel where the agent was asked"), never a narrower one. Group messages get dedicated copy without the misleading `~` prefix. +- **Access context.** When the originating channel is governed by an attribute-based access policy, the card says so. The plugin API only exposes the boolean policy-enforced flag — the policy's attributes and rules are not readable by the plugin — so when nothing is reachable the card omits the line entirely rather than rendering anything that could imply "no restrictions". + +### Ownership split + +The initiator still owns Accept / Reject of the tool call itself — §4's rules are unchanged, and under the default `ask` policy the question card is dispatched only after the initiator accepts. The **target** exclusively owns the answer: the answer endpoint verifies the caller is the recorded target and rejects anyone else, including the initiator and admins. Decline is a first-class outcome, not an error — the Agent is instructed to proceed gracefully without the answer. The initiator (and only the initiator — observers see neither control) additionally owns cancellation of an outstanding question. + +### Initiator cancel + +The waiting tool card offers the initiator a **Cancel question** control. Cancel resolves the waiting call with a valid, non-error "no answer received" tool result, so the conversation resumes immediately with the Agent continuing without the answer, and the target's card flips to the neutral "This question is no longer needed." terminal state. Answer, decline, and cancel contend for a single one-shot claim, so exactly one resolution ever wins: a late answer or decline after a cancel (or a late cancel after an answer) is a graceful no-op — the loser's card settles into the winner's terminal state with no error. + +### No Share / Keep Private stage + +The answer is authored by the target, not produced by a third-party tool, so it is treated like `AskUserQuestion` answers: marked shared on resolution, with no §6 two-step. Channel observers see only a redacted waiting placeholder while the question is outstanding — the question text and target arguments stay hidden, consistent with §7. + +### Policy + +`AskAnotherUser` is a regular built-in tool under the standard `ask` / `auto_run_in_dm` / `auto_run_everywhere` policies (see the admin guide's "Built-in tool policies" section). It ships with an explicit `ask` default. Consistent with §9, it is excluded from `activate_ai` bot-triggered channel flows — a question card is never dispatched from a flow with no accountable human. The policy only matters while the **Enable Agents to Ask Other Users** master switch is on; off wins unconditionally. + +### Limitations + +Regenerating or stopping the conversation while a question is outstanding orphans the card: a late answer fails with an error and the card stays pending, mirroring the "pending tool calls remain pending" behavior in §4 — an initiator who wants a clean resolution should use **Cancel question** instead, which closes out the target's card. Disabling the master switch while a question is outstanding parks it: answering and canceling both refuse until an admin re-enables the setting or the initiator regenerates the conversation. The Mattermost mobile apps show a plain-text fallback and cannot answer; targets must use web or desktop. + +## 11. Operational guidance for admins Configuring the per-tool policy list is the main lever admins have over multiplayer behavior. A few recommendations: @@ -181,7 +220,7 @@ Configuring the per-tool policy list is the main lever admins have over multipla The channel tool-calling capability itself is gated by the workspace setting **Enable Channel Mention Tool Calling**, which is experimental at the time of the v2 launch. Until that setting is enabled, multiplayer tool calling is effectively limited to `auto_run_*` policies in DMs and admins do not need to think about channel privacy at all. -## 11. Tradeoffs and explicit non-goals +## 12. Tradeoffs and explicit non-goals To make the model auditable, several things are deliberately **not** supported. These are not oversights; they are design choices. @@ -191,7 +230,7 @@ To make the model auditable, several things are deliberately **not** supported. - **No tool execution as the bot.** As noted in §3 and §9, tools always run as the initiator. There is no "service account" path for OAuth-backed MCP tools. - **No retroactive privacy.** Once the initiator chooses **Share**, the result is visible to channel members and the Agent's follow-up response incorporates it openly. There is no "unshare" button. The same is true for `auto_run_everywhere` tools, which are auto-shared at the moment they execute — there is no Share / Keep Private prompt to retract. (Channel-level moderation tools — deleting posts, etc. — are still available but live outside Agents.) -## 12. Related docs +## 13. Related docs - [User guide — Use tools](../user_guide.md#use-tools): the end-user-facing instructions for Accept / Reject and the Tools menu. - [Admin guide](../admin_guide.md): per-tool policy configuration, agent management, and the **Enable Channel Mention Tool Calling** setting. diff --git a/docs/user_guide.md b/docs/user_guide.md index 49ebe3827..f6ab6bb7a 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -106,6 +106,18 @@ Tool availability depends on your user permissions, provider connection status, Some MCP providers require each user to connect their own account before those tools become available. When that applies, open the **Tools** menu in the Agents pane or RHS, select **Connect** for the provider, and wait for the list to refresh with the newly available tools. +### Answer a question an agent asks you + +When your system admin enables the experimental **Enable Agents to Ask Other Users** setting, an agent working on someone else's request can ask **you** a clarifying question. The question arrives as an interactive card in your direct message with the agent, showing the question, optional answer choices, and an optional free-form answer field. + +Because the question text is written by the AI model, the card visually separates it from trusted system information. The question and answer choices appear inside a highlighted region labeled **AI-generated content**; everything outside that region comes from Mattermost itself. Above the region, an attribution line tells you who is asking — "Asked on behalf of @username" when a person started the conversation, with their display name and job title when available, or an explicit note that the agent is running unattended when no person did. Below it, a disclosure line tells you who your answer will be shared with: the requester directly when the question started in a direct message, or the members of the originating channel (with the channel name and member count when known) when it started in a channel. If the originating channel is governed by an attribute-based access policy, the card says so. Select **View conversation** to open the conversation the question came from (you'll only see it if you have access to it). + +Choose an option and/or type an answer, then select **Answer** — your response is passed back to the agent and attributed to you in the requesting conversation. If you can't or don't want to answer, select **Decline**; the agent is told you declined and continues without your input. Only you can answer or decline your card. Once resolved, the card shows your answer (or that you declined) and can't be changed. + +The requester can also withdraw a question: if you started the conversation and a question you approved is still waiting, select **Cancel question** on the waiting tool card, and the agent continues without the answer. On the receiving side, a canceled card switches to "This question is no longer needed." and no response is expected. The same message appears if a response was already recorded — for example, you answered or declined from another device — and it means nothing more is needed from you. + +> **Note:** Answering isn't currently supported in the Mattermost mobile apps — the question appears as plain text there. Open Mattermost in a browser or the desktop app to respond. + ### Ask agents to create files You can ask an agent to produce content as a file instead of pasting it into the chat. For example, ask for "the meeting notes as a markdown file" or "a Python script that parses this log, as a file". The agent creates the files and attaches them to its reply, so you can download or share them like any other Mattermost attachment. diff --git a/e2e/helpers/system-console-container.ts b/e2e/helpers/system-console-container.ts index 0ac20f586..46f683f43 100644 --- a/e2e/helpers/system-console-container.ts +++ b/e2e/helpers/system-console-container.ts @@ -14,6 +14,7 @@ export interface SystemConsolePluginConfig { enableVectorIndex?: boolean; enableTokenUsageLogging?: boolean; enableChannelMentionToolCalling?: boolean; + enableAskAnotherUser?: boolean; defaultBotName?: string; allowedUpstreamHostnames?: string; allowUnsafeLinks?: boolean; @@ -104,6 +105,7 @@ export async function RunSystemConsoleContainer(config: SystemConsolePluginConfi enableVectorIndex: config.enableVectorIndex ?? false, enableTokenUsageLogging: config.enableTokenUsageLogging, enableChannelMentionToolCalling: config.enableChannelMentionToolCalling ?? false, + enableAskAnotherUser: config.enableAskAnotherUser ?? false, defaultBotName: config.defaultBotName, allowedUpstreamHostnames: config.allowedUpstreamHostnames, allowUnsafeLinks: config.allowUnsafeLinks, diff --git a/e2e/helpers/tool-config-container.ts b/e2e/helpers/tool-config-container.ts index 5b4c12514..d2e3f0185 100644 --- a/e2e/helpers/tool-config-container.ts +++ b/e2e/helpers/tool-config-container.ts @@ -12,6 +12,7 @@ export type ToolConfigAIMockOptions = { toolConfigs?: ToolPolicyConfig[]; customInstructions?: string; enableVectorIndex?: boolean; + enableAskAnotherUser?: boolean; defaultBotName?: string; botId?: string; botDisplayName?: string; @@ -189,6 +190,7 @@ export async function RunToolConfigAIMockContainer( toolConfigs = [], customInstructions = '', enableVectorIndex = false, + enableAskAnotherUser = false, defaultBotName, botId = 'aimock-toolbot', botDisplayName = 'Aimock Tool Bot', @@ -197,6 +199,7 @@ export async function RunToolConfigAIMockContainer( return RunSystemConsoleContainer({ enableChannelMentionToolCalling: true, enableVectorIndex, + enableAskAnotherUser, defaultBotName, services: [{...AIMOCK_COMPATIBLE_SERVICE}], bots: [ diff --git a/e2e/scripts/ci-test-groups.mjs b/e2e/scripts/ci-test-groups.mjs index 09e505bdb..3372d0732 100644 --- a/e2e/scripts/ci-test-groups.mjs +++ b/e2e/scripts/ci-test-groups.mjs @@ -44,6 +44,7 @@ const groups = { 'tests/llmbot-post-component/citations-annotations.spec.ts', 'tests/llmbot-post-component/combined-features.spec.ts', 'tests/llmbot-post-component/debug-test.spec.ts', + 'tests/ask-another-user/ask-another-user.spec.ts', 'tests/agents/create-file-attachment.spec.ts', ], 'e2e-shard-3': [ diff --git a/e2e/tests/ask-another-user/ask-another-user.spec.ts b/e2e/tests/ask-another-user/ask-another-user.spec.ts new file mode 100644 index 000000000..127c5c389 --- /dev/null +++ b/e2e/tests/ask-another-user/ask-another-user.spec.ts @@ -0,0 +1,381 @@ +// Two-user AskAnotherUser flow with deterministic aimock fixtures: initiator DMs the +// bot in the RHS, target answers/declines a DM card. + +import {test, expect} from '@playwright/test'; + +import {AIMockContainer, RunAIMockSidecar} from 'helpers/aimock-container'; +import {buildTitleFixture, buildToolCallAndTextResponse} from 'helpers/aimock-fixtures'; +import MattermostContainer from 'helpers/mmcontainer'; +import {MattermostPage} from 'helpers/mm'; +import {AIPlugin} from 'helpers/ai-plugin'; +import {RunToolConfigAIMockContainer} from 'helpers/tool-config-container'; +import {adminUsername, adminPassword} from 'helpers/system-console-container'; + +const initiatorUsername = 'regularuser'; +const initiatorPassword = 'regularuser'; +const targetUsername = 'seconduser'; +const targetPassword = 'seconduser'; +const botUsername = 'toolbot'; + +// Exact card-header label: ToolCard title-cases on underscores only, so the +// PascalCase tool name renders verbatim. +const askAnotherUserToolName = 'AskAnotherUser'; + +// The waiting copy ends in a Unicode ellipsis; match without it. +const waitingForTargetRegex = new RegExp(`Waiting for @${targetUsername} to answer`); + +let mattermost: MattermostContainer; +let aimock: AIMockContainer; + +async function setupUsers(mattermostInstance: MattermostContainer): Promise { + await mattermostInstance.createUser('regularuser@sample.com', initiatorUsername, initiatorPassword); + await mattermostInstance.addUserToTeam(initiatorUsername, 'test'); + await mattermostInstance.createUser('seconduser@sample.com', targetUsername, targetPassword); + await mattermostInstance.addUserToTeam(targetUsername, 'test'); + + for (const [username, password] of [[initiatorUsername, initiatorPassword], [targetUsername, targetPassword], [adminUsername, adminPassword]]) { + const client = await mattermostInstance.getClient(username, password); + const user = await client.getMe(); + await client.savePreferences(user.id, [ + {user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'}, + {user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'}, + {user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'}, + { + user_id: user.id, + category: 'drafts', + name: 'drafts_tour_tip_showed', + value: JSON.stringify({drafts_tour_tip_showed: true}), + }, + {user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'}, + ]); + } + + const adminClient = await mattermostInstance.getAdminClient(); + await adminClient.completeSetup({organization: 'test', install_plugins: []}); +} + +test.describe('Ask Another User (Aimock)', () => { + // Serial: one shared Mattermost + aimock sidecar; each test loads its own + // fixtures via setFixtures. AskAnotherUser is a built-in tool that defaults + // to the ask policy, so no toolConfigs are needed. + test.describe.configure({mode: 'serial'}); + + test.beforeAll(async () => { + test.setTimeout(240000); + mattermost = await RunToolConfigAIMockContainer({ + defaultBotName: botUsername, + botId: 'ask-user-test-bot', + botDisplayName: 'Ask User Test Bot', + // V2-C1: the tool is master-gated off by default. + enableAskAnotherUser: true, + }); + await setupUsers(mattermost); + aimock = await RunAIMockSidecar(mattermost.network, { + fixtures: {fixtures: [buildTitleFixture('Ask another user bootstrap')]}, + }); + }); + + test.afterAll(async () => { + await aimock?.stop(); + await mattermost?.stop(); + }); + + test('happy path: target answers and the conversation resumes', async ({browser}) => { + test.setTimeout(300000); + + const askPrompt = `ask another user happy ${Date.now()}`; + const askCallId = `call_ask_user_happy_${Date.now()}`; + const question = `Which release broke the login flow? (${Date.now()})`; + const optionPlain = '4.2.0'; + const optionAnswer = '4.2.1'; + const contextLine = 'Needed to finish the incident report.'; + const finalText = `ASK_HAPPY_FINAL_${Date.now()}`; + + await aimock.setFixtures(buildToolCallAndTextResponse({ + userMessage: askPrompt, + toolCallId: askCallId, + toolName: askAnotherUserToolName, + toolArguments: { + username: targetUsername, + question, + options: [{label: optionPlain}, {label: optionAnswer, description: 'the hotfix release'}], + context: contextLine, + }, + finalContent: finalText, + title: 'Ask another user happy path', + })); + + const initiatorContext = await browser.newContext(); + const targetContext = await browser.newContext(); + const initiatorPage = await initiatorContext.newPage(); + const targetPage = await targetContext.newPage(); + + try { + const initiatorMM = new MattermostPage(initiatorPage); + const targetMM = new MattermostPage(targetPage); + const aiPlugin = new AIPlugin(initiatorPage); + const baseUrl = mattermost.url(); + + // Initiator: DM the bot from the RHS. + await initiatorMM.login(baseUrl, initiatorUsername, initiatorPassword); + await aiPlugin.openRHS(); + await aiPlugin.sendMessage(askPrompt); + + // Pending tool card with Accept/Reject; no continuation yet. + const rhs = initiatorPage.getByTestId('mattermost-ai-rhs'); + await expect(rhs.locator('[data-testid="llm-bot-post"]').last()).toBeVisible({timeout: 90000}); + await expect(rhs.getByText(askAnotherUserToolName, {exact: true})).toBeVisible({timeout: 90000}); + + const acceptButton = rhs.getByRole('button', {name: /^accept$/i}); + const rejectButton = rhs.getByRole('button', {name: /^reject$/i}); + await expect(acceptButton).toBeVisible(); + await expect(rejectButton).toBeVisible(); + await expect(rhs.getByText(finalText)).not.toBeVisible(); + + // Accept dispatches the question card and parks the run in waiting. + await acceptButton.click(); + await expect(rhs.getByText(waitingForTargetRegex)).toBeVisible({timeout: 30000}); + await expect(acceptButton).not.toBeVisible(); + await expect(rhs.getByText(finalText)).not.toBeVisible(); + + // Target logs in after the Accept so the card renders on a fresh + // channel load instead of relying on websocket delivery. + await targetMM.login(baseUrl, targetUsername, targetPassword); + await targetPage.goto(`${baseUrl}/test/messages/@${botUsername}`); + const channelView = targetPage.getByTestId('channel_view'); + + // Scope to the card's post body: the channel's sr-only live region + // also announces the card's plain-text fallback (the question), so + // unscoped getByText would double-match. + const askCard = channelView.getByTestId('postContent').filter({hasText: question}); + await expect(askCard).toBeVisible({timeout: 30000}); + await expect(askCard.getByText(contextLine)).toBeVisible(); + await expect(askCard.getByText(`Asked on behalf of @${initiatorUsername}`)).toBeVisible(); + await expect(askCard.getByRole('button', {name: new RegExp(optionAnswer)})).toBeVisible(); + + // V2 anti-impersonation layout: the model-authored text (question, + // context, options) is contained in the captioned AI region, while + // the system chrome renders outside it, from props only. + const aiRegion = askCard.getByTestId('ask-user-ai-content'); + await expect(aiRegion.getByText('AI-generated content')).toBeVisible(); + await expect(aiRegion.getByText(question)).toBeVisible(); + await expect(aiRegion.getByText(contextLine)).toBeVisible(); + + // V2 destination disclosure for a DM-initiated ask names the + // human requester; both system lines stay outside the AI region. + const disclosureLine = `Your answer will be shared with @${initiatorUsername}.`; + await expect(askCard.getByText(disclosureLine)).toBeVisible(); + await expect(aiRegion.getByText(disclosureLine)).toHaveCount(0); + await expect(aiRegion.getByText(`Asked on behalf of @${initiatorUsername}`)).toHaveCount(0); + + const answerButton = askCard.getByRole('button', {name: 'Answer', exact: true}); + const declineButton = askCard.getByRole('button', {name: 'Decline', exact: true}); + await expect(answerButton).toBeVisible(); + await expect(declineButton).toBeVisible(); + + // Permalink back to the initiating conversation (not followable by + // the target — it points at the initiator's bot DM). + const permalink = askCard.getByRole('link', {name: 'View conversation'}); + await expect(permalink).toBeVisible(); + expect(await permalink.getAttribute('href')).toContain('/_redirect/pl/'); + + // Select an option and answer. + await askCard.getByRole('button', {name: new RegExp(optionAnswer)}).click(); + await expect(answerButton).toBeEnabled(); + await answerButton.click(); + + // Target card resolves to the answered state. + await expect(askCard.getByText('Answered')).toBeVisible({timeout: 30000}); + await expect(askCard.getByText(optionAnswer, {exact: true})).toBeVisible(); + await expect(answerButton).not.toBeVisible(); + await expect(declineButton).not.toBeVisible(); + + // Reload: the resolved state must come from the server-patched + // card-post props, not the local submit snapshot — a prop-patch + // regression would revert the card to pending here. + await targetPage.reload(); + await expect(askCard.getByText('Answered')).toBeVisible({timeout: 30000}); + await expect(askCard.getByText(optionAnswer, {exact: true})).toBeVisible(); + await expect(answerButton).not.toBeVisible(); + await expect(declineButton).not.toBeVisible(); + + // Initiator conversation resumes with the scripted continuation. + await expect(rhs.getByText(finalText)).toBeVisible({timeout: 60000}); + await expect(rhs.getByText(waitingForTargetRegex)).not.toBeVisible(); + await expect(initiatorPage.getByRole('button', {name: /stop/i})).not.toBeVisible({timeout: 30000}); + } finally { + await initiatorContext.close(); + await targetContext.close(); + } + }); + + test('decline: target declines and the initiator sees the declined state', async ({browser}) => { + test.setTimeout(300000); + + const declinePrompt = `ask another user decline ${Date.now()}`; + const declineCallId = `call_ask_user_decline_${Date.now()}`; + const declineQuestion = `Can you confirm the deploy window for tonight? (${Date.now()})`; + const declineFinalText = `ASK_DECLINE_FINAL_${Date.now()} proceeding without confirmation`; + + // Free-form-only question (no options) exercises the textarea path. + await aimock.setFixtures(buildToolCallAndTextResponse({ + userMessage: declinePrompt, + toolCallId: declineCallId, + toolName: askAnotherUserToolName, + toolArguments: { + username: targetUsername, + question: declineQuestion, + }, + finalContent: declineFinalText, + title: 'Ask another user decline path', + })); + + const initiatorContext = await browser.newContext(); + const targetContext = await browser.newContext(); + const initiatorPage = await initiatorContext.newPage(); + const targetPage = await targetContext.newPage(); + + try { + const initiatorMM = new MattermostPage(initiatorPage); + const targetMM = new MattermostPage(targetPage); + const aiPlugin = new AIPlugin(initiatorPage); + const baseUrl = mattermost.url(); + + // Fresh conversation: New chat keeps test 1's turns (and its tool + // call ID) out of this test's completion requests. + await initiatorMM.login(baseUrl, initiatorUsername, initiatorPassword); + await aiPlugin.openRHS(); + await aiPlugin.resetState(); + await aiPlugin.sendMessage(declinePrompt); + + const rhs = initiatorPage.getByTestId('mattermost-ai-rhs'); + await expect(rhs.locator('[data-testid="llm-bot-post"]').last()).toBeVisible({timeout: 90000}); + await expect(rhs.getByText(askAnotherUserToolName, {exact: true})).toBeVisible({timeout: 90000}); + + const acceptButton = rhs.getByRole('button', {name: /^accept$/i}); + await expect(acceptButton).toBeVisible(); + + await acceptButton.click(); + await expect(rhs.getByText(waitingForTargetRegex)).toBeVisible({timeout: 30000}); + + // Target: the DM now holds test 1's resolved card plus this pending + // one — scope to the post body holding this test's unique question + // (which also avoids the sr-only live-region double match). + await targetMM.login(baseUrl, targetUsername, targetPassword); + await targetPage.goto(`${baseUrl}/test/messages/@${botUsername}`); + const channelView = targetPage.getByTestId('channel_view'); + + const declineCard = channelView.getByTestId('postContent').filter({hasText: declineQuestion}); + await expect(declineCard).toBeVisible({timeout: 30000}); + await expect(declineCard.getByPlaceholder(/Type your answer/)).toBeVisible(); + + const declineButton = declineCard.getByRole('button', {name: 'Decline', exact: true}); + await expect(declineButton).toBeEnabled(); + await declineButton.click(); + + await expect(declineCard.getByText('You declined to answer')).toBeVisible({timeout: 30000}); + await expect(declineCard.getByRole('button', {name: 'Answer', exact: true})).not.toBeVisible(); + await expect(declineButton).not.toBeVisible(); + + // Initiator resumes with the scripted continuation. + await expect(rhs.getByText(declineFinalText)).toBeVisible({timeout: 60000}); + await expect(rhs.getByText(waitingForTargetRegex)).not.toBeVisible(); + await expect(initiatorPage.getByRole('button', {name: /stop/i})).not.toBeVisible({timeout: 30000}); + + // The declined line renders inside the collapsible card body, and a + // resolved card is collapsed by default — expand via the header. + await rhs.getByText(askAnotherUserToolName, {exact: true}).last().click(); + await expect(rhs.getByText(`@${targetUsername} declined to answer`)).toBeVisible({timeout: 15000}); + } finally { + await initiatorContext.close(); + await targetContext.close(); + } + }); + + test('cancel: initiator cancels the outstanding question and the target card resolves', async ({browser}) => { + test.setTimeout(300000); + + const cancelPrompt = `ask another user cancel ${Date.now()}`; + const cancelCallId = `call_ask_user_cancel_${Date.now()}`; + const cancelQuestion = `Do you have capacity to take the on-call shift? (${Date.now()})`; + const cancelFinalText = `ASK_CANCEL_FINAL_${Date.now()} proceeding without an answer`; + + // The follow-up fixture matches on the tool call id, so the same + // builder covers the cancel resume: after the canceled tool result is + // recorded the plugin requests a continuation carrying this call id. + await aimock.setFixtures(buildToolCallAndTextResponse({ + userMessage: cancelPrompt, + toolCallId: cancelCallId, + toolName: askAnotherUserToolName, + toolArguments: { + username: targetUsername, + question: cancelQuestion, + }, + finalContent: cancelFinalText, + title: 'Ask another user cancel path', + })); + + const initiatorContext = await browser.newContext(); + const targetContext = await browser.newContext(); + const initiatorPage = await initiatorContext.newPage(); + const targetPage = await targetContext.newPage(); + + try { + const initiatorMM = new MattermostPage(initiatorPage); + const targetMM = new MattermostPage(targetPage); + const aiPlugin = new AIPlugin(initiatorPage); + const baseUrl = mattermost.url(); + + await initiatorMM.login(baseUrl, initiatorUsername, initiatorPassword); + await aiPlugin.openRHS(); + await aiPlugin.resetState(); + await aiPlugin.sendMessage(cancelPrompt); + + const rhs = initiatorPage.getByTestId('mattermost-ai-rhs'); + await expect(rhs.locator('[data-testid="llm-bot-post"]').last()).toBeVisible({timeout: 90000}); + await expect(rhs.getByText(askAnotherUserToolName, {exact: true})).toBeVisible({timeout: 90000}); + + const acceptButton = rhs.getByRole('button', {name: /^accept$/i}); + await expect(acceptButton).toBeVisible(); + await acceptButton.click(); + + // Waiting card with the requester-only cancel control (F5). + await expect(rhs.getByText(waitingForTargetRegex)).toBeVisible({timeout: 30000}); + const cancelButton = rhs.getByRole('button', {name: 'Cancel question'}); + await expect(cancelButton).toBeVisible(); + + // Target opens the DM BEFORE the cancel so the flip to the + // terminal state is delivered live over the post-edit event. + await targetMM.login(baseUrl, targetUsername, targetPassword); + await targetPage.goto(`${baseUrl}/test/messages/@${botUsername}`); + const channelView = targetPage.getByTestId('channel_view'); + + const askCard = channelView.getByTestId('postContent').filter({hasText: cancelQuestion}); + await expect(askCard).toBeVisible({timeout: 30000}); + const answerButton = askCard.getByRole('button', {name: 'Answer', exact: true}); + const declineButton = askCard.getByRole('button', {name: 'Decline', exact: true}); + await expect(declineButton).toBeVisible(); + + // Initiator cancels; the conversation resumes without an answer. + await cancelButton.click(); + await expect(rhs.getByText(cancelFinalText)).toBeVisible({timeout: 60000}); + await expect(rhs.getByText(waitingForTargetRegex)).not.toBeVisible(); + await expect(initiatorPage.getByRole('button', {name: /stop/i})).not.toBeVisible({timeout: 30000}); + + // Target card settles into the neutral terminal state — no + // controls, no error styling (a late answer is a graceful no-op). + await expect(askCard.getByText('This question is no longer needed.')).toBeVisible({timeout: 30000}); + await expect(answerButton).not.toBeVisible(); + await expect(declineButton).not.toBeVisible(); + await expect(askCard.getByText('Failed to submit your response. Please try again.')).toHaveCount(0); + + // The canceled terminal line renders inside the collapsible card + // body; the resolved card is collapsed by default — expand it. + await rhs.getByText(askAnotherUserToolName, {exact: true}).last().click(); + await expect(rhs.getByText('Canceled — the agent continued without an answer')).toBeVisible({timeout: 15000}); + } finally { + await initiatorContext.close(); + await targetContext.close(); + } + }); +}); diff --git a/i18n/en.json b/i18n/en.json index 5d2755b4d..7c73c9b73 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3,6 +3,62 @@ "id": "agents.agent_mention_reminder_fallback", "translation": "To respond to an agent you must @mention them." }, + { + "id": "agents.ask_user_card_canceled", + "translation": "This question is no longer needed." + }, + { + "id": "agents.ask_user_fallback_attrib_bot", + "translation": "Asked by the %s agent running unattended (no human requester):" + }, + { + "id": "agents.ask_user_fallback_attrib_unknown", + "translation": "Asked via the %s agent (requester identity unavailable):" + }, + { + "id": "agents.ask_user_fallback_attrib_user", + "translation": "Asked on behalf of @%s:" + }, + { + "id": "agents.ask_user_fallback_dest_channel", + "translation": "Your answer may be shared with the %[1]d members of ~%[2]s." + }, + { + "id": "agents.ask_user_fallback_dest_channel_no_count", + "translation": "Your answer may be shared with the members of ~%s." + }, + { + "id": "agents.ask_user_fallback_dest_channel_unknown", + "translation": "Your answer may be shared in the channel where the agent was asked." + }, + { + "id": "agents.ask_user_fallback_dest_dm", + "translation": "Your answer will be shared with @%s." + }, + { + "id": "agents.ask_user_fallback_dest_dm_agent", + "translation": "Your answer will be shared with the %s agent." + }, + { + "id": "agents.ask_user_fallback_dest_dm_unknown", + "translation": "Your answer will be shared with the person who asked the agent." + }, + { + "id": "agents.ask_user_fallback_dest_gm", + "translation": "Your answer may be shared with the %d members of a group message." + }, + { + "id": "agents.ask_user_fallback_dest_gm_no_count", + "translation": "Your answer may be shared with the members of a group message." + }, + { + "id": "agents.ask_user_fallback_hint", + "translation": "(Interactive answer card — open Mattermost in a browser or the desktop app to respond.)" + }, + { + "id": "agents.ask_user_fallback_policy", + "translation": "Access to ~%s is restricted by an attribute-based access policy." + }, { "id": "agents.no_longer_access_error", "translation": "Sorry, you no longer have access to the original thread." diff --git a/llm/tool_retry.go b/llm/tool_retry.go index 14629f287..e4526a779 100644 --- a/llm/tool_retry.go +++ b/llm/tool_retry.go @@ -129,7 +129,7 @@ func trailingFailedToolCalls(toolCalls []ToolCall) (count int, allFailed bool, h hasExecutedTool = true case ToolCallStatusSuccess, ToolCallStatusAutoApproved: return 0, false, true - case ToolCallStatusRejected, ToolCallStatusPending, ToolCallStatusAccepted: + case ToolCallStatusRejected, ToolCallStatusPending, ToolCallStatusAccepted, ToolCallStatusWaiting: continue default: return 0, false, hasExecutedTool diff --git a/llm/tool_retry_test.go b/llm/tool_retry_test.go index 91adaf99c..a9629eaa7 100644 --- a/llm/tool_retry_test.go +++ b/llm/tool_retry_test.go @@ -181,6 +181,32 @@ func TestEnsureToolIterationLimitUserMessage(t *testing.T) { } } +func TestCountTrailingFailedToolCallsWaiting(t *testing.T) { + buildPosts := func(nonTerminalStatus ToolCallStatus) []Post { + return []Post{{ + Role: PostRoleBot, + ToolUse: []ToolCall{ + {Name: "ask_tool", Status: nonTerminalStatus}, + {Name: "normal_tool", Status: ToolCallStatusError}, + }, + }} + } + + tests := []struct { + name string + status ToolCallStatus + }{ + {name: "waiting call does not break failure counting", status: ToolCallStatusWaiting}, + {name: "pending call behaves identically", status: ToolCallStatusPending}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, 1, CountTrailingFailedToolCalls(buildPosts(tt.status))) + }) + } +} + func TestCountTrailingFailedToolCallsIgnoresFailedMetaTools(t *testing.T) { posts := []Post{{ Role: PostRoleBot, diff --git a/llm/tools.go b/llm/tools.go index 1632cef9f..8773a9989 100644 --- a/llm/tools.go +++ b/llm/tools.go @@ -42,6 +42,13 @@ type Tool struct { // the Resolver is only an error backstop. Empty for normal tools. UserInteraction string + // DeferredResult marks a tool whose approval/auto-run performs a dispatch + // side effect (handled by the conversation layer) instead of producing a + // tool result synchronously; the result arrives later out-of-band and the + // call sits in ToolCallStatusWaiting until then. The Resolver is only an + // error backstop, like UserInteraction tools. + DeferredResult bool + // AutoExecute marks a built-in tool that runs without user approval, like // the MCP dynamic-loading meta-tools. Reserve it for tools whose only side // effect is scoped to the assistant's own response (e.g. CreateFile @@ -243,6 +250,10 @@ const ( // This status is set by the stream wrapper and consumed by the streaming layer // to skip the call-approval UI and proceed directly to result-sharing. ToolCallStatusAutoApproved + // ToolCallStatusWaiting indicates a deferred-result tool call that was + // dispatched (e.g. a question card sent to another user) and is awaiting + // an out-of-band answer. Not a terminal status: no tool result exists yet. + ToolCallStatusWaiting ) // ToolCall represents a tool call. An empty result indicates that the tool has not yet been resolved. @@ -265,6 +276,10 @@ type ToolCall struct { // re-checks the policy before executing it on resume. WouldAutoExecute bool `json:"would_auto_execute,omitempty"` + // DeferredResult mirrors Tool.DeferredResult so the approval flow and the + // webapp can recognize deferred calls on pending/waiting blocks. + DeferredResult bool `json:"deferred_result,omitempty"` + // ServerOrigin identifies the MCP server this tool came from (the BaseURL). // Empty for built-in tools. Used for auto-approval decisions. ServerOrigin string `json:"server_origin,omitempty"` @@ -463,6 +478,7 @@ func EnrichToolCall(tc *ToolCall, store *ToolStore, opts EnrichToolCallOptions) } tc.Schema = tool.Schema tc.UserInteraction = tool.UserInteraction + tc.DeferredResult = tool.DeferredResult if tc.ServerOrigin == "" { tc.ServerOrigin = lookup.ServerOrigin } diff --git a/mcp/tool_policy.go b/mcp/tool_policy.go index 68496ee22..0cc83c0b8 100644 --- a/mcp/tool_policy.go +++ b/mcp/tool_policy.go @@ -37,9 +37,22 @@ func ToolPolicyLookupName(sc *ServerConfig, toolName string) string { return llm.BareMCPToolName(toolName) } -// LookupToolPolicy resolves a tool's policy for embedded, remote, and plugin -// origins. Unknown or disabled origins never auto-execute. +// LookupToolPolicy resolves a tool's policy for built-in, embedded, remote, +// and plugin origins. Unknown or disabled origins never auto-execute. func LookupToolPolicy(cfg Config, serverBaseURL, toolName string) (string, bool) { + // Built-in tools carry an empty origin. Admins configure them via + // cfg.BuiltInTools; unconfigured built-ins default to (ask, enabled) via + // the synthetic server's GetToolPolicy fallback. `ask` never auto-runs, so + // this preserves the previous fail-closed behavior for approval decisions. + if serverBaseURL == "" { + builtIn := &ServerConfig{ + Name: "Built-in", + Enabled: true, + ToolConfigs: mergeSeedConfigs(cfg.BuiltInTools, SeedBuiltInToolConfigs()), + } + return builtIn.GetToolPolicy(toolName) + } + if serverBaseURL == EmbeddedClientKey { // Backfill the vetted seed for embedded tools the admin hasn't stored a // config for, so tools added after an install first saved its configs diff --git a/mcp/tool_policy_lookup_test.go b/mcp/tool_policy_lookup_test.go index 2ace50d55..1a5efab96 100644 --- a/mcp/tool_policy_lookup_test.go +++ b/mcp/tool_policy_lookup_test.go @@ -222,3 +222,83 @@ func TestLookupToolPolicy(t *testing.T) { require.False(t, enabled) }) } + +func TestLookupToolPolicyBuiltIn(t *testing.T) { + const builtInToolName = "AskAnotherUser" + + cases := []struct { + name string + cfg Config + toolName string + wantPolicy string + wantEnabled bool + // assertCannotAutoRun additionally pins that the resulting policy + // never satisfies IsToolPolicyAutoRunInDM. + assertCannotAutoRun bool + }{ + { + name: "unconfigured AskAnotherUser gets the seed", + cfg: Config{}, + toolName: builtInToolName, + wantPolicy: ToolPolicyAsk, + wantEnabled: true, + }, + { + name: "admin auto_run_in_dm override wins over the seed", + cfg: Config{ + BuiltInTools: []ToolConfig{{ + Name: builtInToolName, + Policy: ToolPolicyAutoRunInDM, + Enabled: true, + }}, + }, + toolName: builtInToolName, + wantPolicy: ToolPolicyAutoRunInDM, + wantEnabled: true, + }, + { + name: "admin disable wins over the seed", + cfg: Config{ + BuiltInTools: []ToolConfig{{ + Name: builtInToolName, + Policy: ToolPolicyAsk, + Enabled: false, + }}, + }, + toolName: builtInToolName, + wantPolicy: ToolPolicyAsk, + wantEnabled: false, + }, + { + // Unconfigured built-ins move from (ask, false) to (ask, true), + // but ask+enabled still cannot auto-run: enabled only gates + // auto-run and ask never satisfies IsToolPolicyAutoRunInDM, so + // approval behavior is unchanged. + name: "unconfigured WebSearch stays at ask and never auto-runs", + cfg: Config{}, + toolName: "WebSearch", + wantPolicy: ToolPolicyAsk, + wantEnabled: true, + assertCannotAutoRun: true, + }, + { + name: "empty tool name stays closed", + cfg: Config{}, + toolName: "", + wantPolicy: ToolPolicyAsk, + wantEnabled: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + policy, enabled := LookupToolPolicy(tc.cfg, "", tc.toolName) + + require.Equal(t, tc.wantPolicy, policy) + require.Equal(t, tc.wantEnabled, enabled) + if tc.assertCannotAutoRun { + require.False(t, IsToolPolicyAutoRunInDM(policy)) + } + }) + } +} diff --git a/mcp/vetted_tools.go b/mcp/vetted_tools.go index ccb22d184..64f1361a3 100644 --- a/mcp/vetted_tools.go +++ b/mcp/vetted_tools.go @@ -76,6 +76,15 @@ func SeedVettedToolConfigs(baseURL string) []ToolConfig { } } +// SeedBuiltInToolConfigs returns the default policies for built-in +// (empty-origin) tools. Stored admin entries win via mergeSeedConfigs. +func SeedBuiltInToolConfigs() []ToolConfig { + return []ToolConfig{ + // Keep in sync with mmtools.AskAnotherUserToolName. + {Name: "AskAnotherUser", Policy: ToolPolicyAsk, Enabled: true}, + } +} + func vettedHostFromBaseURL(baseURL string) (string, bool) { if baseURL == "" { return "", false diff --git a/mcp/vetted_tools_test.go b/mcp/vetted_tools_test.go index bfe5a22ca..ef2c0513b 100644 --- a/mcp/vetted_tools_test.go +++ b/mcp/vetted_tools_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/mattermost/mattermost-plugin-agents/v2/mmtools" "github.com/stretchr/testify/require" ) @@ -218,6 +219,34 @@ func TestSeedVettedToolConfigsSpotChecks(t *testing.T) { }) } +func TestSeedBuiltInToolConfigs(t *testing.T) { + t.Run("seed content is exactly AskAnotherUser ask enabled", func(t *testing.T) { + require.Equal(t, []ToolConfig{ + {Name: "AskAnotherUser", Policy: ToolPolicyAsk, Enabled: true}, + }, SeedBuiltInToolConfigs()) + }) + + t.Run("seed name matches the registered tool name", func(t *testing.T) { + // The seed keeps a string literal instead of importing mmtools from + // mcp production code (import-graph hygiene). This pins the literal + // to the registered runtime name so the two cannot silently drift — + // a renamed tool with a stale seed would lose its ask policy. + names := make([]string, 0) + for _, seed := range SeedBuiltInToolConfigs() { + names = append(names, seed.Name) + } + require.Contains(t, names, mmtools.AskAnotherUserToolName) + }) + + t.Run("stored admin override wins over the built-in seed", func(t *testing.T) { + stored := []ToolConfig{{Name: "AskAnotherUser", Policy: ToolPolicyAutoRunInDM, Enabled: true}} + + merged := mergeSeedConfigs(stored, SeedBuiltInToolConfigs()) + + require.Equal(t, stored, merged) + }) +} + func TestMergeSeedConfigs(t *testing.T) { tests := []struct { name string diff --git a/mmapi/client.go b/mmapi/client.go index 850bc8c58..da90cd4d4 100644 --- a/mmapi/client.go +++ b/mmapi/client.go @@ -31,6 +31,7 @@ type Client interface { DM(senderID, receiverID string, post *model.Post) error GetTeam(teamID string) (*model.Team, error) GetChannel(channelID string) (*model.Channel, error) + GetChannelStats(channelID string) (*model.ChannelStats, error) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *model.WebsocketBroadcast) GetConfig() *model.Config @@ -86,6 +87,10 @@ func (m *client) GetChannel(channelID string) (*model.Channel, error) { return m.pluginAPI.Channel.Get(channelID) } +func (m *client) GetChannelStats(channelID string) (*model.ChannelStats, error) { + return m.pluginAPI.Channel.GetChannelStats(channelID) +} + func (m *client) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) { return m.pluginAPI.Channel.GetDirect(userID1, userID2) } diff --git a/mmapi/mocks/client_mock.go b/mmapi/mocks/client_mock.go index 4a5891103..3811bc9aa 100644 --- a/mmapi/mocks/client_mock.go +++ b/mmapi/mocks/client_mock.go @@ -291,6 +291,62 @@ func (_c *MockClient_GetChannelByName_Call) RunAndReturn(run func(teamID string, return _c } +// GetChannelStats provides a mock function for the type MockClient +func (_mock *MockClient) GetChannelStats(channelID string) (*model.ChannelStats, error) { + ret := _mock.Called(channelID) + + if len(ret) == 0 { + panic("no return value specified for GetChannelStats") + } + + var r0 *model.ChannelStats + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) (*model.ChannelStats, error)); ok { + return returnFunc(channelID) + } + if returnFunc, ok := ret.Get(0).(func(string) *model.ChannelStats); ok { + r0 = returnFunc(channelID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.ChannelStats) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(channelID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetChannelStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetChannelStats' +type MockClient_GetChannelStats_Call struct { + *mock.Call +} + +// GetChannelStats is a helper method to define mock.On call +// - channelID +func (_e *MockClient_Expecter) GetChannelStats(channelID interface{}) *MockClient_GetChannelStats_Call { + return &MockClient_GetChannelStats_Call{Call: _e.mock.On("GetChannelStats", channelID)} +} + +func (_c *MockClient_GetChannelStats_Call) Run(run func(channelID string)) *MockClient_GetChannelStats_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockClient_GetChannelStats_Call) Return(channelStats *model.ChannelStats, err error) *MockClient_GetChannelStats_Call { + _c.Call.Return(channelStats, err) + return _c +} + +func (_c *MockClient_GetChannelStats_Call) RunAndReturn(run func(channelID string) (*model.ChannelStats, error)) *MockClient_GetChannelStats_Call { + _c.Call.Return(run) + return _c +} + // GetConfig provides a mock function for the type MockClient func (_mock *MockClient) GetConfig() *model.Config { ret := _mock.Called() diff --git a/mmtools/ask_another_user.go b/mmtools/ask_another_user.go new file mode 100644 index 000000000..e2934dd75 --- /dev/null +++ b/mmtools/ask_another_user.go @@ -0,0 +1,311 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package mmtools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/mattermost/mattermost-plugin-agents/v2/llm" +) + +const ( + // AskAnotherUserToolName is the runtime name of the built-in + // ask-another-user tool. + AskAnotherUserToolName = "AskAnotherUser" + + askAnotherUserDescription = "Ask a specific other Mattermost user (not the requesting user) a clarifying question. " + + "LAST RESORT: only use this after search and channel-history tools cannot answer the question, and only when one specific person clearly can. " + + "The question is delivered as a direct message card from the agent to that user; the conversation waits until they answer or decline. " + + "Ask only specific, self-contained questions answerable without extra context. The user may decline; if they do, proceed sensibly without the answer. " + + "The result contains the answer (selected options and/or free-form text), a decline marker, or a canceled marker. " + + "A canceled result means the requester stopped waiting: no answer was received and the target did not decline. Ask one user one question at a time." + + // Result status values in the C7 tool-result JSON. The enum is + // answered | declined | canceled (V2-C5). + askAnotherUserStatusAnswered = "answered" + askAnotherUserStatusDeclined = "declined" + askAnotherUserStatusCanceled = "canceled" + + // Maximum lengths (in runes) accepted by ValidateAskAnotherUserArgs. + // Oversized fields become error tool results so the model can shorten + // and retry; without caps, generated text flows unbounded into a DM + // post and its props (F-006). + askAnotherUserMaxQuestionRunes = 1000 + askAnotherUserMaxContextRunes = 500 + askAnotherUserMaxLabelRunes = 100 + askAnotherUserMaxDescriptionRunes = 200 +) + +// AskAnotherUserArgs is the LLM-visible input schema for the tool. +type AskAnotherUserArgs struct { + Username string `json:"username" jsonschema_description:"Mattermost username of the user to ask, without the leading @. Must not be the requesting user."` + Question string `json:"question" jsonschema_description:"The question to ask. Must be specific, self-contained, and answerable without extra context."` + Options []AskUserQuestionOption `json:"options,omitempty" jsonschema_description:"Optional choices to present (up to 5). Omit for a purely free-form question."` + MultiSelect bool `json:"multi_select,omitempty" jsonschema_description:"Set to true to let the user pick more than one option. Defaults to single-select."` + AllowFreeForm *bool `json:"allow_free_form,omitempty" jsonschema_description:"Whether the user may type their own answer. Defaults to true. Must not be false when options is empty."` + Context string `json:"context,omitempty" jsonschema_description:"One short sentence shown to the user explaining why you are asking."` +} + +// FreeFormEnabled reports whether the target may type a free-form answer. An +// omitted field (nil) means enabled; an explicit false disables. +func (a AskAnotherUserArgs) FreeFormEnabled() bool { + return a.AllowFreeForm == nil || *a.AllowFreeForm +} + +// CanonicalAskUsername returns the canonical form of a username argument: +// surrounding whitespace trimmed, then a single leading '@' stripped. Models +// routinely emit "@bob" or pad the name; user lookups and emptiness checks +// must run against the canonical form. +func CanonicalAskUsername(raw string) string { + return strings.TrimPrefix(strings.TrimSpace(raw), "@") +} + +// AskUserReservedPhrases are the canonical system phrases of the ask-user +// question card (V2-C3): substrings of the server-authored attribution, +// destination-disclosure, access-policy, and AI-content-caption lines. Model +// text must never be able to fake that chrome, so +// SanitizeAskAnotherUserArgs strips or rejects arguments containing them. +// Matching is a case-insensitive substring check per line — deliberately +// minimal (no fuzzy/regex/confusable handling); the real defense is the +// card's visual separation of model text from system chrome (F4a). +var AskUserReservedPhrases = []string{ + "asked on behalf of", + "asked by the", + "asked via the", + "your answer will be shared", + "your answer may be shared", + "running unattended", + "is restricted by an attribute-based", + "ai-generated content", +} + +// containsAskUserReservedPhrase reports whether s contains any reserved +// phrase, case-insensitively. +func containsAskUserReservedPhrase(s string) bool { + lower := strings.ToLower(s) + for _, phrase := range AskUserReservedPhrases { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +// stripAskUserReservedLines drops every line of s that contains a reserved +// phrase and rejoins the remainder. +func stripAskUserReservedLines(s string) string { + if !containsAskUserReservedPhrase(s) { + return s + } + lines := strings.Split(s, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if !containsAskUserReservedPhrase(line) { + kept = append(kept, line) + } + } + return strings.Join(kept, "\n") +} + +// SanitizeAskAnotherUserArgs applies the V2-C3 anti-impersonation rule: +// multi-line fields (question, context) have offending lines STRIPPED so an +// accidental match keeps the model productive; single-line option labels and +// descriptions are REJECTED outright because silently removing part of a +// choice would change its meaning. Called after ValidateAskAnotherUserArgs; +// stripping can only shorten fields, so no re-validation is needed. Errors +// become error tool results the model can react to by rephrasing. +func SanitizeAskAnotherUserArgs(args AskAnotherUserArgs) (AskAnotherUserArgs, error) { + args.Question = stripAskUserReservedLines(args.Question) + args.Context = stripAskUserReservedLines(args.Context) + if strings.TrimSpace(args.Question) == "" { + return args, errors.New("question must not be empty after removing reserved system phrasing") + } + for _, opt := range args.Options { + if containsAskUserReservedPhrase(opt.Label) || containsAskUserReservedPhrase(opt.Description) { + return args, errors.New("option labels and descriptions must not contain reserved system phrasing") + } + } + return args, nil +} + +// AskAnotherUserAnswer is the target's submitted answer. +type AskAnotherUserAnswer struct { + Selected []string `json:"selected"` + FreeForm string `json:"free_form"` +} + +// AskAnotherUserResult is the tool-result JSON fed to the LLM for answered +// questions (C7). Answered results always carry both selected and free_form, +// even when empty; declines are marshaled from a reduced shape that carries +// neither (see ResolveAskAnotherUserAnswer). +type AskAnotherUserResult struct { + Status string `json:"status"` // "answered" | "declined" + TargetUsername string `json:"target_username"` + Selected []string `json:"selected"` + FreeForm string `json:"free_form"` +} + +// NewAskAnotherUserTool returns the built-in ask-another-user tool. The +// resolver is an error backstop; dispatch happens in the conversations layer. +func NewAskAnotherUserTool() llm.Tool { + return llm.Tool{ + Name: AskAnotherUserToolName, + Description: askAnotherUserDescription, + Schema: llm.NewJSONSchemaFromStruct[AskAnotherUserArgs](), + DeferredResult: true, + Resolver: func(_ context.Context, _ *llm.Context, _ llm.ToolArgumentGetter) (string, error) { + return "", errors.New("AskAnotherUser is dispatched by the conversation layer and cannot be executed directly") + }, + } +} + +// ValidateAskAnotherUserArgs enforces the argument invariants that do not +// need Mattermost lookups (question/username non-blank, question/context/ +// option fields within length caps, at most 5 options with non-empty unique +// labels, allow_free_form=false requires options). Target-user resolution +// errors live in conversations.dispatchAskAnotherUser. +func ValidateAskAnotherUserArgs(args AskAnotherUserArgs) error { + if CanonicalAskUsername(args.Username) == "" { + return errors.New("username must not be empty") + } + if strings.TrimSpace(args.Question) == "" { + return errors.New("question must not be empty") + } + if utf8.RuneCountInString(args.Question) > askAnotherUserMaxQuestionRunes { + return fmt.Errorf("question must be at most %d characters", askAnotherUserMaxQuestionRunes) + } + if utf8.RuneCountInString(args.Context) > askAnotherUserMaxContextRunes { + return fmt.Errorf("context must be at most %d characters", askAnotherUserMaxContextRunes) + } + if len(args.Options) > 5 { + return errors.New("provide at most 5 options") + } + seen := make(map[string]bool, len(args.Options)) + for _, opt := range args.Options { + if strings.TrimSpace(opt.Label) == "" { + return errors.New("option labels must not be empty") + } + if utf8.RuneCountInString(opt.Label) > askAnotherUserMaxLabelRunes { + return fmt.Errorf("option labels must be at most %d characters", askAnotherUserMaxLabelRunes) + } + if utf8.RuneCountInString(opt.Description) > askAnotherUserMaxDescriptionRunes { + return fmt.Errorf("option descriptions must be at most %d characters", askAnotherUserMaxDescriptionRunes) + } + if seen[opt.Label] { + return fmt.Errorf("duplicate option label %q", opt.Label) + } + seen[opt.Label] = true + } + if !args.FreeFormEnabled() && len(args.Options) == 0 { + return errors.New("allow_free_form must not be false when no options are provided") + } + return nil +} + +// ResolveAskAnotherUserCancel returns the C7-family tool-result JSON for an +// initiator-canceled question (V2-C4): +// {"status":"canceled","target_username":"", +// "answer_received":false,"canceled_by":"requester"}. The username is +// parsed best-effort from the original tool input; an unparseable input +// yields an empty username but still a valid payload — the cancel result +// must always be writable. +func ResolveAskAnotherUserCancel(input json.RawMessage) (string, error) { + targetUsername := "" + var args AskAnotherUserArgs + if err := json.Unmarshal(input, &args); err == nil { + targetUsername = CanonicalAskUsername(args.Username) + } + + result, err := json.Marshal(struct { + Status string `json:"status"` + TargetUsername string `json:"target_username"` + AnswerReceived bool `json:"answer_received"` + CanceledBy string `json:"canceled_by"` + }{ + Status: askAnotherUserStatusCanceled, + TargetUsername: targetUsername, + AnswerReceived: false, + CanceledBy: "requester", + }) + if err != nil { + return "", fmt.Errorf("failed to marshal cancel result: %w", err) + } + return string(result), nil +} + +// ResolveAskAnotherUserAnswer validates the target's answer against the +// original tool arguments and returns the C7 result JSON. declined skips +// answer validation entirely and returns the decline payload. +func ResolveAskAnotherUserAnswer(input json.RawMessage, targetUsername string, answer AskAnotherUserAnswer, declined bool) (string, error) { + var args AskAnotherUserArgs + if err := json.Unmarshal(input, &args); err != nil { + return "", fmt.Errorf("failed to parse question arguments: %w", err) + } + + if declined { + // Declines carry neither selected nor free_form (C7). + result, err := json.Marshal(struct { + Status string `json:"status"` + TargetUsername string `json:"target_username"` + }{Status: askAnotherUserStatusDeclined, TargetUsername: targetUsername}) + if err != nil { + return "", fmt.Errorf("failed to marshal decline result: %w", err) + } + return string(result), nil + } + + selections := answer.Selected + // Whitespace-only free-form text counts as no answer. + freeForm := strings.TrimSpace(answer.FreeForm) + if freeForm != "" && !args.FreeFormEnabled() { + return "", errors.New("free-form answer is not allowed for this question") + } + hasFreeForm := freeForm != "" + + if len(selections) == 0 && !hasFreeForm { + return "", errors.New("no option selected and no text entered") + } + + chosen := len(selections) + if hasFreeForm { + chosen++ + } + if !args.MultiSelect && chosen > 1 { + return "", errors.New("question is single-select but multiple options were selected") + } + + valid := make(map[string]bool, len(args.Options)) + for _, opt := range args.Options { + valid[opt.Label] = true + } + seen := make(map[string]bool, len(selections)) + for _, sel := range selections { + if !valid[sel] { + return "", fmt.Errorf("selected option %q is not one of the offered options", sel) + } + if seen[sel] { + return "", fmt.Errorf("option %q selected more than once", sel) + } + seen[sel] = true + } + + if selections == nil { + selections = []string{} + } + result, err := json.Marshal(AskAnotherUserResult{ + Status: askAnotherUserStatusAnswered, + TargetUsername: targetUsername, + Selected: selections, + FreeForm: freeForm, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal answer result: %w", err) + } + return string(result), nil +} diff --git a/mmtools/ask_another_user_test.go b/mmtools/ask_another_user_test.go new file mode 100644 index 000000000..b62ef379b --- /dev/null +++ b/mmtools/ask_another_user_test.go @@ -0,0 +1,582 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package mmtools + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-agents/v2/config" + "github.com/mattermost/mattermost-plugin-agents/v2/llm" +) + +func boolPtr(b bool) *bool { return &b } + +func TestValidateAskAnotherUserArgs(t *testing.T) { + cases := []struct { + name string + args AskAnotherUserArgs + // wantErr asserts a specific error substring; wantAnyErr asserts + // rejection without pinning the wording (length-cap rows). + wantErr string + wantAnyErr bool + }{ + { + name: "valid free-form only", + args: AskAnotherUserArgs{Username: "bob", Question: "Which release was it?"}, + }, + { + name: "valid with options", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Ship it?", + Options: []AskUserQuestionOption{{Label: "Yes"}, {Label: "No"}}, + }, + }, + { + name: "empty question", + args: AskAnotherUserArgs{Username: "bob", Question: " "}, + wantErr: "question must not be empty", + }, + { + name: "empty username", + args: AskAnotherUserArgs{Question: "Q?"}, + wantErr: "username must not be empty", + }, + { + name: "lone @ username is empty after canonicalization", + args: AskAnotherUserArgs{Username: "@", Question: "Q?"}, + wantErr: "username must not be empty", + }, + { + name: "whitespace-wrapped lone @ username is empty after canonicalization", + args: AskAnotherUserArgs{Username: " @ ", Question: "Q?"}, + wantErr: "username must not be empty", + }, + { + name: "@-prefixed username is valid", + args: AskAnotherUserArgs{Username: "@bob", Question: "Q?"}, + }, + { + name: "whitespace-wrapped @-prefixed username is valid", + args: AskAnotherUserArgs{Username: " @bob ", Question: "Q?"}, + }, + { + name: "too many options", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{ + {Label: "A"}, {Label: "B"}, {Label: "C"}, + {Label: "D"}, {Label: "E"}, {Label: "F"}, + }, + }, + wantAnyErr: true, + }, + { + name: "duplicate labels", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{Label: "Same"}, {Label: "Same"}}, + }, + wantErr: "duplicate option label", + }, + { + name: "empty label", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{Label: " "}}, + }, + wantErr: "labels must not be empty", + }, + { + name: "free-form off without options", + args: AskAnotherUserArgs{Username: "bob", Question: "Q?", AllowFreeForm: boolPtr(false)}, + wantErr: "allow_free_form must not be false", + }, + { + name: "question at max length passes", + args: AskAnotherUserArgs{ + Username: "bob", + Question: strings.Repeat("q", askAnotherUserMaxQuestionRunes), + }, + }, + { + // Caps are rune counts, not byte counts: 1000 two-byte runes + // must pass even though the byte length is double the cap. + name: "multibyte question at max rune length passes", + args: AskAnotherUserArgs{ + Username: "bob", + Question: strings.Repeat("é", askAnotherUserMaxQuestionRunes), + }, + }, + { + name: "question over max length rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: strings.Repeat("q", askAnotherUserMaxQuestionRunes+1), + }, + wantAnyErr: true, + }, + { + name: "context at max length passes", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Context: strings.Repeat("c", askAnotherUserMaxContextRunes), + }, + }, + { + name: "context over max length rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Context: strings.Repeat("c", askAnotherUserMaxContextRunes+1), + }, + wantAnyErr: true, + }, + { + name: "option label at max length passes", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{Label: strings.Repeat("l", askAnotherUserMaxLabelRunes)}}, + }, + }, + { + name: "option label over max length rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{Label: strings.Repeat("l", askAnotherUserMaxLabelRunes+1)}}, + }, + wantAnyErr: true, + }, + { + name: "option description at max length passes", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{ + Label: "A", + Description: strings.Repeat("d", askAnotherUserMaxDescriptionRunes), + }}, + }, + }, + { + name: "option description over max length rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Q?", + Options: []AskUserQuestionOption{{ + Label: "A", + Description: strings.Repeat("d", askAnotherUserMaxDescriptionRunes+1), + }}, + }, + wantAnyErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateAskAnotherUserArgs(tc.args) + if tc.wantAnyErr { + require.Error(t, err) + return + } + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestCanonicalAskUsername(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + {name: "plain username unchanged", raw: "bob", want: "bob"}, + {name: "leading @ stripped", raw: "@bob", want: "bob"}, + {name: "whitespace trimmed then @ stripped", raw: " @bob ", want: "bob"}, + {name: "whitespace only becomes empty", raw: " ", want: ""}, + {name: "lone @ becomes empty", raw: "@", want: ""}, + {name: "whitespace-wrapped lone @ becomes empty", raw: " @ ", want: ""}, + {name: "only a single leading @ is stripped", raw: "@@bob", want: "@bob"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, CanonicalAskUsername(tc.raw)) + }) + } +} + +func TestResolveAskAnotherUserAnswer(t *testing.T) { + choiceInput := json.RawMessage(`{ + "username": "bob", + "question": "Ship it?", + "options": [{"label": "Yes, ship it"}, {"label": "Hold off"}] + }`) + multiSelectInput := json.RawMessage(`{ + "username": "bob", + "question": "Which releases?", + "options": [{"label": "4.2.0"}, {"label": "4.2.1"}, {"label": "4.3.0"}], + "multi_select": true + }`) + freeFormInput := json.RawMessage(`{ + "username": "bob", + "question": "Which release was it?" + }`) + noFreeFormInput := json.RawMessage(`{ + "username": "bob", + "question": "Ship it?", + "options": [{"label": "Yes"}, {"label": "No"}], + "allow_free_form": false + }`) + + cases := []struct { + name string + input json.RawMessage + answer AskAnotherUserAnswer + declined bool + want string + wantErr string + }{ + { + name: "choice answer matches C7 example", + input: choiceInput, + answer: AskAnotherUserAnswer{Selected: []string{"Yes, ship it"}}, + want: `{"status":"answered","target_username":"bob","selected":["Yes, ship it"],"free_form":""}`, + }, + { + name: "free-form answer matches C7 example", + input: freeFormInput, + answer: AskAnotherUserAnswer{FreeForm: "It was the 4.2.1 release, not 4.2.0"}, + want: `{"status":"answered","target_username":"bob","selected":[],"free_form":"It was the 4.2.1 release, not 4.2.0"}`, + }, + { + name: "multi-select two labels", + input: multiSelectInput, + answer: AskAnotherUserAnswer{Selected: []string{"4.2.0", "4.2.1"}}, + want: `{"status":"answered","target_username":"bob","selected":["4.2.0","4.2.1"],"free_form":""}`, + }, + { + name: "decline matches C7 example", + input: choiceInput, + declined: true, + want: `{"status":"declined","target_username":"bob"}`, + }, + { + name: "decline with garbage selections still succeeds", + input: choiceInput, + answer: AskAnotherUserAnswer{Selected: []string{"not an option"}, FreeForm: "junk"}, + declined: true, + want: `{"status":"declined","target_username":"bob"}`, + }, + { + name: "invalid option label", + input: choiceInput, + answer: AskAnotherUserAnswer{Selected: []string{"Maybe"}}, + wantErr: "not one of the offered options", + }, + { + name: "free-form rejected when disallowed", + input: noFreeFormInput, + answer: AskAnotherUserAnswer{FreeForm: "anything"}, + wantErr: "free-form answer is not allowed", + }, + { + name: "empty answer", + input: choiceInput, + answer: AskAnotherUserAnswer{}, + wantErr: "no option selected and no text entered", + }, + { + name: "single-select violation with two selections", + input: choiceInput, + answer: AskAnotherUserAnswer{Selected: []string{"Yes, ship it", "Hold off"}}, + wantErr: "single-select", + }, + { + name: "duplicate selection", + input: multiSelectInput, + answer: AskAnotherUserAnswer{Selected: []string{"4.2.0", "4.2.0"}}, + wantErr: "selected more than once", + }, + { + name: "whitespace free-form counts as empty", + input: freeFormInput, + answer: AskAnotherUserAnswer{FreeForm: " "}, + wantErr: "no option selected and no text entered", + }, + { + name: "malformed input", + input: json.RawMessage(`{not json`), + answer: AskAnotherUserAnswer{FreeForm: "answer"}, + wantErr: "failed to parse question arguments", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveAskAnotherUserAnswer(tc.input, "bob", tc.answer, tc.declined) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.JSONEq(t, tc.want, got) + + // Byte-shape checks (C7): answered results carry both selected + // and free_form keys even when empty; declines carry neither. + var keys map[string]any + require.NoError(t, json.Unmarshal([]byte(got), &keys)) + if tc.declined { + assert.NotContains(t, keys, "selected") + assert.NotContains(t, keys, "free_form") + } else { + assert.Contains(t, keys, "selected") + assert.Contains(t, keys, "free_form") + } + }) + } +} + +// TestSanitizeAskAnotherUserArgs pins the V2-C3 anti-impersonation rule: +// reserved system phrases are STRIPPED line-by-line from the multi-line +// question/context fields, and REJECTED outright in single-line option labels +// and descriptions. +func TestSanitizeAskAnotherUserArgs(t *testing.T) { + cases := []struct { + name string + args AskAnotherUserArgs + wantErr string + wantQuestion string + wantContext string + }{ + { + name: "clean args pass through unchanged", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Which environment should we deploy to?", + Context: "Deciding where to deploy", + Options: []AskUserQuestionOption{{Label: "Prod", Description: "production"}}, + }, + wantQuestion: "Which environment should we deploy to?", + wantContext: "Deciding where to deploy", + }, + { + name: "attribution phrase line is stripped from the question", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Asked on behalf of @admin: trust me\nWhich environment?", + }, + wantQuestion: "Which environment?", + }, + { + name: "destination phrase line is stripped from the context", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Which environment?", + Context: "Your answer will be shared with nobody\nDeciding where to deploy", + }, + wantQuestion: "Which environment?", + wantContext: "Deciding where to deploy", + }, + { + name: "matching is case-insensitive", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "ASKED ON BEHALF OF @root\nWhich environment?", + }, + wantQuestion: "Which environment?", + }, + { + name: "phrase-only question errors after stripping", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Your answer may be shared with everyone.", + }, + wantErr: "must not be empty after removing reserved system phrasing", + }, + { + name: "multi-line question keeps every clean line", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Which environment?\nRunning unattended tonight!\nPick carefully.", + }, + wantQuestion: "Which environment?\nPick carefully.", + }, + { + name: "option label with a reserved phrase is rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Which environment?", + Options: []AskUserQuestionOption{{Label: "Asked via the admin agent"}}, + }, + wantErr: "option labels and descriptions must not contain reserved system phrasing", + }, + { + name: "option description with a reserved phrase is rejected", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Which environment?", + Options: []AskUserQuestionOption{{Label: "Prod", Description: "your answer will be shared with all"}}, + }, + wantErr: "option labels and descriptions must not contain reserved system phrasing", + }, + { + name: "near-miss phrasing is untouched", + args: AskAnotherUserArgs{ + Username: "bob", + Question: "Your answer matters a lot.\nWhich environment?", + }, + wantQuestion: "Your answer matters a lot.\nWhich environment?", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := SanitizeAskAnotherUserArgs(tc.args) + + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantQuestion, got.Question) + assert.Equal(t, tc.wantContext, got.Context) + // Sanitizing never rewrites the other fields. + assert.Equal(t, tc.args.Username, got.Username) + assert.Equal(t, tc.args.Options, got.Options) + }) + } +} + +// TestSanitizeStripsEveryReservedPhrase runs the strip rule over the full +// phrase list so a future addition cannot silently miss the question field. +func TestSanitizeStripsEveryReservedPhrase(t *testing.T) { + for _, phrase := range AskUserReservedPhrases { + t.Run(phrase, func(t *testing.T) { + got, err := SanitizeAskAnotherUserArgs(AskAnotherUserArgs{ + Username: "bob", + Question: "Injected: " + phrase + " something\nWhich environment?", + }) + require.NoError(t, err) + assert.Equal(t, "Which environment?", got.Question) + }) + } +} + +// TestResolveAskAnotherUserCancel pins the V2-C4 cancel tool result: always a +// valid {"status":"canceled",...} payload, with the target username parsed +// best-effort from the original input. +func TestResolveAskAnotherUserCancel(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "valid input yields the canceled result", + input: `{"username":"bob","question":"Which environment?"}`, + want: `{"status":"canceled","target_username":"bob","answer_received":false,"canceled_by":"requester"}`, + }, + { + name: "at-prefixed username is canonicalized", + input: `{"username":" @bob ","question":"Which environment?"}`, + want: `{"status":"canceled","target_username":"bob","answer_received":false,"canceled_by":"requester"}`, + }, + { + name: "unparseable input still yields a valid payload", + input: `{not json`, + want: `{"status":"canceled","target_username":"","answer_received":false,"canceled_by":"requester"}`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ResolveAskAnotherUserCancel(json.RawMessage(tc.input)) + require.NoError(t, err) + assert.JSONEq(t, tc.want, got) + }) + } +} + +func TestAskAnotherUserResolverIsBackstopOnly(t *testing.T) { + tool := NewAskAnotherUserTool() + require.NotNil(t, tool.Resolver) + + _, err := tool.Resolver(context.Background(), nil, func(args any) error { return nil }) + require.Error(t, err) + assert.Contains(t, err.Error(), "dispatched by the conversation layer") +} + +func TestGetToolsRegistersAskAnotherUserRegardlessOfInteractivity(t *testing.T) { + cases := []struct { + name string + llmContext *llm.Context + wantAskUser bool + wantAskAnother bool + }{ + { + name: "interactive context includes both tools", + llmContext: &llm.Context{ToolCatalog: llm.ToolCatalogContext{InteractiveUserPresent: true}}, + wantAskUser: true, + wantAskAnother: true, + }, + { + name: "non-interactive context still includes AskAnotherUser", + llmContext: &llm.Context{}, + wantAskUser: false, + wantAskAnother: true, + }, + { + name: "nil context still includes AskAnotherUser", + llmContext: nil, + wantAskUser: false, + wantAskAnother: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Toggle on: this test pins interactivity independence, not the + // V2-C1 master gate (TestGetToolsAskAnotherUserToggle). + provider := NewMMToolProvider(nil, nil, func() *config.Config { + return &config.Config{EnableAskAnotherUser: true} + }) + tools := provider.GetTools(nil, tc.llmContext) + + foundAskUser := false + foundAskAnother := false + for _, tool := range tools { + switch tool.Name { + case AskUserQuestionToolName: + foundAskUser = true + case AskAnotherUserToolName: + foundAskAnother = true + assert.True(t, tool.DeferredResult, "AskAnotherUser must be a deferred-result tool") + assert.Empty(t, tool.UserInteraction, "AskAnotherUser must not be a user-interaction tool") + assert.Empty(t, tool.ServerOrigin, "AskAnotherUser is a built-in (empty origin)") + } + } + assert.Equal(t, tc.wantAskUser, foundAskUser) + assert.Equal(t, tc.wantAskAnother, foundAskAnother) + }) + } +} diff --git a/mmtools/ask_user_question_test.go b/mmtools/ask_user_question_test.go index 91dbfeca4..d63ed227f 100644 --- a/mmtools/ask_user_question_test.go +++ b/mmtools/ask_user_question_test.go @@ -200,7 +200,7 @@ func TestGetToolsGatesAskUserQuestionOnInteractiveContext(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - provider := NewMMToolProvider(nil, nil) + provider := NewMMToolProvider(nil, nil, nil) tools := provider.GetTools(nil, tc.llmContext) found := false diff --git a/mmtools/provider.go b/mmtools/provider.go index 10ad855f5..74491f6f2 100644 --- a/mmtools/provider.go +++ b/mmtools/provider.go @@ -5,6 +5,7 @@ package mmtools import ( "github.com/mattermost/mattermost-plugin-agents/v2/bots" + "github.com/mattermost/mattermost-plugin-agents/v2/config" "github.com/mattermost/mattermost-plugin-agents/v2/llm" "github.com/mattermost/mattermost-plugin-agents/v2/mmapi" ) @@ -20,13 +21,17 @@ type ToolProvider interface { type MMToolProvider struct { pluginAPI mmapi.Client webSearch WebSearchService + cfgGetter func() *config.Config } -// NewMMToolProvider creates a new tool provider -func NewMMToolProvider(pluginAPI mmapi.Client, webSearch WebSearchService) *MMToolProvider { +// NewMMToolProvider creates a new tool provider. cfgGetter supplies the live +// plugin configuration for catalog gates (same pattern as +// NewWebSearchService); a nil getter fails closed on gated tools. +func NewMMToolProvider(pluginAPI mmapi.Client, webSearch WebSearchService, cfgGetter func() *config.Config) *MMToolProvider { return &MMToolProvider{ pluginAPI: pluginAPI, webSearch: webSearch, + cfgGetter: cfgGetter, } } @@ -61,6 +66,16 @@ func (p *MMToolProvider) GetTools(bot *bots.Bot, llmContext *llm.Context) []llm. builtInTools = append(builtInTools, NewAskUserQuestionTool()) } + // AskAnotherUser does not require an interactive invoker (the *target* + // answers), but it is experimental and master-gated by the admin toggle + // (V2-C1): with the toggle off the model never sees the tool. Fail + // closed on a nil getter or config. + if p.cfgGetter != nil { + if cfg := p.cfgGetter(); cfg != nil && cfg.EnableAskAnotherUser { + builtInTools = append(builtInTools, NewAskAnotherUserTool()) + } + } + return builtInTools } diff --git a/mmtools/provider_test.go b/mmtools/provider_test.go index 6e00b4ab0..b7c427d18 100644 --- a/mmtools/provider_test.go +++ b/mmtools/provider_test.go @@ -6,6 +6,7 @@ package mmtools import ( "testing" + "github.com/mattermost/mattermost-plugin-agents/v2/config" "github.com/mattermost/mattermost-plugin-agents/v2/llm" "github.com/mattermost/mattermost-plugin-agents/v2/mmapi" "github.com/mattermost/mattermost-plugin-agents/v2/mmapi/mocks" @@ -37,7 +38,7 @@ func TestGetToolsCreateFileCatalog(t *testing.T) { if !tt.nilClient { client = mocks.NewMockClient(t) } - provider := NewMMToolProvider(client, nil) + provider := NewMMToolProvider(client, nil, nil) names := []string{} for _, tool := range provider.GetTools(nil, tt.llmContext) { @@ -52,3 +53,58 @@ func TestGetToolsCreateFileCatalog(t *testing.T) { }) } } + +// TestGetToolsAskAnotherUserToggle pins the V2-C1 master gate: AskAnotherUser +// is cataloged only when the admin toggle is on, failing closed on a nil +// config getter or nil config, and the gate never disturbs the rest of the +// catalog. +func TestGetToolsAskAnotherUserToggle(t *testing.T) { + interactiveCtx := &llm.Context{ToolCatalog: llm.ToolCatalogContext{InteractiveUserPresent: true}} + + tests := []struct { + name string + cfgGetter func() *config.Config + want bool + }{ + { + name: "toggle on catalogs the tool", + cfgGetter: func() *config.Config { return &config.Config{EnableAskAnotherUser: true} }, + want: true, + }, + { + name: "toggle off hides the tool", + cfgGetter: func() *config.Config { return &config.Config{EnableAskAnotherUser: false} }, + want: false, + }, + { + name: "nil config getter fails closed", + cfgGetter: nil, + want: false, + }, + { + name: "nil config from the getter fails closed", + cfgGetter: func() *config.Config { return nil }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := NewMMToolProvider(nil, nil, tt.cfgGetter) + + names := []string{} + for _, tool := range provider.GetTools(nil, interactiveCtx) { + names = append(names, tool.Name) + } + + if tt.want { + require.Contains(t, names, AskAnotherUserToolName) + } else { + require.NotContains(t, names, AskAnotherUserToolName) + } + // The master gate must not disturb the rest of the catalog: + // the interactive context always yields AskUserQuestion. + require.Contains(t, names, AskUserQuestionToolName) + }) + } +} diff --git a/server/main.go b/server/main.go index 885f6b529..4d9188b71 100644 --- a/server/main.go +++ b/server/main.go @@ -347,6 +347,9 @@ func (p *Plugin) OnActivate() error { toolProvider := mmtools.NewMMToolProvider( mmClient, webSearchService, + func() *config.Config { + return p.configuration.Config() + }, ) // Build redirect URI diff --git a/streaming/streaming.go b/streaming/streaming.go index 4c12c6fd7..a6ad39e23 100644 --- a/streaming/streaming.go +++ b/streaming/streaming.go @@ -174,6 +174,7 @@ func (a *turnAccumulator) buildContentBlocks() []conversation.ContentBlock { Shared: conversation.BoolPtr(a.isDM), UserInteraction: tc.UserInteraction, WouldAutoExecute: tc.WouldAutoExecute, + DeferredResult: tc.DeferredResult, }) } @@ -457,6 +458,8 @@ func (p *MMPostStreamService) broadcastToolCalls(post *model.Post, toolCalls []l // auto-run tools as AutoApproved (not Success) and errored ones as Error; // user-approved tools are later tagged Success by the approval flow. Anything // else — most commonly Pending — indicates the event hasn't been executed yet. +// ToolCallStatusWaiting is deliberately non-terminal here: waiting batches +// must be retained in the turn accumulator so finalizeTurn persists them. func isResolvedToolCallsEvent(toolCalls []llm.ToolCall) bool { if len(toolCalls) == 0 { return false @@ -486,6 +489,7 @@ func redactToolCalls(toolCalls []llm.ToolCall) []llm.ToolCall { Status: tc.Status, UserInteraction: tc.UserInteraction, WouldAutoExecute: tc.WouldAutoExecute, + DeferredResult: tc.DeferredResult, } } return redacted diff --git a/streaming/test_helpers_test.go b/streaming/test_helpers_test.go index 1c1897118..d2915d2fa 100644 --- a/streaming/test_helpers_test.go +++ b/streaming/test_helpers_test.go @@ -56,6 +56,10 @@ func (c *fakeStreamingClient) GetChannel(channelID string) (*model.Channel, erro return channel, nil } +func (c *fakeStreamingClient) GetChannelStats(_ string) (*model.ChannelStats, error) { + return nil, fmt.Errorf("not implemented") +} + func (c *fakeStreamingClient) GetConfig() *model.Config { locale := "en" return &model.Config{ diff --git a/streaming/turn_persistence_test.go b/streaming/turn_persistence_test.go index a8acab3be..3cbbe01f8 100644 --- a/streaming/turn_persistence_test.go +++ b/streaming/turn_persistence_test.go @@ -1576,3 +1576,84 @@ func TestRedactToolCallsPreservesUserInteraction(t *testing.T) { require.Equal(t, llm.UserInteractionSelect, redacted[0].UserInteraction) require.True(t, redacted[0].WouldAutoExecute) } + +// TestBuildContentBlocksDeferredWaiting pins that a dispatched deferred call +// persists with the waiting status and the deferred flag intact so the +// webapp can render the waiting affordance after reload. +func TestBuildContentBlocksDeferredWaiting(t *testing.T) { + acc := newTurnAccumulator("conv-id", "post-id", "", false, false) + acc.toolCalls = []llm.ToolCall{{ + ID: "ask-1", + Name: "AskAnotherUser", + Arguments: json.RawMessage(`{"username":"bob","question":"Q?"}`), + Status: llm.ToolCallStatusWaiting, + DeferredResult: true, + }} + + blocks := acc.buildContentBlocks() + + require.Len(t, blocks, 1) + require.Equal(t, conversation.BlockTypeToolUse, blocks[0].Type) + require.Equal(t, conversation.StatusWaiting, blocks[0].Status) + require.True(t, blocks[0].DeferredResult) +} + +// TestIsResolvedToolCallsEventWaiting pins that waiting is non-terminal: +// waiting batches must be retained by the accumulator (and later persisted +// by finalizeTurn) rather than treated as an executed round. +func TestIsResolvedToolCallsEventWaiting(t *testing.T) { + tests := []struct { + name string + toolCalls []llm.ToolCall + want bool + }{ + { + name: "waiting alone is not resolved", + toolCalls: []llm.ToolCall{{ID: "tc1", Status: llm.ToolCallStatusWaiting}}, + want: false, + }, + { + name: "waiting mixed with terminal statuses is not resolved", + toolCalls: []llm.ToolCall{ + {ID: "tc1", Status: llm.ToolCallStatusWaiting}, + {ID: "tc2", Status: llm.ToolCallStatusError}, + {ID: "tc3", Status: llm.ToolCallStatusSuccess}, + }, + want: false, + }, + { + name: "all terminal statuses is resolved", + toolCalls: []llm.ToolCall{ + {ID: "tc1", Status: llm.ToolCallStatusError}, + {ID: "tc2", Status: llm.ToolCallStatusAutoApproved}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isResolvedToolCallsEvent(tt.toolCalls)) + }) + } +} + +// TestRedactToolCallsKeepsDeferredResult pins that the observer-facing +// redacted copy keeps the deferred flag (name/status-level metadata, needed +// for the generic waiting placeholder) while dropping payloads. +func TestRedactToolCallsKeepsDeferredResult(t *testing.T) { + redacted := redactToolCalls([]llm.ToolCall{{ + ID: "ask-1", + Name: "AskAnotherUser", + Arguments: json.RawMessage(`{"username":"bob","question":"secret"}`), + Result: `{"status":"answered"}`, + Status: llm.ToolCallStatusWaiting, + DeferredResult: true, + }}) + + require.Len(t, redacted, 1) + require.Empty(t, redacted[0].Arguments) + require.Empty(t, redacted[0].Result) + require.True(t, redacted[0].DeferredResult) + require.Equal(t, llm.ToolCallStatusWaiting, redacted[0].Status) +} diff --git a/toolrunner/toolrunner.go b/toolrunner/toolrunner.go index c06691485..c3aa2ac69 100644 --- a/toolrunner/toolrunner.go +++ b/toolrunner/toolrunner.go @@ -25,8 +25,9 @@ const MaxToolRounds = limits.MaxToolRounds // It calls the LLM, checks for tool calls in the stream, executes // approved ones, appends results back to the request, and calls again. type ToolRunner struct { - llm llm.LanguageModel - maxRounds int + llm llm.LanguageModel + maxRounds int + deferredDispatcher DeferredDispatcher } // Option configures a ToolRunner at construction time. @@ -42,6 +43,20 @@ func WithMaxRounds(n int) Option { } } +// DeferredDispatcher performs the side-effect dispatch for a deferred-result +// tool call (llm.ToolCall.DeferredResult). Returning nil means the call is +// now waiting for an out-of-band result; returning an error converts the +// call into an error tool result. +type DeferredDispatcher func(ctx context.Context, call llm.ToolCall) error + +// WithDeferredDispatcher installs the dispatcher used for deferred-result +// tool calls. Without one, batches containing deferred calls are emitted +// pending instead of executed, so callers that cannot dispatch never strand +// a call silently. +func WithDeferredDispatcher(d DeferredDispatcher) Option { + return func(r *ToolRunner) { r.deferredDispatcher = d } +} + // New creates a ToolRunner bound to the given language model. Pass // WithMaxRounds to override the default per-agent tool-call ceiling. func New(lm llm.LanguageModel, opts ...Option) *ToolRunner { @@ -322,6 +337,79 @@ func (r *ToolRunner) runLoop( return } + // Deferred-result calls cannot be executed synchronously: dispatch the + // side effect, mark them waiting, and stop the run. Handled before the + // normal execute path so a mixed batch never partially executes. + hasDeferred := false + for _, tc := range toolCalls { + if tc.DeferredResult { + hasDeferred = true + break + } + } + if hasDeferred { + if r.deferredDispatcher == nil { + // Defensive: callers without a dispatcher (bridge API, channels + // digest) fall back to the pending path so nothing strands. + for i := range toolCalls { + toolCalls[i].WouldAutoExecute = approved[i] + } + r.deliverToolTurns(result, onToolTurns) + output <- llm.TextStreamEvent{Type: llm.EventTypeToolCalls, Value: toolCalls} + output <- llm.TextStreamEvent{Type: llm.EventTypeEnd} + return + } + + deferredCount, failedCount, nonDeferredCount := 0, 0, 0 + var failedResults []ToolResult + for i := range toolCalls { + if !toolCalls[i].DeferredResult { + // Not executed this round; runs on resume via HandleToolCall. + toolCalls[i].WouldAutoExecute = true + toolCalls[i].Status = llm.ToolCallStatusPending + nonDeferredCount++ + continue + } + deferredCount++ + if dispatchErr := r.deferredDispatcher(ctx, toolCalls[i]); dispatchErr != nil { + toolCalls[i].Status = llm.ToolCallStatusError + toolCalls[i].Result = dispatchErr.Error() + failedCount++ + failedResults = append(failedResults, ToolResult{ + ToolCallID: toolCalls[i].ID, + Name: toolCalls[i].Name, + Result: dispatchErr.Error(), + IsError: true, + }) + continue + } + toolCalls[i].Status = llm.ToolCallStatusWaiting + } + + // Every deferred call failed and nothing else is in the batch: + // surface the failures as a normal executed round so the model + // can correct itself, and keep looping. failedResults is + // index-aligned with toolCalls here because every call failed. + if failedCount == deferredCount && nonDeferredCount == 0 { + resolvedToolCalls := buildResolvedToolCalls(toolCalls, failedResults) + appendToolTurnAndPost(result, &request, text.String(), reasoningData, serverTools, resolvedToolCalls, failedResults, usage) + output <- llm.TextStreamEvent{Type: llm.EventTypeToolCalls, Value: resolvedToolCalls} + if llm.CountTrailingFailedToolCalls(request.Posts) >= llm.MaxConsecutiveToolCallFailures { + request.Posts = llm.EnsureToolRetryLimitSystemMessage(request.Posts) + currentOpts = append(currentOpts, llm.WithToolsDisabled()) + } + continue + } + + // Waiting (and any pending) statuses on one event: the streaming + // accumulator keeps non-terminal batches and finalizeTurn + // persists them. + r.deliverToolTurns(result, onToolTurns) + output <- llm.TextStreamEvent{Type: llm.EventTypeToolCalls, Value: toolCalls} + output <- llm.TextStreamEvent{Type: llm.EventTypeEnd} + return + } + // All calls passed the policy: stamp them so the pending broadcast // (and, if the stream is interrupted mid-execution, the persisted // blocks) carry the auto-execute signal instead of implying an diff --git a/toolrunner/toolrunner_deferred_test.go b/toolrunner/toolrunner_deferred_test.go new file mode 100644 index 000000000..171902f8d --- /dev/null +++ b/toolrunner/toolrunner_deferred_test.go @@ -0,0 +1,301 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package toolrunner + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-agents/v2/llm" +) + +// newDeferredToolStore builds a store with a deferred-result tool (backstop +// resolver) plus any normal tools defined the usual way. +func newDeferredToolStore(deferredName string, normalTools ...llm.Tool) *llm.ToolStore { + store := llm.NewNoTools() + store.AddTools(append([]llm.Tool{{ + Name: deferredName, + Description: "deferred test tool", + DeferredResult: true, + Resolver: func(_ context.Context, _ *llm.Context, _ llm.ToolArgumentGetter) (string, error) { + return "", fmt.Errorf("%s is dispatched by the conversation layer and cannot be executed directly", deferredName) + }, + }}, normalTools...)) + return store +} + +// dispatchRecorder records DeferredDispatcher invocations and returns a +// scripted error. +type dispatchRecorder struct { + calls []llm.ToolCall + err error +} + +func (d *dispatchRecorder) dispatch(_ context.Context, call llm.ToolCall) error { + d.calls = append(d.calls, call) + return d.err +} + +func TestRunLoopDeferredDispatch(t *testing.T) { + deferredBatch := []llm.TextStreamEvent{ + {Type: llm.EventTypeToolCalls, Value: []llm.ToolCall{ + {ID: "ask1", Name: "AskAnotherUser", Arguments: json.RawMessage(`{"username":"bob","question":"Q?"}`)}, + }}, + {Type: llm.EventTypeEnd}, + } + finalText := testResponse{events: []llm.TextStreamEvent{ + {Type: llm.EventTypeText, Value: "recovered"}, + {Type: llm.EventTypeEnd}, + }} + + // collectToolCallEvents drains the stream and returns every ToolCall from + // EventTypeToolCalls events, in order. + collectToolCallEvents := func(t *testing.T, result *ToolRunResult) []llm.ToolCall { + t.Helper() + var events []llm.ToolCall + for event := range result.Stream.Stream { + if event.Type == llm.EventTypeToolCalls { + events = append(events, event.Value.([]llm.ToolCall)...) + } + } + return events + } + + t.Run("deferred dispatch success stops run waiting", func(t *testing.T) { + inner := &testLLM{responses: []testResponse{{events: deferredBatch}}} + recorder := &dispatchRecorder{} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser")}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + + events := collectToolCallEvents(t, result) + require.Len(t, events, 1) + assert.Equal(t, llm.ToolCallStatusWaiting, events[0].Status) + assert.True(t, events[0].DeferredResult) + + require.Len(t, recorder.calls, 1) + assert.Equal(t, "ask1", recorder.calls[0].ID) + assert.True(t, recorder.calls[0].DeferredResult, "dispatcher must receive the enriched call") + + assert.Empty(t, result.ToolTurns) + assert.Equal(t, 1, inner.callCount, "run must stop after dispatch") + }) + + t.Run("dispatch failure alone continues loop", func(t *testing.T) { + inner := &testLLM{responses: []testResponse{{events: deferredBatch}, finalText}} + recorder := &dispatchRecorder{err: fmt.Errorf("user \"bob\" not found")} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser")}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + + text, readErr := result.Stream.ReadAll() + require.NoError(t, readErr) + assert.Equal(t, "recovered", text) + + require.Len(t, result.ToolTurns, 1) + require.Len(t, result.ToolTurns[0].ToolResults, 1) + assert.True(t, result.ToolTurns[0].ToolResults[0].IsError) + assert.Contains(t, result.ToolTurns[0].ToolResults[0].Result, "not found") + + assert.Equal(t, 2, inner.callCount, "loop must continue so the model can correct itself") + secondReq := inner.capturedRequests[1] + require.Greater(t, len(secondReq.Posts), 1, "request posts must grow with the failed round") + botPost := secondReq.Posts[len(secondReq.Posts)-1] + require.Len(t, botPost.ToolUse, 1) + assert.Equal(t, llm.ToolCallStatusError, botPost.ToolUse[0].Status) + }) + + t.Run("no dispatcher falls back to pending", func(t *testing.T) { + inner := &testLLM{responses: []testResponse{{events: deferredBatch}}} + runner := New(inner) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser")}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + + events := collectToolCallEvents(t, result) + require.Len(t, events, 1) + assert.Equal(t, llm.ToolCallStatusPending, events[0].Status) + assert.True(t, events[0].WouldAutoExecute) + + assert.Empty(t, result.ToolTurns) + assert.Equal(t, 1, inner.callCount) + }) + + t.Run("mixed batch tags non-deferred pending without executing it", func(t *testing.T) { + normalResolverCalled := false + normalTool := llm.Tool{ + Name: "normal_tool", + Description: "normal test tool", + Resolver: func(_ context.Context, _ *llm.Context, _ llm.ToolArgumentGetter) (string, error) { + normalResolverCalled = true + return "should_not_run", nil + }, + } + inner := &testLLM{responses: []testResponse{{events: []llm.TextStreamEvent{ + {Type: llm.EventTypeToolCalls, Value: []llm.ToolCall{ + {ID: "ask1", Name: "AskAnotherUser", Arguments: json.RawMessage(`{"username":"bob","question":"Q?"}`)}, + {ID: "tc1", Name: "normal_tool", Arguments: json.RawMessage(`{}`)}, + }}, + {Type: llm.EventTypeEnd}, + }}}} + recorder := &dispatchRecorder{} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser", normalTool)}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + + events := collectToolCallEvents(t, result) + require.Len(t, events, 2) + byID := map[string]llm.ToolCall{events[0].ID: events[0], events[1].ID: events[1]} + assert.Equal(t, llm.ToolCallStatusWaiting, byID["ask1"].Status) + assert.Equal(t, llm.ToolCallStatusPending, byID["tc1"].Status) + assert.True(t, byID["tc1"].WouldAutoExecute) + + assert.False(t, normalResolverCalled, "non-deferred call must not execute this round") + require.Len(t, recorder.calls, 1) + assert.Empty(t, result.ToolTurns) + assert.Equal(t, 1, inner.callCount) + }) + + t.Run("not-all-approved leaves deferred pending without dispatching", func(t *testing.T) { + inner := &testLLM{responses: []testResponse{{events: deferredBatch}}} + recorder := &dispatchRecorder{} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser")}, + } + + result, err := runner.Run(context.Background(), request, neverExecute, nil) + require.NoError(t, err) + + events := collectToolCallEvents(t, result) + require.Len(t, events, 1) + assert.Equal(t, llm.ToolCallStatusPending, events[0].Status) + assert.False(t, events[0].WouldAutoExecute) + + assert.Empty(t, recorder.calls, "dispatcher must not run under the ask policy") + assert.Empty(t, result.ToolTurns) + assert.Equal(t, 1, inner.callCount) + }) + + t.Run("all deferred fail with non-deferred present stops the run", func(t *testing.T) { + normalTool := llm.Tool{ + Name: "normal_tool", + Description: "normal test tool", + Resolver: func(_ context.Context, _ *llm.Context, _ llm.ToolArgumentGetter) (string, error) { + return "should_not_run", nil + }, + } + inner := &testLLM{responses: []testResponse{{events: []llm.TextStreamEvent{ + {Type: llm.EventTypeToolCalls, Value: []llm.ToolCall{ + {ID: "ask1", Name: "AskAnotherUser", Arguments: json.RawMessage(`{}`)}, + {ID: "ask2", Name: "AskAnotherUser", Arguments: json.RawMessage(`{}`)}, + {ID: "tc1", Name: "normal_tool", Arguments: json.RawMessage(`{}`)}, + }}, + {Type: llm.EventTypeEnd}, + }}}} + recorder := &dispatchRecorder{err: fmt.Errorf("dispatch broken")} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser", normalTool)}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + + events := collectToolCallEvents(t, result) + require.Len(t, events, 3) + byID := map[string]llm.ToolCall{} + for _, e := range events { + byID[e.ID] = e + } + assert.Equal(t, llm.ToolCallStatusError, byID["ask1"].Status) + assert.Equal(t, llm.ToolCallStatusError, byID["ask2"].Status) + assert.Equal(t, llm.ToolCallStatusPending, byID["tc1"].Status) + + assert.Equal(t, 1, inner.callCount, "run must stop, not continue, when a non-deferred call is pending") + assert.Empty(t, result.ToolTurns) + }) +} + +func TestRunLoopDeferredDispatchFailureCountsTowardRetryLimit(t *testing.T) { + deferredRound := testResponse{events: []llm.TextStreamEvent{ + {Type: llm.EventTypeToolCalls, Value: []llm.ToolCall{ + {ID: "ask", Name: "AskAnotherUser", Arguments: json.RawMessage(`{}`)}, + }}, + {Type: llm.EventTypeEnd}, + }} + + responses := make([]testResponse, 0, llm.MaxConsecutiveToolCallFailures+1) + for range llm.MaxConsecutiveToolCallFailures { + responses = append(responses, deferredRound) + } + responses = append(responses, testResponse{events: []llm.TextStreamEvent{ + {Type: llm.EventTypeText, Value: "giving up"}, + {Type: llm.EventTypeEnd}, + }}) + + var capturedOpts [][]llm.LanguageModelOption + inner := &optCapturingLLM{ + inner: &testLLM{responses: responses}, + capturedOpts: &capturedOpts, + } + recorder := &dispatchRecorder{err: fmt.Errorf("dispatch always fails")} + runner := New(inner, WithDeferredDispatcher(recorder.dispatch)) + request := llm.CompletionRequest{ + Posts: []llm.Post{{Role: llm.PostRoleUser, Message: "go"}}, + Context: &llm.Context{Tools: newDeferredToolStore("AskAnotherUser")}, + } + + result, err := runner.Run(context.Background(), request, alwaysExecute, nil) + require.NoError(t, err) + _, readErr := result.Stream.ReadAll() + require.NoError(t, readErr) + + require.Len(t, result.ToolTurns, llm.MaxConsecutiveToolCallFailures) + require.Len(t, capturedOpts, llm.MaxConsecutiveToolCallFailures+1) + + // The call after the limit is reached must have tools disabled. + var finalCfg llm.LanguageModelConfig + for _, opt := range capturedOpts[llm.MaxConsecutiveToolCallFailures] { + opt(&finalCfg) + } + assert.True(t, finalCfg.ToolsDisabled, "tools must be disabled after consecutive dispatch failures") + + // The final request carries the retry-limit system message. + finalReq := inner.inner.capturedRequests[llm.MaxConsecutiveToolCallFailures] + found := false + for _, post := range finalReq.Posts { + if post.Role == llm.PostRoleSystem && strings.Contains(post.Message, llm.ToolRetryLimitSystemMessage) { + found = true + } + } + assert.True(t, found, "final request must include the retry-limit system message") +} diff --git a/webapp/src/client.test.ts b/webapp/src/client.test.ts index 68adfcc5f..28d722634 100644 --- a/webapp/src/client.test.ts +++ b/webapp/src/client.test.ts @@ -9,6 +9,8 @@ import type {ConversationResponse, Turn} from '@/types/conversation'; import manifest from './manifest'; import { + doAskUserCancel, + doAskUserResponse, doLoopInAgent, getConversation, getConversationContext, @@ -43,7 +45,19 @@ jest.mock('@mattermost/client', () => { return {...options, headers: {'X-Requested-With': 'XMLHttpRequest'}}; } }, - ClientError: class extends Error {}, + + // Mirrors the real ClientError just enough for callers that branch on + // status_code (e.g. the ask-user 409 handling). + ClientError: class extends Error { + status_code?: number; + url?: string; + + constructor(baseUrl: string, data: {message: string; status_code?: number; url?: string}) { + super(data.message); + this.status_code = data.status_code; + this.url = data.url; + } + }, mockSearchAllChannels, mockUpdateThreadReadForUser, }; @@ -70,6 +84,10 @@ function okResponse(): Response { return {ok: true, status: 200, json: () => Promise.resolve({})} as unknown as Response; } +function jsonResponse(body: unknown): Response { + return {ok: true, status: 200, json: () => Promise.resolve(body)} as unknown as Response; +} + // Mattermost IDs are 26 characters of lowercase letters and digits. const WELL_FORMED_ID = 'c7f2m9xq4v1b8n3k6t5w0hzjd2'; @@ -228,6 +246,84 @@ describe('doLoopInAgent', () => { }); }); +describe('doAskUserResponse', () => { + test('posts the exact answer body to the ask_user_response route with the card bot in the query', async () => { + mockFetch.mockResolvedValue(jsonResponse({status: 'answered'})); + + await expect(doAskUserResponse(WELL_FORMED_ID, 'agentbot', {action: 'answer', selected: ['A'], free_form: ''})). + resolves.toEqual({status: 'answered'}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe(`${siteURL}/plugins/${manifest.id}/post/${WELL_FORMED_ID}/ask_user_response?botUsername=agentbot`); + expect(options).toEqual(expect.objectContaining({ + method: 'POST', + body: JSON.stringify({action: 'answer', selected: ['A'], free_form: ''}), + })); + }); + + test('percent-encodes the bot username in the query string', async () => { + mockFetch.mockResolvedValue(jsonResponse({status: 'answered'})); + + await doAskUserResponse(WELL_FORMED_ID, 'agent bot&x=1', {action: 'answer', selected: ['A'], free_form: ''}); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe(`${siteURL}/plugins/${manifest.id}/post/${WELL_FORMED_ID}/ask_user_response?botUsername=agent%20bot%26x%3D1`); + }); + + test('serializes a decline action', async () => { + mockFetch.mockResolvedValue(jsonResponse({status: 'declined'})); + + await expect(doAskUserResponse(WELL_FORMED_ID, 'agentbot', {action: 'decline', selected: [], free_form: ''})). + resolves.toEqual({status: 'declined'}); + + const [, options] = mockFetch.mock.calls[0]; + expect(options).toEqual(expect.objectContaining({ + body: JSON.stringify({action: 'decline', selected: [], free_form: ''}), + })); + }); + + test('rejects with the response status code on a non-OK response', async () => { + mockFetch.mockResolvedValue({ok: false, status: 409} as unknown as Response); + + await expect(doAskUserResponse(WELL_FORMED_ID, 'agentbot', {action: 'answer', selected: ['A'], free_form: ''})). + rejects.toMatchObject({status_code: 409}); + }); +}); + +describe('doAskUserCancel', () => { + test('posts the tool_use_id body to the ask_user_cancel route with the bot in the query', async () => { + mockFetch.mockResolvedValue(jsonResponse({status: 'canceled'})); + + await expect(doAskUserCancel(WELL_FORMED_ID, 'agentbot', {tool_use_id: 'toolu_123'})). + resolves.toEqual({status: 'canceled'}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe(`${siteURL}/plugins/${manifest.id}/post/${WELL_FORMED_ID}/ask_user_cancel?botUsername=agentbot`); + expect(options).toEqual(expect.objectContaining({ + method: 'POST', + body: JSON.stringify({tool_use_id: 'toolu_123'}), + })); + }); + + test('percent-encodes the bot username in the query string', async () => { + mockFetch.mockResolvedValue(jsonResponse({status: 'canceled'})); + + await doAskUserCancel(WELL_FORMED_ID, 'agent bot&x=1', {tool_use_id: 'toolu_123'}); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toBe(`${siteURL}/plugins/${manifest.id}/post/${WELL_FORMED_ID}/ask_user_cancel?botUsername=agent%20bot%26x%3D1`); + }); + + test('rejects with the response status code on a non-OK response', async () => { + mockFetch.mockResolvedValue({ok: false, status: 409} as unknown as Response); + + await expect(doAskUserCancel(WELL_FORMED_ID, 'agentbot', {tool_use_id: 'toolu_123'})). + rejects.toMatchObject({status_code: 409}); + }); +}); + describe('getConversation', () => { test('requests the conversation route for a well-formed id', async () => { await expect(getConversation(WELL_FORMED_ID)).resolves.toEqual({turns: []}); diff --git a/webapp/src/client.tsx b/webapp/src/client.tsx index c705aafb1..b9f549a9f 100644 --- a/webapp/src/client.tsx +++ b/webapp/src/client.tsx @@ -246,6 +246,67 @@ export async function doToolCall(postid: string, toolIDs: string[], toolAnswers? }); } +export type AskUserResponseAction = 'answer' | 'decline'; +export type AskUserResponseStatus = 'answered' | 'declined' | 'canceled'; + +// Request body for the ask_user_response endpoint. Mirrors +// conversations.AskUserResponse on the server. +export interface AskUserResponseBody { + action: AskUserResponseAction; + selected: string[]; + free_form: string; +} + +// botUsername must be the card bot's username (the card post's author): the +// endpoint's middleware resolves the bot from this query param — falling back +// to the DEFAULT bot when absent — and runs usage-restriction checks against +// that bot. Omitting it would 403 targets who lack access to the default bot. +export async function doAskUserResponse(postid: string, botUsername: string, body: AskUserResponseBody): Promise<{status: AskUserResponseStatus}> { + const url = `${postRoute(postid)}/ask_user_response?botUsername=${encodeURIComponent(botUsername)}`; + const response = await fetch(url, Client4.getOptions({ + method: 'POST', + body: JSON.stringify(body), + })); + + if (response.ok) { + return response.json(); + } + + throw new ClientError(Client4.url, { + message: '', + status_code: response.status, + url, + }); +} + +// Request body for the ask_user_cancel endpoint. Mirrors the V2-C4 contract: +// tool_use_id is the provider-issued id of the waiting tool_use block. +export interface AskUserCancelBody { + tool_use_id: string; +} + +// Cancels an outstanding AskAnotherUser question from the initiator's anchor +// post. botUsername names the conversation bot (the anchor post's author) so +// the endpoint middleware runs its checks against that bot rather than the +// default one (same rationale as doAskUserResponse). +export async function doAskUserCancel(postid: string, botUsername: string, body: AskUserCancelBody): Promise<{status: string}> { + const url = `${postRoute(postid)}/ask_user_cancel?botUsername=${encodeURIComponent(botUsername)}`; + const response = await fetch(url, Client4.getOptions({ + method: 'POST', + body: JSON.stringify(body), + })); + + if (response.ok) { + return response.json(); + } + + throw new ClientError(Client4.url, { + message: '', + status_code: response.status, + url, + }); +} + export async function doToolResult(postid: string, toolIDs: string[]): Promise { const url = `${postRoute(postid)}/tool_result`; const response = await fetch(url, Client4.getOptions({ diff --git a/webapp/src/components/ask_user_post/ask_user_post.test.tsx b/webapp/src/components/ask_user_post/ask_user_post.test.tsx new file mode 100644 index 000000000..db8a7a657 --- /dev/null +++ b/webapp/src/components/ask_user_post/ask_user_post.test.tsx @@ -0,0 +1,817 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import {IntlProvider} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import {AskUserPost, buildAnswerPreview, parseAskUserProps} from './ask_user_post'; + +const mockDoAskUserResponse = jest.fn(); +const mockGetProfilesByIds = jest.fn(); + +jest.mock('@/client', () => ({ + doAskUserResponse: (...args: unknown[]) => mockDoAskUserResponse(...args), + getProfilesByIds: (...args: unknown[]) => mockGetProfilesByIds(...args), +})); + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), + useDispatch: jest.fn(), +})); + +// mm_webapp reads window.Components at module load (absent in jsdom). +jest.mock('@/mm_webapp', () => ({ + Timestamp: null, +})); + +const mockUseSelector = useSelector as unknown as jest.Mock; +const mockUseDispatch = useDispatch as unknown as jest.Mock; +const dispatchMock = jest.fn(); + +// Mattermost IDs are 26 characters of lowercase letters and digits. +const POST_ID = 'c7f2m9xq4v1b8n3k6t5w0hzjd2'; +const TARGET_ID = 'kq3n7vd1x9r4bz2m8sw6t5jhpc'; +const REQUESTER_ID = 'ehz9k3wqr7t1a5m2xd8pnb4jsc'; +const SOURCE_POST_ID = 'a1b2c3d4e5f6g7h8i9j0k1l2m3'; +const CONV_ID = 'n4o5p6q7r8s9t0u1v2w3x4y5z6'; +const CHANNEL_ID = 'z9y8x7w6v5u4t3s2r1q0p9o8n7'; +const BOT_ID = 'b0t1b0t2b0t3b0t4b0t5b0t6b0'; +const OTHER_USER_ID = 'o1t2h3e4r5u6s7e8r9i0d1x2y3'; + +const FALLBACK_MESSAGE = 'Which release broke it? (Open Mattermost in a browser to respond.)'; + +function makeProps(overrides: Record = {}): Record { + return { + ask_user_status: 'pending', + ask_user_question: 'Which release broke it?', + ask_user_context: 'Needed to finish the RCA', + ask_user_options: [{label: '4.2.0'}, {label: '4.2.1', description: 'the hotfix'}], + ask_user_multi_select: false, + ask_user_allow_free_form: true, + ask_user_requester_id: REQUESTER_ID, + ask_user_target_id: TARGET_ID, + ask_user_conversation_id: CONV_ID, + ask_user_tool_use_id: 'tooluse_1', + ask_user_source_post_id: SOURCE_POST_ID, + ...overrides, + }; +} + +function omit(props: Record, key: string): Record { + const out = {...props}; + delete out[key]; + return out; +} + +// v2 (V2-C2) prop set: a human requester asking from a DM. Tests override +// individual keys to walk the destination/attribution matrices. +const V2_BASE: Record = { + ask_user_requester_kind: 'user', + ask_user_requester_username: 'jane', + ask_user_requester_display_name: 'Jane Doe', + ask_user_requester_position: 'Engineering Lead', + ask_user_agent_display_name: 'Matty', + ask_user_destination_type: 'dm', + ask_user_destination_channel_display_name: '', + ask_user_destination_member_count: 0, + ask_user_destination_policy_enforced: false, +}; + +function makeV2Props(overrides: Record = {}): Record { + return makeProps({...V2_BASE, ...overrides}); +} + +type StateOverrides = { + currentUserId?: string; + profiles?: Record; +}; + +function stateFixture(overrides: StateOverrides = {}) { + return { + entities: { + users: { + currentUserId: overrides.currentUserId ?? TARGET_ID, + profiles: overrides.profiles ?? { + [REQUESTER_ID]: {id: REQUESTER_ID, username: 'jane'}, + [BOT_ID]: {id: BOT_ID, username: 'agentbot'}, + }, + }, + general: {config: {SiteURL: 'http://localhost:8065'}}, + }, + }; +} + +function buildPostElement(propOverrides: Record = {}) { + return ( + + + + ); +} + +function renderPost(propOverrides: Record = {}) { + return render(buildPostElement(propOverrides)); +} + +function renderV2Post(propOverrides: Record = {}) { + return renderPost({...V2_BASE, ...propOverrides}); +} + +beforeEach(() => { + mockDoAskUserResponse.mockReset(); + mockDoAskUserResponse.mockResolvedValue({status: 'answered'}); + mockGetProfilesByIds.mockReset(); + mockGetProfilesByIds.mockResolvedValue([]); + dispatchMock.mockReset(); + mockUseDispatch.mockReturnValue(dispatchMock); + mockUseSelector.mockImplementation((selector) => selector(stateFixture())); +}); + +describe('parseAskUserProps', () => { + test('parses well-formed pending props', () => { + expect(parseAskUserProps(makeProps())).toEqual({ + status: 'pending', + question: 'Which release broke it?', + context: 'Needed to finish the RCA', + options: [{label: '4.2.0'}, {label: '4.2.1', description: 'the hotfix'}], + multiSelect: false, + allowFreeForm: true, + requesterId: REQUESTER_ID, + targetId: TARGET_ID, + sourcePostId: SOURCE_POST_ID, + answeredAt: 0, + answerPreview: '', + requesterKind: '', + requesterUsername: '', + requesterDisplayName: '', + requesterPosition: '', + agentDisplayName: '', + destinationType: '', + destinationChannelName: '', + destinationMemberCount: 0, + destinationPolicyEnforced: false, + }); + }); + + test.each([ + ['invalid status', makeProps({ask_user_status: 'bogus'})], + ['missing status', omit(makeProps(), 'ask_user_status')], + ['empty question', makeProps({ask_user_question: ''})], + ['missing question', omit(makeProps(), 'ask_user_question')], + ['missing target id', omit(makeProps(), 'ask_user_target_id')], + ['non-string target id', makeProps({ask_user_target_id: 42})], + ['option without a label', makeProps({ask_user_options: [{description: 'no label'}]})], + ['option with an empty label', makeProps({ask_user_options: [{label: ''}]})], + ['non-array options', makeProps({ask_user_options: 'not an array'})], + ['non-object option', makeProps({ask_user_options: ['4.2.0']})], + ['duplicate option labels', makeProps({ask_user_options: [{label: 'A'}, {label: 'A', description: 'again'}]})], + ['no options and free-form disabled', makeProps({ask_user_options: [], ask_user_allow_free_form: false})], + ])('returns null for %s', (_label, props) => { + expect(parseAskUserProps(props)).toBeNull(); + }); + + test('applies defaults for absent optional props', () => { + let props = makeProps(); + props = omit(props, 'ask_user_context'); + props = omit(props, 'ask_user_requester_id'); + props = omit(props, 'ask_user_source_post_id'); + props = omit(props, 'ask_user_multi_select'); + props = omit(props, 'ask_user_allow_free_form'); + + expect(parseAskUserProps(props)).toMatchObject({ + context: '', + requesterId: '', + sourcePostId: '', + answeredAt: 0, + answerPreview: '', + allowFreeForm: true, + multiSelect: false, + }); + }); + + test('treats a non-boolean multi_select as false', () => { + expect(parseAskUserProps(makeProps({ask_user_multi_select: 'yes'}))?.multiSelect).toBe(false); + }); + + test('treats a non-numeric answered_at as absent', () => { + expect(parseAskUserProps(makeProps({ask_user_answered_at: 'noon'}))?.answeredAt).toBe(0); + }); + + test('parses the full v2 prop set', () => { + expect(parseAskUserProps(makeV2Props({ + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: 'Town Square', + ask_user_destination_member_count: 12, + ask_user_destination_policy_enforced: true, + }))).toMatchObject({ + requesterKind: 'user', + requesterUsername: 'jane', + requesterDisplayName: 'Jane Doe', + requesterPosition: 'Engineering Lead', + agentDisplayName: 'Matty', + destinationType: 'channel', + destinationChannelName: 'Town Square', + destinationMemberCount: 12, + destinationPolicyEnforced: true, + }); + }); + + test('defaults every v2 prop on a pre-v2 card', () => { + expect(parseAskUserProps(makeProps())).toMatchObject({ + requesterKind: '', + requesterUsername: '', + requesterDisplayName: '', + requesterPosition: '', + agentDisplayName: '', + destinationType: '', + destinationChannelName: '', + destinationMemberCount: 0, + destinationPolicyEnforced: false, + }); + }); + + // A malformed v2 prop must degrade to absent, never break the card. + test.each([ + ['numeric requester kind', {ask_user_requester_kind: 42}, {requesterKind: ''}], + ['unknown requester kind string', {ask_user_requester_kind: 'martian'}, {requesterKind: ''}], + ['unknown destination type', {ask_user_destination_type: 'broadcast'}, {destinationType: ''}], + ['member count as string', {ask_user_destination_member_count: '12'}, {destinationMemberCount: 0}], + ['negative member count', {ask_user_destination_member_count: -3}, {destinationMemberCount: 0}], + ['policy flag as string', {ask_user_destination_policy_enforced: 'yes'}, {destinationPolicyEnforced: false}], + ])('treats a malformed v2 prop as absent: %s', (_label, overrides, expected) => { + expect(parseAskUserProps(makeV2Props(overrides))).toMatchObject(expected); + }); + + test('accepts the canceled status', () => { + expect(parseAskUserProps(makeProps({ask_user_status: 'canceled'}))?.status).toBe('canceled'); + }); +}); + +// Pins the client preview against the server rule (askUserAnswerPreview in +// conversations/ask_another_user.go): labels joined with ', ', ' — ' before +// free-form, 200-rune truncation. +describe('buildAnswerPreview', () => { + test.each([ + ['labels only', ['A', 'B'], '', 'A, B'], + ['free-form only', [], 'hello', 'hello'], + ['labels and free-form joined with an em dash', ['A'], 'extra', 'A — extra'], + ['whitespace-only free-form dropped', ['A'], ' ', 'A'], + ['empty', [], '', ''], + ])('%s', (_label, selected, freeForm, want) => { + expect(buildAnswerPreview(selected as string[], freeForm as string)).toBe(want); + }); + + test('truncates to 200 runes, not UTF-16 code units', () => { + // Astral characters are two UTF-16 code units but one rune each. + const long = '😀'.repeat(300); + + expect(buildAnswerPreview([], long)).toBe('😀'.repeat(200)); + }); +}); + +describe('AskUserPost rendering', () => { + test('renders the full pending card for the target', () => { + renderPost(); + + expect(screen.getByText('Which release broke it?')).not.toBeNull(); + expect(screen.getByText('Needed to finish the RCA')).not.toBeNull(); + expect(screen.getByText('Asked on behalf of @jane')).not.toBeNull(); + expect(screen.getByText('4.2.0')).not.toBeNull(); + expect(screen.getByText('4.2.1')).not.toBeNull(); + expect(screen.getByText('Answer')).not.toBeNull(); + expect(screen.getByText('Decline')).not.toBeNull(); + + const link = screen.getByRole('link', {name: 'View conversation'}); + expect(link.getAttribute('href')).toBe(`http://localhost:8065/_redirect/pl/${SOURCE_POST_ID}`); + }); + + test('pre-v2 cards render the v1 layout without any v2 chrome', () => { + renderPost(); + + expect(screen.queryByText('AI-generated content')).toBeNull(); + expect(screen.queryByText(/Your answer (will|may) be shared/)).toBeNull(); + }); + + test('omits the attribution row when there is no requester', () => { + renderPost({ask_user_requester_id: ''}); + + expect(screen.queryByText(/Asked on behalf of/)).toBeNull(); + }); + + test('omits the permalink when there is no source post id', () => { + renderPost({ask_user_source_post_id: ''}); + + expect(screen.queryByRole('link', {name: 'View conversation'})).toBeNull(); + }); + + test('hydrates the requester and bot profiles when they are not cached', async () => { + mockUseSelector.mockImplementation((selector) => selector(stateFixture({profiles: {}}))); + mockGetProfilesByIds.mockResolvedValue([ + {id: REQUESTER_ID, username: 'jane'}, + {id: BOT_ID, username: 'agentbot'}, + ]); + + renderPost(); + + await waitFor(() => { + expect(mockGetProfilesByIds).toHaveBeenCalledWith([REQUESTER_ID, BOT_ID]); + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'RECEIVED_PROFILES', + data: { + [REQUESTER_ID]: expect.objectContaining({username: 'jane'}), + [BOT_ID]: expect.objectContaining({username: 'agentbot'}), + }, + }); + }); + }); + + test('renders the answered state from props without controls', () => { + renderPost({ + ask_user_status: 'answered', + ask_user_answer_preview: '4.2.1', + ask_user_answered_at: 1712345678901, + }); + + expect(screen.getByText('Answered')).not.toBeNull(); + expect(screen.getByText('4.2.1')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + expect(screen.queryByText('4.2.0')).toBeNull(); + }); + + test('renders the declined state from props without controls', () => { + renderPost({ask_user_status: 'declined'}); + + expect(screen.getByText('You declined to answer')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + }); + + test('renders the question without controls for a non-target viewer', () => { + mockUseSelector.mockImplementation((selector) => selector(stateFixture({currentUserId: OTHER_USER_ID}))); + + renderPost(); + + expect(screen.getByText('Which release broke it?')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + }); + + test('falls back to the post message for malformed props', () => { + renderPost({ask_user_status: 'bogus'}); + + expect(screen.getByText(FALLBACK_MESSAGE)).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + }); +}); + +describe('AskUserPost v2 destination disclosure', () => { + // The full V2-C2 rendering matrix, one row per destination/requester + // combination. + test.each([ + [ + 'dm from a human requester', + {}, + 'Your answer will be shared with @jane.', + ], + [ + 'dm from an unattended agent', + { + ask_user_requester_kind: 'bot', + ask_user_requester_username: '', + ask_user_requester_display_name: '', + ask_user_requester_position: '', + ask_user_requester_id: '', + }, + 'Your answer will be shared with the Matty agent.', + ], + [ + 'dm with an unknown requester', + { + ask_user_requester_kind: 'unknown', + ask_user_requester_username: '', + ask_user_requester_display_name: '', + ask_user_requester_position: '', + ask_user_requester_id: '', + }, + 'Your answer will be shared with the person who asked the agent.', + ], + [ + 'dm from a human requester whose username is missing degrades to the unknown copy', + { + ask_user_requester_kind: 'user', + ask_user_requester_username: '', + }, + 'Your answer will be shared with the person who asked the agent.', + ], + [ + 'channel with name and member count', + { + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: 'Town Square', + ask_user_destination_member_count: 12, + }, + 'Your answer may be shared with the 12 members of ~Town Square.', + ], + [ + 'channel with name only', + { + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: 'Town Square', + ask_user_destination_member_count: 0, + }, + 'Your answer may be shared with the members of ~Town Square.', + ], + [ + 'channel with nothing known', + { + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: '', + ask_user_destination_member_count: 0, + }, + 'Your answer may be shared in the channel where the agent was asked.', + ], + [ + 'group message with member count', + { + ask_user_destination_type: 'gm', + ask_user_destination_member_count: 3, + }, + 'Your answer may be shared with the 3 members of a group message.', + ], + [ + 'group message without member count', + { + ask_user_destination_type: 'gm', + ask_user_destination_member_count: 0, + }, + 'Your answer may be shared with the members of a group message.', + ], + ])('%s', (_label, overrides, expected) => { + renderV2Post(overrides as Record); + + expect(screen.getByText(expected as string)).not.toBeNull(); + }); +}); + +describe('AskUserPost v2 access context', () => { + test('renders the policy line only when the flag is true', () => { + const {unmount} = renderV2Post({ + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: 'Town Square', + ask_user_destination_member_count: 12, + ask_user_destination_policy_enforced: true, + }); + + expect(screen.getByText('Access to ~Town Square is restricted by an attribute-based access policy.')).not.toBeNull(); + unmount(); + + renderV2Post({ + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: 'Town Square', + ask_user_destination_member_count: 12, + ask_user_destination_policy_enforced: false, + }); + + expect(screen.queryByText(/attribute-based access policy/)).toBeNull(); + }); + + test('never renders a policy line without a channel name', () => { + renderV2Post({ + ask_user_destination_type: 'channel', + ask_user_destination_channel_display_name: '', + ask_user_destination_policy_enforced: true, + }); + + expect(screen.queryByText(/attribute-based access policy/)).toBeNull(); + }); + + test.each([ + ['display name and position', 'Jane Doe', 'Engineering Lead', 'Jane Doe · Engineering Lead'], + ['display name only', 'Jane Doe', '', 'Jane Doe'], + ['position only', '', 'Engineering Lead', 'Engineering Lead'], + ])('identity detail line with %s', (_label, displayName, position, expected) => { + renderV2Post({ + ask_user_requester_display_name: displayName, + ask_user_requester_position: position, + }); + + expect(screen.getByText(expected)).not.toBeNull(); + }); + + test('omits the identity line when both parts are empty', () => { + renderV2Post({ + ask_user_requester_display_name: '', + ask_user_requester_position: '', + }); + + expect(screen.queryByText(/ · /)).toBeNull(); + expect(screen.queryByText('Jane Doe')).toBeNull(); + }); +}); + +describe('AskUserPost v2 attribution', () => { + test('human requester attributes from props without a profile fetch', () => { + renderV2Post(); + + expect(screen.getByText('Asked on behalf of @jane')).not.toBeNull(); + + // The bot profile is cached in the fixture and the requester comes + // from props, so no hydration request is needed at all. + expect(mockGetProfilesByIds).not.toHaveBeenCalled(); + }); + + test('unattended agent requester renders the unattended copy', () => { + renderV2Post({ + ask_user_requester_kind: 'bot', + ask_user_requester_username: '', + ask_user_requester_display_name: '', + ask_user_requester_position: '', + ask_user_requester_id: '', + }); + + expect(screen.getByText('Asked by the Matty agent running unattended (no human requester)')).not.toBeNull(); + expect(screen.queryByText(/Asked on behalf of/)).toBeNull(); + }); + + test('unknown requester renders the unknown-requester copy', () => { + renderV2Post({ + ask_user_requester_kind: 'unknown', + ask_user_requester_username: '', + ask_user_requester_display_name: '', + ask_user_requester_position: '', + ask_user_requester_id: '', + }); + + expect(screen.getByText('Asked via the Matty agent (requester identity unavailable)')).not.toBeNull(); + }); +}); + +describe('AskUserPost v2 visual separation', () => { + test('model-authored content renders inside the AI region; system chrome outside', () => { + renderV2Post(); + + const caption = screen.getByText('AI-generated content'); + const aiRegion = caption.parentElement as HTMLElement; + + // Model-authored: question, context, option labels, free-form toggle. + expect(aiRegion.textContent).toContain('Which release broke it?'); + expect(aiRegion.textContent).toContain('Needed to finish the RCA'); + expect(aiRegion.textContent).toContain('4.2.0'); + expect(aiRegion.textContent).toContain('4.2.1'); + + // System-authored chrome must live outside the region so injected + // text cannot forge it: attribution, disclosure, and the buttons. + expect(aiRegion.textContent).not.toContain('Asked on behalf of'); + expect(aiRegion.textContent).not.toContain('Your answer will be shared'); + expect(aiRegion.textContent).not.toContain('Answer'); + expect(aiRegion.textContent).not.toContain('Decline'); + + // The chrome itself still renders — just elsewhere in the card. + expect(screen.getByText('Asked on behalf of @jane')).not.toBeNull(); + expect(screen.getByText('Your answer will be shared with @jane.')).not.toBeNull(); + expect(screen.getByText('Answer')).not.toBeNull(); + }); +}); + +describe('AskUserPost canceled state', () => { + test('canceled props render the neutral terminal line without controls', () => { + renderV2Post({ask_user_status: 'canceled'}); + + expect(screen.getByText('This question is no longer needed.')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + expect(screen.queryByText('Answered')).toBeNull(); + expect(screen.queryByText('4.2.0')).toBeNull(); + }); + + test('a pre-v2 canceled card also renders the terminal line', () => { + renderPost({ask_user_status: 'canceled'}); + + expect(screen.getByText('This question is no longer needed.')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + }); +}); + +describe('AskUserPost interaction', () => { + test('single-select answers with exactly the last clicked option', async () => { + renderPost(); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('4.2.1')); // replaces the prior choice + fireEvent.click(screen.getByText('Answer')); + + expect(mockDoAskUserResponse).toHaveBeenCalledTimes(1); + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'answer', + selected: ['4.2.1'], + free_form: '', + }); + expect(await screen.findByText('Answered')).not.toBeNull(); + }); + + test('multi-select accumulates and toggles options off', async () => { + renderPost({ask_user_multi_select: true}); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('4.2.1')); + fireEvent.click(screen.getByText('4.2.0')); // toggle the first back off + fireEvent.click(screen.getByText('Answer')); + + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'answer', + selected: ['4.2.1'], + free_form: '', + }); + expect(await screen.findByText('Answered')).not.toBeNull(); + }); + + test('multi-select with an option and free-form text submits both, previewed with an em dash', async () => { + renderPost({ask_user_multi_select: true}); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('Something else…')); + fireEvent.change(screen.getByPlaceholderText('Something else…'), {target: {value: 'x'}}); + fireEvent.click(screen.getByText('Answer')); + + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'answer', + selected: ['4.2.0'], + free_form: 'x', + }); + + // The local resolved snapshot previews with the server's em-dash rule. + expect(await screen.findByText('4.2.0 — x')).not.toBeNull(); + }); + + test('Answer is disabled until a selection exists', () => { + renderPost(); + + expect((screen.getByText('Answer').closest('button') as HTMLButtonElement).disabled).toBe(true); + }); + + test('free-form alongside options submits the typed text', async () => { + renderPost(); + + fireEvent.click(screen.getByText('Something else…')); + fireEvent.change(screen.getByPlaceholderText('Something else…'), {target: {value: 'It was a config change'}}); + fireEvent.click(screen.getByText('Answer')); + + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'answer', + selected: [], + free_form: 'It was a config change', + }); + expect(await screen.findByText('Answered')).not.toBeNull(); + }); + + test('free-form-only question renders a textarea and submits trimmed text', async () => { + renderPost({ask_user_options: []}); + + const answerButton = () => screen.getByText('Answer').closest('button') as HTMLButtonElement; + expect(answerButton().disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), {target: {value: ' '}}); + expect(answerButton().disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), {target: {value: ' the 4.2.1 hotfix '}}); + expect(answerButton().disabled).toBe(false); + fireEvent.click(answerButton()); + + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'answer', + selected: [], + free_form: 'the 4.2.1 hotfix', + }); + expect(await screen.findByText('Answered')).not.toBeNull(); + }); + + test('renders no free-form input when allow_free_form is false', () => { + renderPost({ask_user_allow_free_form: false}); + + expect(screen.queryByText('Something else…')).toBeNull(); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + test('decline submits without a selection', async () => { + mockDoAskUserResponse.mockResolvedValue({status: 'declined'}); + renderPost(); + + fireEvent.click(screen.getByText('Decline')); + + expect(mockDoAskUserResponse).toHaveBeenCalledWith(POST_ID, 'agentbot', { + action: 'decline', + selected: [], + free_form: '', + }); + expect(await screen.findByText('You declined to answer')).not.toBeNull(); + }); + + test('submission is disabled until the bot profile is available', async () => { + // Only the requester profile is cached; the card bot (the post + // author) is unknown, so the botUsername query param can't be built. + mockUseSelector.mockImplementation((selector) => selector(stateFixture({ + profiles: {[REQUESTER_ID]: {id: REQUESTER_ID, username: 'jane'}}, + }))); + renderPost(); + + fireEvent.click(screen.getByText('4.2.0')); + + expect((screen.getByText('Answer').closest('button') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByText('Decline').closest('button') as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByText('Answer')); + fireEvent.click(screen.getByText('Decline')); + expect(mockDoAskUserResponse).not.toHaveBeenCalled(); + + // The card fetches the missing bot profile so submission can unlock. + await waitFor(() => { + expect(mockGetProfilesByIds).toHaveBeenCalledWith([BOT_ID]); + }); + }); + + test('shows the submitting state and prevents a second submission', async () => { + mockDoAskUserResponse.mockImplementation(() => new Promise(() => { + // Never resolves — keeps the card in the submitting phase. + })); + renderPost(); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('Answer')); + + expect(await screen.findByText('Submitting…')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + + // Option rows are inert while submitting; nothing can re-trigger. + fireEvent.click(screen.getByText('4.2.1')); + expect(mockDoAskUserResponse).toHaveBeenCalledTimes(1); + }); + + test('409 conflict shows the neutral no-longer-needed state and resolves from patched props', async () => { + mockDoAskUserResponse.mockRejectedValue({status_code: 409}); + const {rerender} = renderPost(); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('Answer')); + + // The question was resolved by another answer/decline — a neutral + // line, never the red error copy (V2-C4). + expect(await screen.findByText('This question is no longer needed.')).not.toBeNull(); + expect(screen.queryByText('Failed to submit your response. Please try again.')).toBeNull(); + + // Conflict means someone else resolved the question; controls are dead. + expect(screen.queryByText('Answer')).toBeNull(); + + // The server patch arrives as new props via the post-edited event; + // props win over local state and the neutral line clears. + rerender(buildPostElement({ + ask_user_status: 'answered', + ask_user_answer_preview: '4.2.1', + ask_user_answered_at: 1712345678901, + })); + + expect(screen.getByText('Answered')).not.toBeNull(); + expect(screen.getByText('4.2.1')).not.toBeNull(); + expect(screen.queryByText('This question is no longer needed.')).toBeNull(); + }); + + test('a canceled success response stays neutral when canceled props arrive', async () => { + mockDoAskUserResponse.mockResolvedValue({status: 'canceled'}); + const {rerender} = renderPost({...V2_BASE}); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('Answer')); + + expect(await screen.findByText('This question is no longer needed.')).not.toBeNull(); + + // The canceled card patch arrives; props keep the same terminal + // canceled rendering (same copy, no controls). + rerender(buildPostElement({...V2_BASE, ask_user_status: 'canceled'})); + + expect(screen.getByText('This question is no longer needed.')).not.toBeNull(); + expect(screen.queryByText('Answer')).toBeNull(); + expect(screen.queryByText('4.2.0')).toBeNull(); + }); + + test('generic failure shows the retry error and keeps controls enabled', async () => { + mockDoAskUserResponse.mockRejectedValue({status_code: 500}); + renderPost(); + + fireEvent.click(screen.getByText('4.2.0')); + fireEvent.click(screen.getByText('Answer')); + + expect(await screen.findByText('Failed to submit your response. Please try again.')).not.toBeNull(); + expect((screen.getByText('Answer').closest('button') as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/webapp/src/components/ask_user_post/ask_user_post.tsx b/webapp/src/components/ask_user_post/ask_user_post.tsx new file mode 100644 index 000000000..78a60f113 --- /dev/null +++ b/webapp/src/components/ask_user_post/ask_user_post.tsx @@ -0,0 +1,977 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useMemo, useState} from 'react'; +import styled from 'styled-components'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import {AccountOutlineIcon, CheckIcon, EyeOutlineIcon, ShieldOutlineIcon} from '@mattermost/compass-icons/components'; + +import type {ClientError} from '@mattermost/client'; + +import {GlobalState} from '@mattermost/types/store'; + +import {doAskUserResponse, getProfilesByIds, type AskUserResponseAction} from '@/client'; +import {isValidId} from '@/utils/ids'; +import {Timestamp} from '@/mm_webapp'; + +import QuestionOptions, {FreeFormTextarea, QuestionOption, useOptionSelection} from '../question_options'; +import LoadingSpinner from '../assets/loading_spinner'; + +// Post type of the question card the bot DMs to the target user. Mirrors the +// server-side constant in conversations/ask_another_user.go (contract C4). +export const AskUserPostType = 'custom_llm_ask_user'; + +// Contract C4 prop keys. The Go side defines its own constants — the strings +// are the contract. ask_user_conversation_id and ask_user_tool_use_id also +// exist on the post but the webapp never reads them: the server resolves them +// from the post at answer time. +const PROP_STATUS = 'ask_user_status'; +const PROP_QUESTION = 'ask_user_question'; +const PROP_CONTEXT = 'ask_user_context'; +const PROP_OPTIONS = 'ask_user_options'; +const PROP_MULTI_SELECT = 'ask_user_multi_select'; +const PROP_ALLOW_FREE_FORM = 'ask_user_allow_free_form'; +const PROP_REQUESTER_ID = 'ask_user_requester_id'; +const PROP_TARGET_ID = 'ask_user_target_id'; +const PROP_SOURCE_POST_ID = 'ask_user_source_post_id'; +const PROP_ANSWERED_AT = 'ask_user_answered_at'; +const PROP_ANSWER_PREVIEW = 'ask_user_answer_preview'; + +// Contract V2-C2 prop keys — all optional on the read side. A card without +// ask_user_requester_kind is a pre-v2 card and renders the exact v1 layout. +const PROP_REQUESTER_KIND = 'ask_user_requester_kind'; +const PROP_REQUESTER_USERNAME = 'ask_user_requester_username'; +const PROP_REQUESTER_DISPLAY_NAME = 'ask_user_requester_display_name'; +const PROP_REQUESTER_POSITION = 'ask_user_requester_position'; +const PROP_AGENT_DISPLAY_NAME = 'ask_user_agent_display_name'; +const PROP_DESTINATION_TYPE = 'ask_user_destination_type'; +const PROP_DESTINATION_CHANNEL_NAME = 'ask_user_destination_channel_display_name'; +const PROP_DESTINATION_MEMBER_COUNT = 'ask_user_destination_member_count'; +const PROP_DESTINATION_POLICY_ENFORCED = 'ask_user_destination_policy_enforced'; + +export type AskUserStatus = 'pending' | 'answered' | 'declined' | 'canceled'; + +// '' = the prop is absent or malformed (pre-v2 card / unknown destination). +export type AskUserRequesterKind = 'user' | 'bot' | 'unknown' | ''; +export type AskUserDestinationType = 'dm' | 'gm' | 'channel' | ''; + +export interface AskUserCardData { + status: AskUserStatus; + question: string; + context: string; // '' when absent + options: QuestionOption[]; // [] = free-form only + multiSelect: boolean; + allowFreeForm: boolean; + requesterId: string; // '' = no attribution row + targetId: string; + sourcePostId: string; // '' = no permalink + answeredAt: number; // ms; 0 when absent + answerPreview: string; + + // v2 (V2-C2) — every field degrades to its zero value on pre-v2 cards. + requesterKind: AskUserRequesterKind; // '' = pre-v2 card, render v1 layout + requesterUsername: string; + requesterDisplayName: string; + requesterPosition: string; + agentDisplayName: string; + destinationType: AskUserDestinationType; + destinationChannelName: string; + destinationMemberCount: number; // <= 0 = unknown + destinationPolicyEnforced: boolean; +} + +function stringProp(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +// Returns null when the props are missing or malformed so the component can +// fall back to rendering post.message (the server-side plain-text fallback). +export function parseAskUserProps(props: Record | undefined): AskUserCardData | null { + if (!props) { + return null; + } + + const status = props[PROP_STATUS]; + if (status !== 'pending' && status !== 'answered' && status !== 'declined' && status !== 'canceled') { + return null; + } + + const question = props[PROP_QUESTION]; + if (typeof question !== 'string' || question === '') { + return null; + } + + // Without the target id the card cannot gate interactivity. + const targetId = props[PROP_TARGET_ID]; + if (typeof targetId !== 'string' || targetId === '') { + return null; + } + + const rawOptions = props[PROP_OPTIONS]; + const options: QuestionOption[] = []; + if (rawOptions != null) { + if (!Array.isArray(rawOptions)) { + return null; + } + const seenLabels = new Set(); + for (const opt of rawOptions) { + if (opt == null || typeof opt !== 'object' || Array.isArray(opt)) { + return null; + } + const optObj = opt as {[key: string]: unknown}; + if (typeof optObj.label !== 'string' || optObj.label === '') { + return null; + } + + // Labels key the option rows and the selection state; duplicates + // would double-toggle. The server validates uniqueness, but props + // are untrusted. + if (seenLabels.has(optObj.label)) { + return null; + } + seenLabels.add(optObj.label); + const option: QuestionOption = {label: optObj.label}; + if (typeof optObj.description === 'string') { + option.description = optObj.description; + } + options.push(option); + } + } + + // Mirror the server pointer semantics: an absent key means allowed, an + // explicit false disables. + const allowFreeForm = props[PROP_ALLOW_FREE_FORM] !== false; + if (options.length === 0 && !allowFreeForm) { + // Nothing to interact with; server validation makes this unreachable, + // but props are untrusted. + return null; + } + + const rawAnsweredAt = props[PROP_ANSWERED_AT]; + const answeredAt = typeof rawAnsweredAt === 'number' && Number.isFinite(rawAnsweredAt) && rawAnsweredAt > 0 ? rawAnsweredAt : 0; + + // v2 props (V2-C2). Every one is optional and a malformed value is treated + // as absent — a bad v2 prop must never break an otherwise valid card. + const rawKind = props[PROP_REQUESTER_KIND]; + const requesterKind: AskUserRequesterKind = + rawKind === 'user' || rawKind === 'bot' || rawKind === 'unknown' ? rawKind : ''; + + const rawDestinationType = props[PROP_DESTINATION_TYPE]; + const destinationType: AskUserDestinationType = + rawDestinationType === 'dm' || rawDestinationType === 'gm' || rawDestinationType === 'channel' ? rawDestinationType : ''; + + const rawMemberCount = props[PROP_DESTINATION_MEMBER_COUNT]; + const destinationMemberCount = + typeof rawMemberCount === 'number' && Number.isFinite(rawMemberCount) && rawMemberCount > 0 ? rawMemberCount : 0; + + return { + status, + question, + context: stringProp(props[PROP_CONTEXT]), + options, + multiSelect: props[PROP_MULTI_SELECT] === true, + allowFreeForm, + requesterId: stringProp(props[PROP_REQUESTER_ID]), + targetId, + sourcePostId: stringProp(props[PROP_SOURCE_POST_ID]), + answeredAt, + answerPreview: stringProp(props[PROP_ANSWER_PREVIEW]), + requesterKind, + requesterUsername: stringProp(props[PROP_REQUESTER_USERNAME]), + requesterDisplayName: stringProp(props[PROP_REQUESTER_DISPLAY_NAME]), + requesterPosition: stringProp(props[PROP_REQUESTER_POSITION]), + agentDisplayName: stringProp(props[PROP_AGENT_DISPLAY_NAME]), + destinationType, + destinationChannelName: stringProp(props[PROP_DESTINATION_CHANNEL_NAME]), + destinationMemberCount, + destinationPolicyEnforced: props[PROP_DESTINATION_POLICY_ENFORCED] === true, + }; +} + +// Mirrors the server's answer-preview rule (askUserAnswerPreview in +// conversations/ask_another_user.go) so the local resolved snapshot matches +// what the patched props will show: selected labels joined with ', ', then +// ' — ' and the free-form text when both are present, truncated to 200 runes. +// Exported for tests. +export function buildAnswerPreview(selected: string[], freeForm: string): string { + let preview = selected.join(', '); + const trimmedFreeForm = freeForm.trim(); + if (trimmedFreeForm !== '') { + preview = preview === '' ? trimmedFreeForm : `${preview} — ${trimmedFreeForm}`; + } + + // Rune-safe truncation: Array.from splits by code points, matching Go's + // []rune semantics for astral characters. + return Array.from(preview).slice(0, 200).join(''); +} + +const Card = styled.div` + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 16px 16px 12px; + margin: 8px 0 12px; + overflow: hidden; + background: var(--center-channel-bg); + border: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + border-radius: 4px; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 3px; + background: var(--button-bg); + } +`; + +const QuestionText = styled.div` + padding-left: 12px; + font-size: 14px; + font-weight: 600; + line-height: 20px; + color: var(--center-channel-color); + word-break: break-word; +`; + +const ContextLine = styled.div` + padding-left: 12px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); + word-break: break-word; +`; + +const AttributionRow = styled.div` + padding-left: 12px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.64); +`; + +// F4a (V2-C7): visually contained region for MODEL-authored text (question, +// context, option labels/descriptions, free-form input). System chrome must +// never render inside it, so injected text cannot forge system lines. +const AIContentSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + margin-left: 12px; + padding: 12px; + background: rgba(var(--center-channel-color-rgb), 0.04); + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; +`; + +const AIContentLabel = styled.div` + font-size: 10px; + font-weight: 600; + line-height: 16px; + text-transform: uppercase; + letter-spacing: 0.02em; + color: rgba(var(--center-channel-color-rgb), 0.56); +`; + +// Inside the AI region the section supplies the left inset, so the v1 12px +// question/context padding is dropped. +const AIQuestionText = styled(QuestionText)` + padding-left: 0; +`; + +const AIContextLine = styled(ContextLine)` + padding-left: 0; +`; + +// SYSTEM-authored context (destination disclosure, requester identity, +// access-policy note) below the model region and above the footer buttons. +const SystemContextSection = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 12px; + padding: 8px 0 0; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); +`; + +const SystemContextLine = styled.div` + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12px; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); +`; + +const SystemContextIcon = styled.span` + display: flex; + align-items: center; + flex-shrink: 0; + height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.56); +`; + +// Icon-less detail lines (requester identity) indent by the icon + gap width +// (14px + 6px) so their text column aligns with the icon-prefixed lines. +const SystemContextDetailLine = styled(SystemContextLine)` + padding-left: 20px; +`; + +// v2 attribution line above the model region; same line treatment as the +// system context block but with the card's 12px left inset. +const AttributionLine = styled(SystemContextLine)` + margin-left: 12px; +`; + +const ErrorLine = styled.div` + color: var(--error-text); + font-size: 12px; + padding-left: 12px; +`; + +const FreeFormOnlyRow = styled.div` + display: flex; +`; + +const Footer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding-left: 12px; + padding-top: 4px; +`; + +const SelectedCount = styled.div` + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.75); +`; + +const FooterButtons = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +`; + +const FooterButton = styled.button<{$primary: boolean}>` + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + line-height: 16px; + cursor: pointer; + background: ${(props) => (props.$primary ? 'var(--button-bg)' : 'rgba(var(--button-bg-rgb), 0.08)')}; + color: ${(props) => (props.$primary ? 'var(--button-color)' : 'var(--button-bg)')}; + + &:hover:not(:disabled) { + background: ${(props) => (props.$primary ? 'rgba(var(--button-bg-rgb), 0.88)' : 'rgba(var(--button-bg-rgb), 0.12)')}; + } + + &:disabled { + cursor: default; + opacity: 0.5; + } +`; + +const StatusLine = styled.div` + display: flex; + align-items: center; + gap: 8px; + padding-left: 12px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.75); +`; + +const AnsweredIcon = styled(CheckIcon)` + color: var(--online-indicator); +`; + +const DeclinedText = styled.div` + padding-left: 12px; + font-size: 13px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.64); +`; + +const AnswerPreview = styled.div` + padding-left: 12px; + font-size: 13px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); + word-break: break-word; +`; + +const ViewConversationLink = styled.a` + padding-left: 12px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: var(--link-color); + width: fit-content; + + &:hover { + text-decoration: underline; + } +`; + +const FallbackMessage = styled.div` + color: rgba(var(--center-channel-color-rgb), 0.64); +`; + +const ProcessingSpinner = styled(LoadingSpinner)` + width: 12px; + height: 12px; +`; + +type SubmitState = + | {phase: 'idle'} + | {phase: 'submitting'; action: AskUserResponseAction} + | {phase: 'resolved'; resolution: 'answered' | 'declined' | 'canceled'; preview: string; at: number} + | {phase: 'error'; conflict: boolean}; + +interface AskUserPostProps { + post: { + id: string; + message: string; + channel_id: string; + user_id: string; + props?: Record; + }; +} + +export const AskUserPost: React.FC = ({post}) => { + const data = useMemo(() => parseAskUserProps(post.props), [post.props]); + + const {formatMessage} = useIntl(); + const currentUserId = useSelector((state) => state.entities.users.currentUserId); + const siteURL = useSelector((state) => state.entities.general.config.SiteURL); + + // Pre-v2 cards attribute via the requester id + a client profile fetch. + // v2 cards (requesterKind set) carry the username in props and never + // depend on the profile store for attribution. + const legacyRequesterId = (data?.requesterKind ?? '') === '' ? (data?.requesterId ?? '') : ''; + const legacyRequesterUsername = useSelector( + (state) => state.entities.users.profiles[legacyRequesterId]?.username, + ); + + // The card post's author is the bot. Its username must accompany every + // answer/decline request (see doAskUserResponse); submission stays + // disabled until the profile resolves. + const botUserId = post.user_id; + const botUsername = useSelector( + (state) => state.entities.users.profiles[botUserId]?.username, + ); + const dispatch = useDispatch(); + + useEffect(() => { + const missing: string[] = []; + if (legacyRequesterId && !legacyRequesterUsername) { + missing.push(legacyRequesterId); + } + if (botUserId && !botUsername) { + missing.push(botUserId); + } + if (missing.length === 0) { + return; + } + getProfilesByIds(missing).then((profiles) => { + const profilesById = profiles.reduce>((acc, p) => { + acc[p.id] = p; + return acc; + }, {}); + dispatch({type: 'RECEIVED_PROFILES', data: profilesById}); + }).catch(() => { + // Best-effort: the attribution row stays hidden and submission + // stays disabled until the profiles land in redux some other way + // (e.g. another consumer fetches them) or the card remounts and + // the effect runs again. + }); + }, [legacyRequesterId, legacyRequesterUsername, botUserId, botUsername, dispatch]); + + const selection = useOptionSelection(data?.multiSelect ?? false); + + // Free-form-only questions (no options) use a standalone textarea instead + // of the shared option selector. The placeholder doubles as the accessible + // name since the field has no visible label. + const [freeFormOnlyText, setFreeFormOnlyText] = useState(''); + const freeFormOnlyPlaceholder = formatMessage({ + id: 'ai.ask_user.free_form_placeholder', + defaultMessage: 'Type your answer…', + }); + const [submitState, setSubmitState] = useState({phase: 'idle'}); + + if (!data) { + return {post.message}; + } + + const canRespond = data.status === 'pending' && currentUserId === data.targetId; + const interactive = canRespond && (submitState.phase === 'idle' || (submitState.phase === 'error' && !submitState.conflict)); + + const submit = async (action: AskUserResponseAction, selected: string[], freeForm: string) => { + if (!interactive || !botUsername) { + return; + } + setSubmitState({phase: 'submitting', action}); + try { + const result = await doAskUserResponse(post.id, botUsername, {action, selected, free_form: freeForm}); + setSubmitState({ + phase: 'resolved', + resolution: result.status, + preview: result.status === 'answered' ? buildAnswerPreview(selected, freeForm) : '', + at: result.status === 'canceled' ? 0 : Date.now(), + }); + } catch (err) { + const conflict = (err as ClientError).status_code === 409; + setSubmitState({phase: 'error', conflict}); + } + }; + + const hasOptions = data.options.length > 0; + + // Answer requires at least one valid choice. When free-form is selected + // its text must be non-empty; otherwise a predefined option must be + // selected. Free-form-only questions just need non-blank text. + let canSubmit: boolean; + if (!hasOptions) { + canSubmit = freeFormOnlyText.trim() !== ''; + } else if (selection.freeFormSelected) { + canSubmit = selection.customAnswered || selection.selections.length > 0; + } else { + canSubmit = selection.selections.length > 0; + } + + const onAnswer = () => { + if (hasOptions) { + submit('answer', selection.selections, selection.customAnswered ? selection.trimmedCustom : ''); + } else { + submit('answer', [], freeFormOnlyText.trim()); + } + }; + + const onDecline = () => { + submit('decline', [], ''); + }; + + const guardedToggleOption = (label: string) => { + if (!interactive) { + return; + } + selection.toggleOption(label); + }; + + const guardedToggleFreeForm = () => { + if (!interactive) { + return; + } + selection.toggleFreeForm(); + }; + + // Props always win over the local snapshot: the server patches the card + // post on answer/decline/cancel and the post-edit event delivers new + // props. The local snapshot only bridges the gap until that event lands. + let resolution: 'answered' | 'declined' | 'canceled' | null = null; + let preview = ''; + let resolvedAt = 0; + if (data.status === 'answered') { + resolution = 'answered'; + preview = data.answerPreview; + resolvedAt = data.answeredAt; + } else if (data.status === 'declined') { + resolution = 'declined'; + } else if (data.status === 'canceled') { + resolution = 'canceled'; + } else if (submitState.phase === 'resolved') { + resolution = submitState.resolution; + preview = submitState.preview; + resolvedAt = submitState.at; + } + + const renderResolved = () => { + if (resolution === 'declined') { + return ( + + + + ); + } + if (resolution === 'canceled') { + // Neutral terminal state (V2-C6): no preview, no timestamp. + return ( + + + + ); + } + return ( + <> + + + + {resolvedAt > 0 && Timestamp && ( + + )} + + {preview !== '' && {preview}} + + ); + }; + + // MODEL-authored answer inputs (option labels/descriptions, free-form + // field). In the v2 layout these render inside the AI content region. + const renderAnswerInputs = () => ( + hasOptions ? ( + + ) : ( + canRespond && ( + + setFreeFormOnlyText(e.target.value)} + /> + + ) + ) + ); + + // SYSTEM-authored pending chrome: submit status/error lines + footer + // buttons. In the v2 layout these render outside the AI content region. + const renderPendingChrome = () => ( + <> + {submitState.phase === 'error' && !submitState.conflict && ( + + + + )} + {submitState.phase === 'error' && submitState.conflict && ( + + // A 409 means another answer or decline resolved the question. + // Neutral line, not an error; patched props settle the final + // rendering (V2-C4). + + + + )} + {submitState.phase === 'submitting' && ( + + + + + )} + {interactive && ( +
+ {data.multiSelect && ( + + + + )} + + + + + + + + +
+ )} + + ); + + const renderPending = () => ( + <> + {renderAnswerInputs()} + {renderPendingChrome()} + + ); + + const permalink = isValidId(data.sourcePostId) && Boolean(siteURL) && ( + + + + ); + + // Pre-v2 card: render the exact v1 layout (V2-C2 backward-compat rule). + if (data.requesterKind === '') { + return ( + + {data.question} + {data.context !== '' && {data.context}} + {legacyRequesterId !== '' && legacyRequesterUsername && ( + + + + )} + {resolution === null ? renderPending() : renderResolved()} + {permalink} + + ); + } + + // F4c attribution — always from props, never from model text. An empty + // name value (malformed props) omits the row rather than rendering a + // broken sentence. + const renderAttribution = () => { + if (data.requesterKind === 'user' && data.requesterUsername !== '') { + return ( + + + + + + + ); + } + if (data.requesterKind === 'bot' && data.agentDisplayName !== '') { + return ( + + + + + + + ); + } + if (data.requesterKind === 'unknown' && data.agentDisplayName !== '') { + return ( + + + + + + + ); + } + return null; + }; + + // Destination disclosure — the full V2-C2 rendering matrix. Every lookup + // failure at dispatch degraded toward the broader audience claim, so the + // fallbacks here go in the same direction. + const renderDestinationMessage = () => { + if (data.destinationType === 'dm') { + if (data.requesterKind === 'user' && data.requesterUsername !== '') { + return ( + + ); + } + if (data.requesterKind === 'bot' && data.agentDisplayName !== '') { + return ( + + ); + } + return ( + + ); + } + if (data.destinationType === 'channel') { + if (data.destinationChannelName !== '' && data.destinationMemberCount > 0) { + return ( + + ); + } + if (data.destinationChannelName !== '') { + return ( + + ); + } + return ( + + ); + } + if (data.destinationType === 'gm') { + if (data.destinationMemberCount > 0) { + return ( + + ); + } + return ( + + ); + } + return null; + }; + + const destinationMessage = renderDestinationMessage(); + + // Identity detail (kind=user only): non-empty parts joined with ' · ' in + // code — pure formatting, no i18n message (V2-C2). + const identityDetail = data.requesterKind === 'user' ? + [data.requesterDisplayName, data.requesterPosition].filter((part) => part !== '').join(' · ') : + ''; + + // The policy flag can only come from the same channel read that produced + // the display name; anything unavailable means the line is omitted — + // never render an empty or "no restrictions" statement (V2-C2). + const showPolicyLine = data.destinationType === 'channel' && + data.destinationPolicyEnforced && + data.destinationChannelName !== ''; + + const hasSystemContext = destinationMessage !== null || identityDetail !== '' || showPolicyLine; + + return ( + + {renderAttribution()} + + + + + {data.question} + {data.context !== '' && {data.context}} + {resolution === null && renderAnswerInputs()} + + {hasSystemContext && ( + + {destinationMessage !== null && ( + + + {destinationMessage} + + )} + {identityDetail !== '' && ( + {identityDetail} + )} + {showPolicyLine && ( + + + + + + + )} + + )} + {resolution === null ? renderPendingChrome() : renderResolved()} + {permalink} + + ); +}; diff --git a/webapp/src/components/llmbot_post/turn_content_utils.test.ts b/webapp/src/components/llmbot_post/turn_content_utils.test.ts index b421386c2..79c4bcb8e 100644 --- a/webapp/src/components/llmbot_post/turn_content_utils.test.ts +++ b/webapp/src/components/llmbot_post/turn_content_utils.test.ts @@ -62,6 +62,7 @@ describe('statusStringToEnum', () => { ['error', ToolCallStatus.Error], ['success', ToolCallStatus.Success], ['auto_approved', ToolCallStatus.AutoApproved], + ['waiting', ToolCallStatus.Waiting], ] as const)('maps %s to %i', (input, expected) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(statusStringToEnum(input as any)).toBe(expected); @@ -154,6 +155,22 @@ describe('extractToolCallsForPost', () => { expect(result[2].decided).toBe(true); }); + test('maps a waiting deferred-result block onto ToolCall', () => { + const assistantTurn = makeTurn({ + post_id: 'post_1', + sequence: 1, + content: [ + {type: 'tool_use', id: 'tc_w', name: 'AskAnotherUser', input: {username: 'bob'}, status: 'waiting', deferred_result: true}, + ], + }); + const conv = makeConversation([assistantTurn]); + const result = extractToolCallsForPost(conv, 'post_1'); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe(ToolCallStatus.Waiting); + expect(result[0].deferred_result).toBe(true); + }); + test('handles tool_use with null input (redacted)', () => { const assistantTurn = makeTurn({ post_id: 'post_1', diff --git a/webapp/src/components/llmbot_post/turn_content_utils.ts b/webapp/src/components/llmbot_post/turn_content_utils.ts index c5bf0138f..7a301b468 100644 --- a/webapp/src/components/llmbot_post/turn_content_utils.ts +++ b/webapp/src/components/llmbot_post/turn_content_utils.ts @@ -14,6 +14,7 @@ import { StatusError, StatusSuccess, StatusAutoApproved, + StatusWaiting, type ConversationResponse, type ContentBlock, type ServerToolUse, @@ -39,6 +40,8 @@ export function statusStringToEnum(status: ConvToolCallStatus | undefined): Tool return ToolCallStatus.Success; case StatusAutoApproved: return ToolCallStatus.AutoApproved; + case StatusWaiting: + return ToolCallStatus.Waiting; default: return ToolCallStatus.Pending; } @@ -106,6 +109,7 @@ function toolUseBlockToToolCall(block: ContentBlock, resultMap: Map` - position: relative; - display: flex; - align-items: center; - gap: 8px; - min-height: 44px; - padding: 12px; - border: none; - text-align: left; - border-radius: 4px; - background: ${(props) => (props.$selected ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent')}; - cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; - - &:hover { - background: ${(props) => { - if (props.$disabled) { - return props.$selected ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent'; - } - return props.$selected ? 'rgba(var(--button-bg-rgb), 0.12)' : 'rgba(var(--center-channel-color-rgb), 0.04)'; - }}; - } - - &:not(:last-child)::after { - content: ''; - position: absolute; - left: 12px; - right: 12px; - bottom: 0; - height: 1px; - background: ${(props) => (props.$selected ? 'transparent' : 'rgba(var(--center-channel-color-rgb), 0.08)')}; - } -`; - -const Checkbox = styled.span<{$checked: boolean}>` - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - margin: 0 2px; - border-radius: 3px; - border: ${(props) => (props.$checked ? 'none' : '1px solid rgba(var(--center-channel-color-rgb), 0.24)')}; - background: ${(props) => (props.$checked ? 'var(--button-bg)' : 'var(--center-channel-bg)')}; - color: var(--button-color); -`; - -const NumberBadge = styled.span<{$selected: boolean}>` - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 24px; - height: 24px; - border-radius: 4px; - font-size: 14px; - font-weight: 600; - line-height: 20px; - background: ${(props) => (props.$selected ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.08)')}; - color: ${(props) => (props.$selected ? 'var(--button-color)' : 'rgba(var(--center-channel-color-rgb), 0.75)')}; -`; - -const OptionText = styled.span` - display: flex; - flex-direction: column; - min-width: 0; -`; - -const OptionLabel = styled.span` - font-size: 14px; - font-weight: 400; - line-height: 20px; - color: var(--center-channel-color); - word-break: break-word; -`; - -const OptionDescription = styled.span` - font-size: 12px; - font-weight: 400; - line-height: 16px; - color: rgba(var(--center-channel-color-rgb), 0.64); - word-break: break-word; -`; - -// FreeFormRow mirrors a selected OptionRow but is a div so it can hold the -// inline text input (an input cannot be nested inside the button OptionRow). -const FreeFormRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - min-height: 44px; - padding: 12px; - border-radius: 4px; - background: rgba(var(--button-bg-rgb), 0.08); -`; - -const FreeFormToggle = styled.button<{$disabled: boolean}>` - display: flex; - align-items: center; - flex-shrink: 0; - padding: 0; - border: none; - background: none; - cursor: ${(props) => (props.$disabled ? 'default' : 'pointer')}; -`; - -const FreeFormInput = styled.input` - flex: 1; - min-width: 0; - padding: 6px 10px; - font-size: 14px; - line-height: 20px; - color: var(--center-channel-color); - background: var(--center-channel-bg); - border: 1px solid rgba(var(--center-channel-color-rgb), 0.24); - border-radius: 4px; - - &:focus { - outline: none; - border-color: var(--button-bg); - } - - &::placeholder { - color: rgba(var(--center-channel-color-rgb), 0.42); - } -`; - const Footer = styled.div` display: flex; align-items: center; @@ -335,13 +202,7 @@ const QuestionCard: React.FC = ({ onAnswer, onSkip, }) => { - const {formatMessage} = useIntl(); - const [selections, setSelections] = useState([]); - - // Whether the free-form "Something else…" row is selected, plus the text - // typed into it. The row behaves like any other option for select rules. - const [freeFormSelected, setFreeFormSelected] = useState(false); - const [customText, setCustomText] = useState(''); + const selection = useOptionSelection(question.multiSelect); const isPending = tool.status === ToolCallStatus.Pending || tool.status === ToolCallStatus.Accepted; const isAnswered = tool.status === ToolCallStatus.Success; @@ -350,46 +211,28 @@ const QuestionCard: React.FC = ({ const interactive = isPending && canAnswer && !isProcessing && !hasLocalDecision && Boolean(onAnswer && onSkip); const answered = useMemo(() => parseAnswerFromResult(tool.result), [tool.result]); - const shownSelections = isAnswered ? answered.selected : selections; - const shownFreeFormSelected = isAnswered ? answered.custom !== '' : freeFormSelected; - const shownCustomText = isAnswered ? answered.custom : customText; + const shownSelections = isAnswered ? answered.selected : selection.selections; + const shownFreeFormSelected = isAnswered ? answered.custom !== '' : selection.freeFormSelected; + const shownCustomText = isAnswered ? answered.custom : selection.customText; - const toggleOption = (label: string) => { + const handleToggleOption = (label: string) => { if (!interactive) { return; } - if (question.multiSelect) { - setSelections((prev) => ( - prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label] - )); - } else { - // Single-select: a predefined choice replaces any other choice, - // including the free-form row. - setSelections([label]); - setFreeFormSelected(false); - } + selection.toggleOption(label); }; - const toggleFreeForm = () => { + const handleToggleFreeForm = () => { if (!interactive) { return; } - if (question.multiSelect) { - setFreeFormSelected((prev) => !prev); - } else { - // Single-select: choosing free-form replaces any predefined choice. - setFreeFormSelected(true); - setSelections([]); - } + selection.toggleFreeForm(); }; - const trimmedCustom = customText.trim(); - const customAnswered = freeFormSelected && trimmedCustom !== ''; - // Accept requires at least one valid choice. When free-form is selected its // text must be non-empty; otherwise a predefined option must be selected. - const canSubmit = freeFormSelected ? (customAnswered || selections.length > 0) : selections.length > 0; - const selectedCount = selections.length + (customAnswered ? 1 : 0); + const canSubmit = selection.freeFormSelected ? (selection.customAnswered || selection.selections.length > 0) : selection.selections.length > 0; + const selectedCount = selection.selectedCount; const renderStatus = () => { if (isProcessing || (hasLocalDecision && isPending)) { @@ -441,84 +284,18 @@ const QuestionCard: React.FC = ({ return ( {question.question} - - {question.options.map((opt, idx) => { - const selected = shownSelections.includes(opt.label); - return ( - toggleOption(opt.label)} - > - {question.multiSelect ? ( - - {selected && } - - ) : ( - {idx + 1} - )} - - {opt.label} - {opt.description && {opt.description}} - - - ); - })} - {question.allowFreeForm && (interactive || shownFreeFormSelected) && ( - - // Selected: the "Something else…" label becomes the - // placeholder of an inline single-line input. - shownFreeFormSelected ? ( - - - {question.multiSelect ? ( - - - - ) : ( - {question.options.length + 1} - )} - - setCustomText(e.target.value)} - /> - - ) : ( - - {question.multiSelect ? ( - - ) : ( - {question.options.length + 1} - )} - - - - - - - ) - )} - + {interactive && (