Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3da5372
Phase 1 server: carry tool Title/Description on both paths, drop Tool…
cursoragent Jul 13, 2026
11073fe
Phase 1 webapp: plumb tool title/mcp_bare_name/server_origin; add too…
cursoragent Jul 13, 2026
6e1ecd3
Phase 1 tests: drift-guard parity, GetTools title/annotations, tool_i…
cursoragent Jul 13, 2026
0b74618
Phase 1: fix no-undefined lint in tool_identity.test.ts
cursoragent Jul 13, 2026
822945b
Rich tool call rendering (2/3): generic arguments field list + view raw
cursoragent Jul 13, 2026
ff8b2c1
Phase 3: extract ToolCardShell and host View raw on the shell
cursoragent Jul 13, 2026
8501826
Phase 3: renderer registry + rich cards + entity chips
cursoragent Jul 13, 2026
5d51cc1
Phase 3 tests: parsers, entity chips, registry routing, view-raw inhe…
cursoragent Jul 13, 2026
e122ca4
Phase 3 e2e: rich create_post card spec (mock LLM) + shard assignment
cursoragent Jul 13, 2026
6934cbc
Phase 1: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
b5bef92
Phase 2: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
c8eaf4a
Phase 3: restore webapp/package-lock.json to master (no dependency ch…
cursoragent Jul 13, 2026
765acff
Phase 1: table-driven parity test for the toolUseBlocks writer
cursoragent Jul 13, 2026
8b2b906
Phase 1: table-driven parity test for the toolUseBlocks writer (stacked)
cursoragent Jul 13, 2026
ac3412d
Phase 1: table-driven parity test for the toolUseBlocks writer (stacked)
cursoragent Jul 13, 2026
e2d3078
Merge branch 'cursor/rich-tool-call-rendering-f0d9' into cursor/rich-…
cursoragent Jul 13, 2026
6279537
Merge branch 'cursor/rich-tool-call-rendering-phase-2-f0d9' into curs…
cursoragent Jul 13, 2026
62ca6c1
Trim verbose comments; remove phase references
cursoragent Jul 20, 2026
95b8992
Simplify from review: single-parse registry, drop dead code
cursoragent Jul 20, 2026
c341dc8
Address PR review feedback
cursoragent Jul 21, 2026
49734fa
Restyle tool cards to match the card designs
cursoragent Jul 21, 2026
b8dad58
e2e: disambiguate channel-name assertions on the rich create_post card
cursoragent Jul 21, 2026
7961ce8
Align card styling with Mattermost conventions
cursoragent Jul 21, 2026
a61444f
ci: retrigger after unrelated container-startup flake in e2e-shard-2
cursoragent Jul 21, 2026
be53be4
Generic response rendering, post preview card, prune future-use code
cursoragent Jul 22, 2026
901608a
e2e: allow seeded content to match both the post preview and the resp…
cursoragent Jul 22, 2026
dbf98a3
Scope the read_post preview to undecided calls
cursoragent Jul 22, 2026
6fb783c
create_post: preview the post-to-be before approval
cursoragent Jul 22, 2026
9d0fbc9
Make the create_post preview non-interactive
cursoragent Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions conversation/content_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ type ContentBlock struct {
Status string `json:"status,omitempty"`
Shared *bool `json:"shared,omitempty"` // pointer to distinguish unset from false

// Title and Description mirror llm.ToolCall so a reloaded conversation
// renders the same tool identity the live websocket event showed. Both
// are visible to non-requesters like Name. Description is not rendered
// anywhere yet.
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`

// UserInteraction is the persisted form of llm.Tool.UserInteraction.
UserInteraction string `json:"user_interaction,omitempty"`

Expand Down Expand Up @@ -104,11 +111,11 @@ func BoolPtr(b bool) *bool { return &b }
func Int64Ptr(v int64) *int64 { return &v }

// FilterForNonRequester returns a new slice of content blocks with private
// tool data redacted. Tool use blocks with shared != true have their Input
// field set to nil. Tool result blocks with shared != true have their Content
// field set to empty string. All other block types pass through unchanged.
// The original slice and its elements are never mutated.
// Returns nil if the input is nil.
// tool data redacted. Tool use blocks with shared != true have Input and
// MCPBareName cleared; tool result blocks with shared != true have Content
// cleared. Tool identity (Name, Title, Description, ServerOrigin) stays
// visible, mirroring redactToolCalls on the live path so both paths render
// identically. The original slice is never mutated; nil in, nil out.
func FilterForNonRequester(blocks []ContentBlock) []ContentBlock {
if blocks == nil {
return nil
Expand All @@ -133,10 +140,11 @@ func FilterForNonRequester(blocks []ContentBlock) []ContentBlock {
}

// SanitizeForDisplay returns a new slice of content blocks with LLM-generated
// string fields sanitized against Unicode bidi/spoofing attacks. Tool use
// blocks have their Input field sanitized, and tool result blocks have their
// Content field sanitized. The original slice is never mutated.
// Returns nil if the input is nil.
// and MCP-server-supplied string fields sanitized against Unicode bidi/spoofing
// attacks: Input, Title, and Description on tool_use blocks, Content on
// tool_result blocks. Title/Description are already sanitized at capture; this
// is defense in depth that also covers older persisted turns. The original
// slice is never mutated; nil in, nil out.
func SanitizeForDisplay(blocks []ContentBlock) []ContentBlock {
if blocks == nil {
return nil
Expand All @@ -150,6 +158,12 @@ func SanitizeForDisplay(blocks []ContentBlock) []ContentBlock {
if len(block.Input) > 0 {
result[i].Input = json.RawMessage(llm.SanitizeNonPrintableChars(string(block.Input)))
}
if block.Title != "" {
result[i].Title = llm.SanitizeNonPrintableChars(block.Title)
}
if block.Description != "" {
result[i].Description = llm.SanitizeNonPrintableChars(block.Description)
}
case BlockTypeToolResult:
if block.Content != "" {
result[i].Content = llm.SanitizeNonPrintableChars(block.Content)
Expand Down
62 changes: 62 additions & 0 deletions conversation/content_block_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ func TestContentBlockMarshalUnmarshal(t *testing.T) {
},
expected: `{"type":"tool_use","id":"tc_02","name":"read_file","input":{"path":"/etc/passwd"},"status":"pending","shared":false}`,
},
{
name: "tool_use block with title/description/mcp_bare_name",
block: ContentBlock{
Type: BlockTypeToolUse,
ID: "tc_03",
Name: "mattermost__create_post",
ServerOrigin: "embedded://mattermost",
MCPBareName: "create_post",
Title: "Create Post",
Description: "Create a new post in Mattermost.",
Input: json.RawMessage(`{"channel_id":"c1"}`),
Status: StatusPending,
Shared: BoolPtr(true),
},
expected: `{"type":"tool_use","id":"tc_03","name":"mattermost__create_post","server_origin":"embedded://mattermost","input":{"channel_id":"c1"},"mcp_bare_name":"create_post","status":"pending","shared":true,"title":"Create Post","description":"Create a new post in Mattermost."}`,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -194,6 +210,8 @@ func TestFilterForNonRequesterRedactsApprovalMetadata(t *testing.T) {
Type: BlockTypeToolUse,
ID: "tc_private",
Name: "jira__get_issue",
Title: "Get Issue",
Description: "Get a Jira issue",
Input: json.RawMessage(`{"key":"MM-1"}`),
MCPBareName: "get_issue",
Status: StatusPending,
Expand All @@ -205,6 +223,10 @@ func TestFilterForNonRequesterRedactsApprovalMetadata(t *testing.T) {
require.Len(t, result, 1)
assert.Nil(t, result[0].Input)
assert.Empty(t, result[0].MCPBareName)
// Tool identity stays visible to non-requesters, matching redactToolCalls.
assert.Equal(t, "Get Issue", result[0].Title)
assert.Equal(t, "Get a Jira issue", result[0].Description)
assert.Equal(t, "jira__get_issue", result[0].Name)
assert.NotNil(t, blocks[0].Input, "original block must not be mutated")
assert.Equal(t, "get_issue", blocks[0].MCPBareName, "original block must not be mutated")
}
Expand Down Expand Up @@ -331,3 +353,43 @@ func TestFilterForNonRequesterDoesNotMutateOriginal(t *testing.T) {
assert.Equal(t, originalInputCopy, original[0].Input)
assert.Equal(t, originalContentCopy, original[1].Content)
}

func TestSanitizeForDisplaySanitizesTitleAndDescription(t *testing.T) {
// U+202E (right-to-left override) is a classic bidi spoofing character.
blocks := []ContentBlock{{
Type: BlockTypeToolUse,
ID: "tc1",
Name: "jira__get_issue",
Title: "Get\u202eIssue",
Description: "Get\u202ean issue",
Input: json.RawMessage("{\"key\":\"MM\u202e-1\"}"),
Status: StatusPending,
}}

result := SanitizeForDisplay(blocks)

require.Len(t, result, 1)
assert.Equal(t, "Get[U+202E]Issue", result[0].Title)
assert.Equal(t, "Get[U+202E]an issue", result[0].Description)
assert.Contains(t, string(result[0].Input), "[U+202E]")

// Original is not mutated.
assert.Equal(t, "Get\u202eIssue", blocks[0].Title)
assert.Equal(t, "Get\u202ean issue", blocks[0].Description)
}

func TestSanitizeForDisplayLeavesCleanTitleAndDescription(t *testing.T) {
blocks := []ContentBlock{{
Type: BlockTypeToolUse,
ID: "tc1",
Name: "jira__get_issue",
Title: "Get Issue",
Description: "Get a Jira issue",
}}

result := SanitizeForDisplay(blocks)

require.Len(t, result, 1)
assert.Equal(t, "Get Issue", result[0].Title)
assert.Equal(t, "Get a Jira issue", result[0].Description)
}
4 changes: 4 additions & 0 deletions conversation/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ func BlocksToPost(
Arguments: arguments,
MCPBareName: block.MCPBareName,
Status: StatusFromString(block.Status),
Title: block.Title,
Description: block.Description,
}
if redactToolUse {
toolCall.MCPBareName = ""
Expand Down Expand Up @@ -268,6 +270,8 @@ func PostToBlocks(post llm.Post, shared bool) []ContentBlock {
MCPBareName: tc.MCPBareName,
Status: StatusToString(tc.Status),
Shared: BoolPtr(shared),
Title: tc.Title,
Description: tc.Description,
})

if tc.Result != "" {
Expand Down
20 changes: 8 additions & 12 deletions conversation/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,6 @@ func TestBlocksToPost_RedactUnshared(t *testing.T) {
assert.JSONEq(t, `{}`, args["t-nilshared"])
for _, tc := range got.ToolUse {
if tc.ID == "t-private" {
assert.Nil(t, tc.Schema)
assert.Empty(t, tc.MCPBareName)
assert.Empty(t, tc.Description)
}
Expand All @@ -246,16 +245,11 @@ func TestPostToBlocksPreservesToolIdentityMetadata(t *testing.T) {
ID: "tc1",
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
ServerOrigin: "https://jira.example.com",
Arguments: json.RawMessage(`{"key":"MM-1"}`),
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"key": map[string]any{"type": "string"},
},
},
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
}},
}

Expand All @@ -266,11 +260,13 @@ func TestPostToBlocksPreservesToolIdentityMetadata(t *testing.T) {
assert.Equal(t, "jira__get_issue", blocks[0].Name)
assert.Equal(t, "https://jira.example.com", blocks[0].ServerOrigin)
assert.Equal(t, "get_issue", blocks[0].MCPBareName)
assert.Equal(t, "Get Issue", blocks[0].Title)
assert.Equal(t, "Get a Jira issue", blocks[0].Description)

data, err := json.Marshal(blocks[0])
require.NoError(t, err)
assert.NotContains(t, string(data), "input_schema")
assert.NotContains(t, string(data), "tool_description")
assert.NotContains(t, string(data), "\"schema\"")
}

func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
Expand All @@ -290,6 +286,7 @@ func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
toolStore.AddTools([]llm.Tool{{
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
Schema: schema,
ServerOrigin: "https://jira.example.com",
}})
Expand All @@ -303,8 +300,7 @@ func TestBlocksToPostRehydratesToolCatalogMetadata(t *testing.T) {
assert.Equal(t, "https://jira.example.com", toolCall.ServerOrigin)
assert.Equal(t, "get_issue", toolCall.MCPBareName)
assert.Equal(t, "Get a Jira issue", toolCall.Description)
require.IsType(t, json.RawMessage{}, toolCall.Schema)
assert.JSONEq(t, `{"type":"object","properties":{"key":{"type":"string"}}}`, string(toolCall.Schema.(json.RawMessage)))
assert.Equal(t, "Get Issue", toolCall.Title)
}

func TestPostToBlocks(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions conversation/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func toolUseBlocks(
Status: StatusToString(tc.Status),
Shared: BoolPtr(shared),
UserInteraction: tc.UserInteraction,
Title: tc.Title,
Description: tc.Description,
})
}

Expand Down
4 changes: 3 additions & 1 deletion conversation/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ func TestToolUseBlocksPreservesApprovalMetadata(t *testing.T) {
ID: "tc1",
Name: "jira__get_issue",
Description: "Get a Jira issue",
Title: "Get Issue",
ServerOrigin: "https://jira.example.com",
Arguments: json.RawMessage(`{"key":"MM-1"}`),
Schema: json.RawMessage(`{"type":"object"}`),
MCPBareName: "get_issue",
Status: llm.ToolCallStatusPending,
}}, false)
Expand All @@ -77,6 +77,8 @@ func TestToolUseBlocksPreservesApprovalMetadata(t *testing.T) {
assert.Equal(t, "jira__get_issue", blocks[0].Name)
assert.Equal(t, "https://jira.example.com", blocks[0].ServerOrigin)
assert.Equal(t, "get_issue", blocks[0].MCPBareName)
assert.Equal(t, "Get Issue", blocks[0].Title)
assert.Equal(t, "Get a Jira issue", blocks[0].Description)
}

func TestUnmarshalBlocks(t *testing.T) {
Expand Down
87 changes: 87 additions & 0 deletions conversation/tool_use_writer_parity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

package conversation

import (
"encoding/json"
"testing"

"github.com/mattermost/mattermost-plugin-agents/v2/llm"
"github.com/stretchr/testify/require"
)

// persistedToolUseFields are the tool_use ContentBlock JSON tags the writers
// in this package must emit so a tool call renders identically live and after
// reload. Keep in sync with the persisted fields in
// streaming/tool_call_parity_test.go, which covers the live-path writer and
// redaction parity but cannot reach the unexported toolUseBlocks.
var persistedToolUseFields = []string{
"id",
"name",
"server_origin",
"input",
"mcp_bare_name",
"status",
"title",
"description",
"user_interaction",
}

func parityToolCall() llm.ToolCall {
return llm.ToolCall{
ID: "tc-1",
Name: "mattermost__create_post",
Description: "Create a post",
Title: "Create Post",
Arguments: json.RawMessage(`{"channel_id":"c1"}`),
Status: llm.ToolCallStatusSuccess,
MCPBareName: "create_post",
UserInteraction: llm.UserInteractionSelect,
ServerOrigin: "embedded://mattermost",
}
}

func toolUseBlockJSONMap(t *testing.T, block ContentBlock) map[string]any {
t.Helper()
data, err := json.Marshal(block)
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
return m
}

// TestToolUseBlocksPersistsPolicyFields asserts the auto-run writer
// (toolUseBlocks) emits every persisted tool_use identity/metadata field for a
// fully-populated call, so an auto-run round renders the same as a live one.
func TestToolUseBlocksPersistsPolicyFields(t *testing.T) {
blocks := toolUseBlocks("", llm.ReasoningData{}, []llm.ToolCall{parityToolCall()}, true)
require.Len(t, blocks, 1)
require.Equal(t, BlockTypeToolUse, blocks[0].Type)

m := toolUseBlockJSONMap(t, blocks[0])
for _, field := range persistedToolUseFields {
require.Containsf(t, m, field, "toolUseBlocks dropped persisted tool_use field %q", field)
require.NotEmptyf(t, m[field], "toolUseBlocks emitted an empty persisted tool_use field %q", field)
}
}

// TestPostToBlocksPersistsPolicyFields asserts the generic Post->blocks
// converter carries the same tool identity/metadata. It intentionally omits
// user_interaction: PostToBlocks converts completed posts, where the pending
// interaction kind is not meaningful (it is set by the approval-path writers).
func TestPostToBlocksPersistsPolicyFields(t *testing.T) {
post := llm.Post{Role: llm.PostRoleBot, ToolUse: []llm.ToolCall{parityToolCall()}}
blocks := PostToBlocks(post, true)
require.GreaterOrEqual(t, len(blocks), 1)
require.Equal(t, BlockTypeToolUse, blocks[0].Type)

m := toolUseBlockJSONMap(t, blocks[0])
for _, field := range persistedToolUseFields {
if field == "user_interaction" {
continue
}
require.Containsf(t, m, field, "PostToBlocks dropped persisted tool_use field %q", field)
require.NotEmptyf(t, m[field], "PostToBlocks emitted an empty persisted tool_use field %q", field)
}
}
1 change: 1 addition & 0 deletions e2e/scripts/ci-test-groups.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const groups = {
'tests/rhs-core/new-messages-rhs.spec.ts',
'tests/tool-config/policy-change.spec.ts',
'tests/tool-config/tab-layout.spec.ts',
'tests/tool-config/mock-api/rich-create-post-card.spec.ts',
'tests/custom-prompts/custom-prompts.spec.ts',
'tests/meeting-summary/summary-persistence.spec.ts',
'tests/channel-analysis/backend-verification/real-api.spec.ts',
Expand Down
Loading
Loading