From 30653fbdbf1e138fd615af794ae9d82550c6c243 Mon Sep 17 00:00:00 2001 From: Acbox Date: Wed, 11 Mar 2026 16:59:42 +0800 Subject: [PATCH] fix(agent): reject send tool when targeting the same conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass replyTarget through the full pipeline (ChatRequest → gateway identity → agent headers → MCP session) so the send tool can detect when the destination matches the current conversation and return an error guiding the agent to reply directly instead. --- apps/agent/src/models.ts | 1 + internal/channel/inbound/channel.go | 1 + internal/conversation/flow/resolver.go | 2 ++ internal/conversation/types.go | 1 + internal/mcp/providers/message/provider.go | 10 ++++++++ .../mcp/providers/message/provider_test.go | 23 +++++++++++++++++-- packages/agent/src/types/agent.ts | 1 + packages/agent/src/utils/headers.ts | 3 +++ 8 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/agent/src/models.ts b/apps/agent/src/models.ts index acbfc180..8758505e 100644 --- a/apps/agent/src/models.ts +++ b/apps/agent/src/models.ts @@ -31,6 +31,7 @@ export const IdentityContextModel = z.object({ channelIdentityId: z.string().min(1, 'Channel identity ID is required'), displayName: z.string().min(1, 'Display name is required'), currentPlatform: z.string().optional(), + replyTarget: z.string().optional(), conversationType: z.string().optional(), sessionToken: z.string().optional(), }) diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index 38ceaf3a..16bf7cb0 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -382,6 +382,7 @@ func (p *ChannelInboundProcessor) HandleInbound(ctx context.Context, cfg channel RouteID: resolved.RouteID, ChatToken: chatToken, ExternalMessageID: sourceMessageID, + ReplyTarget: target, ConversationType: msg.Conversation.Type, ConversationName: msg.Conversation.Name, Query: text, diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index c6848c17..a19cb2af 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -161,6 +161,7 @@ type gatewayIdentity struct { ChannelIdentityID string `json:"channelIdentityId"` DisplayName string `json:"displayName"` CurrentPlatform string `json:"currentPlatform,omitempty"` + ReplyTarget string `json:"replyTarget,omitempty"` ConversationType string `json:"conversationType,omitempty"` SessionToken string `json:"sessionToken,omitempty"` //nolint:gosec // intentional: session token forwarded to agent gateway for channel reply routing } @@ -470,6 +471,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID), DisplayName: displayName, CurrentPlatform: req.CurrentChannel, + ReplyTarget: strings.TrimSpace(req.ReplyTarget), ConversationType: strings.TrimSpace(req.ConversationType), SessionToken: req.ChatToken, }, diff --git a/internal/conversation/types.go b/internal/conversation/types.go index 972cfc5a..8ff0bda2 100644 --- a/internal/conversation/types.go +++ b/internal/conversation/types.go @@ -225,6 +225,7 @@ type ChatRequest struct { RouteID string `json:"-"` ChatToken string `json:"-"` ExternalMessageID string `json:"-"` + ReplyTarget string `json:"-"` ConversationType string `json:"-"` ConversationName string `json:"-"` UserMessagePersisted bool `json:"-"` diff --git a/internal/mcp/providers/message/provider.go b/internal/mcp/providers/message/provider.go index a43b11b3..db6c9d7d 100644 --- a/internal/mcp/providers/message/provider.go +++ b/internal/mcp/providers/message/provider.go @@ -227,6 +227,16 @@ func (p *Executor) callSend(ctx context.Context, session mcpgw.ToolSessionContex return mcpgw.BuildToolErrorResult("target is required"), nil } + // Reject send when the destination matches the current conversation. + if strings.EqualFold(channelType.String(), strings.TrimSpace(session.CurrentPlatform)) && + target == strings.TrimSpace(session.ReplyTarget) { + return mcpgw.BuildToolErrorResult( + "You are trying to send a message to the SAME conversation you are already in. " + + "Do NOT use the send tool for this. Instead, write your reply as plain text directly. " + + "To include files, use the block in your response (e.g. [{\"type\":\"image\",\"path\":\"/data/media/file.jpg\"}]).", + ), nil + } + sendReq := channel.SendRequest{ Target: target, Message: outboundMessage, diff --git a/internal/mcp/providers/message/provider_test.go b/internal/mcp/providers/message/provider_test.go index cbab0ebb..5a89c1cf 100644 --- a/internal/mcp/providers/message/provider_test.go +++ b/internal/mcp/providers/message/provider_test.go @@ -252,7 +252,7 @@ func TestExecutor_CallTool_SendError(t *testing.T) { } } -func TestExecutor_CallTool_Success(t *testing.T) { +func TestExecutor_CallTool_SameRouteRejected(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} exec := NewExecutor(nil, sender, nil, resolver, nil) @@ -263,6 +263,23 @@ func TestExecutor_CallTool_Success(t *testing.T) { if err != nil { t.Fatal(err) } + if isErr, _ := result["isError"].(bool); !isErr { + t.Error("expected error when sending to the same route as current session") + } +} + +func TestExecutor_CallTool_Success(t *testing.T) { + sender := &fakeSender{} + resolver := &fakeResolver{ct: channel.ChannelType("feishu")} + exec := NewExecutor(nil, sender, nil, resolver, nil) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu", ReplyTarget: "chat1"} + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ + "target": "chat2", + "text": "hello", + }) + if err != nil { + t.Fatal(err) + } if err := mcpgw.PayloadError(result); err != nil { t.Fatal(err) } @@ -284,6 +301,7 @@ func TestExecutor_CallTool_ReplyTo(t *testing.T) { exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ + "target": "456", "text": "reply text", "reply_to": "msg-789", }) @@ -307,7 +325,8 @@ func TestExecutor_CallTool_NoReplyTo(t *testing.T) { exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ - "text": "no reply", + "target": "456", + "text": "no reply", }) if err != nil { t.Fatal(err) diff --git a/packages/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts index ef46de13..6eca5a2f 100644 --- a/packages/agent/src/types/agent.ts +++ b/packages/agent/src/types/agent.ts @@ -9,6 +9,7 @@ export interface IdentityContext { channelIdentityId: string displayName: string currentPlatform?: string + replyTarget?: string conversationType?: string sessionToken?: string } diff --git a/packages/agent/src/utils/headers.ts b/packages/agent/src/utils/headers.ts index 2340e6b6..326b4ffa 100644 --- a/packages/agent/src/utils/headers.ts +++ b/packages/agent/src/utils/headers.ts @@ -17,6 +17,9 @@ export const buildIdentityHeaders = (identity: IdentityContext, auth: AgentAuthC if (identity.currentPlatform) { headers['X-Memoh-Current-Platform'] = identity.currentPlatform } + if (identity.replyTarget) { + headers['X-Memoh-Reply-Target'] = identity.replyTarget + } if (options?.isSubagent) { headers['X-Memoh-Is-Subagent'] = 'true' }