From 00e589e1bdddde89598e5a66b92e30ce30da06b0 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:01:45 +0200 Subject: [PATCH 01/11] feat(session): preserve threaded message history metadata --- pkg/agent/context.go | 35 ++- pkg/agent/eventbus_test.go | 12 +- pkg/agent/hook_process_test.go | 12 +- pkg/agent/hooks_test.go | 12 +- pkg/agent/loop.go | 285 +++++++++++++++++++------ pkg/agent/loop_test.go | 83 ++++++- pkg/agent/steering.go | 9 +- pkg/bus/bus.go | 10 + pkg/bus/types.go | 13 +- pkg/channels/discord/discord.go | 51 +++-- pkg/channels/interfaces.go | 6 + pkg/channels/manager.go | 107 ++++++---- pkg/channels/manager_test.go | 141 +++++++++++- pkg/channels/qq/qq.go | 24 ++- pkg/channels/slack/slack.go | 16 +- pkg/channels/telegram/telegram.go | 45 ++-- pkg/channels/telegram/telegram_test.go | 28 ++- pkg/providers/protocoltypes/types.go | 12 ++ pkg/providers/types.go | 1 + 19 files changed, 710 insertions(+), 192 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 12e3cdd4d1..bad93c6508 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -593,8 +593,15 @@ func (cb *ContextBuilder) BuildMessages( SystemParts: contentBlocks, }) - // Add conversation history - messages = append(messages, history...) + // Add conversation history, annotating messages that have threading IDs + // so the LLM can navigate thread structure from persisted sessions. + for _, msg := range history { + annotated := msg + if prefix := messageThreadAnnotation(msg); prefix != "" { + annotated.Content = prefix + msg.Content + } + messages = append(messages, annotated) + } // Add current user message if strings.TrimSpace(currentMessage) != "" { @@ -843,3 +850,27 @@ func (cb *ContextBuilder) GetSkillsInfo() map[string]any { "names": skillNames, } } + +// messageThreadAnnotation returns the thread annotation prefix for a message, +// e.g. "[msg:#5, reply_to:#3] " or "" if the message has no threading IDs. +func messageThreadAnnotation(msg providers.Message) string { + msgIDs := msg.MessageIDs + formattedIDs := strings.Join(msgIDs, ",#") + if formattedIDs != "" { + formattedIDs = "#" + formattedIDs + } + switch { + case len(msgIDs) > 1 && msg.ReplyToMessageID != "": + return fmt.Sprintf("[msgs:%s, reply_to:#%s] ", formattedIDs, msg.ReplyToMessageID) + case len(msgIDs) > 1: + return fmt.Sprintf("[msgs:%s] ", formattedIDs) + case len(msgIDs) == 1 && msg.ReplyToMessageID != "": + return fmt.Sprintf("[msg:%s, reply_to:#%s] ", formattedIDs, msg.ReplyToMessageID) + case len(msgIDs) == 1: + return fmt.Sprintf("[msg:%s] ", formattedIDs) + case msg.ReplyToMessageID != "": + return fmt.Sprintf("[reply_to:#%s] ", msg.ReplyToMessageID) + default: + return "" + } +} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 19a1ea9ebb..6c458b6481 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -140,8 +140,8 @@ func TestAgentLoop_EmitsMinimalTurnEvents(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if response != "done" { - t.Fatalf("expected final response 'done', got %q", response) + if response.Content != "done" { + t.Fatalf("expected final response 'done', got %q", response.Content) } events := collectEventStream(sub.C) @@ -396,8 +396,8 @@ func TestAgentLoop_EmitsContextCompressEventOnRetry(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "Recovered from context error" { - t.Fatalf("expected retry success, got %q", resp) + if resp.Content != "Recovered from context error" { + t.Fatalf("expected retry success, got %q", resp.Content) } events := collectEventStream(sub.C) @@ -551,8 +551,8 @@ func TestAgentLoop_EmitsFollowUpQueuedEvent(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "async launched" { - t.Fatalf("expected final response 'async launched', got %q", resp) + if resp.Content != "async launched" { + t.Fatalf("expected final response 'async launched', got %q", resp.Content) } select { diff --git a/pkg/agent/hook_process_test.go b/pkg/agent/hook_process_test.go index 50f89811ff..829c7f8997 100644 --- a/pkg/agent/hook_process_test.go +++ b/pkg/agent/hook_process_test.go @@ -52,8 +52,8 @@ func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "provider content|ipc" { - t.Fatalf("expected process-hooked llm content, got %q", resp) + if resp.Content != "provider content|ipc" { + t.Fatalf("expected process-hooked llm content, got %q", resp.Content) } provider.mu.Lock() @@ -92,8 +92,8 @@ func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "ipc:ipc" { - t.Fatalf("expected rewritten process-hook tool result, got %q", resp) + if resp.Content != "ipc:ipc" { + t.Fatalf("expected rewritten process-hook tool result, got %q", resp.Content) } } @@ -160,8 +160,8 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) { } expected := "Tool execution denied by approval hook: blocked by ipc hook" - if resp != expected { - t.Fatalf("expected %q, got %q", expected, resp) + if resp.Content != expected { + t.Fatalf("expected %q, got %q", expected, resp.Content) } events := collectEventStream(sub.C) diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index 49e1b17843..9bb2126b47 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -159,8 +159,8 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "hooked content" { - t.Fatalf("expected hooked content, got %q", resp) + if resp.Content != "hooked content" { + t.Fatalf("expected hooked content, got %q", resp.Content) } provider.mu.Lock() @@ -286,8 +286,8 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { if err != nil { t.Fatalf("runAgentLoop failed: %v", err) } - if resp != "after:modified" { - t.Fatalf("expected rewritten tool result, got %q", resp) + if resp.Content != "after:modified" { + t.Fatalf("expected rewritten tool result, got %q", resp.Content) } } @@ -326,8 +326,8 @@ func TestAgentLoop_Hooks_ToolApproverCanDeny(t *testing.T) { t.Fatalf("runAgentLoop failed: %v", err) } expected := "Tool execution denied by approval hook: blocked" - if resp != expected { - t.Fatalf("expected %q, got %q", expected, resp) + if resp.Content != expected { + t.Fatalf("expected %q, got %q", expected, resp.Content) } events := collectEventStream(sub.C) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 72c78c7297..ad167878ff 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -72,21 +72,24 @@ type AgentLoop struct { // processOptions configures how a message is processed type processOptions struct { - SessionKey string // Session identifier for history/context - Channel string // Target channel for tool execution - ChatID string // Target chat ID for tool execution - SenderID string // Current sender ID for dynamic context - SenderDisplayName string // Current sender display name for dynamic context - UserMessage string // User message content (may include prefix) - ForcedSkills []string // Skills explicitly requested for this message - SystemPromptOverride string // Override the default system prompt (Used by SubTurns) - Media []string // media:// refs from inbound message - InitialSteeringMessages []providers.Message // Steering messages from refactor/agent - DefaultResponse string // Response when LLM returns empty - EnableSummary bool // Whether to trigger summarization - SendResponse bool // Whether to send response via bus - NoHistory bool // If true, don't load session history (for heartbeat) - SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) + SessionKey string // Session identifier for history/context + Channel string // Target channel for tool execution + ChatID string // Target chat ID for tool execution + SenderID string // Current sender ID for dynamic context + SenderDisplayName string // Current sender display name for dynamic context + UserMessage string // User message content (may include prefix) + ForcedSkills []string // Skills explicitly requested for this message + SystemPromptOverride string // Override the default system prompt (Used by SubTurns) + Media []string // media:// refs from inbound message + InitialSteeringMessages []providers.Message // Steering messages from refactor/agent + DefaultResponse string // Response when LLM returns empty + EnableSummary bool // Whether to trigger summarization + SendResponse bool // Whether to send response via bus + NoHistory bool // If true, don't load session history (for heartbeat) + SkipInitialSteeringPoll bool // If true, skip the steering poll at loop start (used by Continue) + MessageID string // Inbound platform message ID (for threading) + ReplyToMessageID string // Parent message ID from inbound (for threading) + Sender *providers.MessageSender // Author identity (nil for system/automated messages) } type continuationTarget struct { @@ -95,6 +98,46 @@ type continuationTarget struct { ChatID string } +type agentResponse struct { + Content string + Channel string + ChatID string + OnDelivered func(msgIDs []string) +} + +func (r agentResponse) outboundMessage(defaultChannel, defaultChatID string) bus.OutboundMessage { + channel := r.Channel + if channel == "" { + channel = defaultChannel + } + chatID := r.ChatID + if chatID == "" { + chatID = defaultChatID + } + return bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: r.Content, + OnDelivered: r.OnDelivered, + } +} + +func singleMessageIDs(msgID string) []string { + if msgID == "" { + return nil + } + return []string{msgID} +} + +func cloneMessageIDs(msgIDs []string) []string { + if len(msgIDs) == 0 { + return nil + } + cloned := make([]string, len(msgIDs)) + copy(cloned, msgIDs) + return cloned +} + const ( defaultResponse = "The model returned an empty response. This may indicate a provider error or token limit." toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps." @@ -104,6 +147,7 @@ const ( metadataKeyTeamID = "team_id" metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerID = "parent_peer_id" + metadataKeyReplyToMessage = "reply_to_message_id" ) func NewAgentLoop( @@ -442,9 +486,13 @@ func (al *AgentLoop) Run(ctx context.Context) error { response, err := al.processMessage(ctx, msg) if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) + response = agentResponse{ + Content: fmt.Sprintf("Error processing message: %v", err), + Channel: msg.Channel, + ChatID: msg.ChatID, + } } - finalResponse := response + finalResponse := response.Content target, targetErr := al.buildContinuationTarget(msg) if targetErr != nil { @@ -457,12 +505,20 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if target == nil { cancelDrain() - if finalResponse != "" { - al.publishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse) + if response.Content != "" { + al.publishAgentResponseIfNeeded(ctx, response, msg.Channel, msg.ChatID) } return } + responsePersisted := false + continuedOnce := false + if al.pendingSteeringCountForScope(target.SessionKey) > 0 && + response.Content != "" && response.OnDelivered != nil { + response.OnDelivered(nil) + responsePersisted = true + } + for al.pendingSteeringCountForScope(target.SessionKey) > 0 { logger.InfoCF("agent", "Continuing queued steering after turn end", map[string]any{ @@ -487,10 +543,17 @@ func (al *AgentLoop) Run(ctx context.Context) error { } finalResponse = continued + continuedOnce = true } cancelDrain() + if al.pendingSteeringCountForScope(target.SessionKey) > 0 && + !responsePersisted && response.Content != "" && response.OnDelivered != nil { + response.OnDelivered(nil) + responsePersisted = true + } + for al.pendingSteeringCountForScope(target.SessionKey) > 0 { logger.InfoCF("agent", "Draining steering queued during turn shutdown", map[string]any{ @@ -515,10 +578,15 @@ func (al *AgentLoop) Run(ctx context.Context) error { } finalResponse = continued + continuedOnce = true } if finalResponse != "" { - al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) + if continuedOnce { + al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) + } else { + al.publishAgentResponseIfNeeded(ctx, response, target.Channel, target.ChatID) + } } }() default: @@ -641,6 +709,47 @@ func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatI }) } +func (al *AgentLoop) publishAgentResponseIfNeeded( + ctx context.Context, + response agentResponse, + defaultChannel, defaultChatID string, +) { + if response.Content == "" { + return + } + + alreadySent := false + defaultAgent := al.GetRegistry().GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } + } + } + + if alreadySent { + if response.OnDelivered != nil { + response.OnDelivered(nil) + } + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent)", + map[string]any{"channel": response.outboundMessage(defaultChannel, defaultChatID).Channel}, + ) + return + } + + outbound := response.outboundMessage(defaultChannel, defaultChatID) + al.bus.PublishOutbound(ctx, outbound) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": outbound.Channel, + "chat_id": outbound.ChatID, + "content_len": len(response.Content), + }) +} + func (al *AgentLoop) buildContinuationTarget(msg bus.InboundMessage) (*continuationTarget, error) { if msg.Channel == "system" { return nil, nil @@ -1220,7 +1329,14 @@ func (al *AgentLoop) ProcessDirectWithChannel( SessionKey: sessionKey, } - return al.processMessage(ctx, msg) + response, err := al.processMessage(ctx, msg) + if err != nil { + return "", err + } + if response.OnDelivered != nil { + response.OnDelivered(nil) + } + return response.Content, nil } // ProcessHeartbeat processes a heartbeat request without session history. @@ -1240,7 +1356,7 @@ func (al *AgentLoop) ProcessHeartbeat( if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } - return al.runAgentLoop(ctx, agent, processOptions{ + response, err := al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, ChatID: chatID, @@ -1250,9 +1366,16 @@ func (al *AgentLoop) ProcessHeartbeat( SendResponse: false, NoHistory: true, // Don't load session history for heartbeat }) + if err != nil { + return "", err + } + if response.OnDelivered != nil { + response.OnDelivered(nil) + } + return response.Content, nil } -func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { +func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (agentResponse, error) { // Add message preview to log (show full content for error messages) var logContent string if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { @@ -1287,7 +1410,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) route, agent, routeErr := al.resolveMessageRoute(msg) if routeErr != nil { - return "", routeErr + return agentResponse{}, routeErr } // Reset message-tool state for this round so we don't skip publishing due to a previous round. @@ -1322,12 +1445,19 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, + MessageID: msg.MessageID, + ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage), + Sender: messageSenderFromInbound(msg.Sender), } // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { - return response, nil + return agentResponse{ + Content: response, + Channel: opts.Channel, + ChatID: opts.ChatID, + }, nil } if pending := al.takePendingSkills(opts.SessionKey); len(pending) > 0 { @@ -1400,9 +1530,9 @@ func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error { func (al *AgentLoop) processSystemMessage( ctx context.Context, msg bus.InboundMessage, -) (string, error) { +) (agentResponse, error) { if msg.Channel != "system" { - return "", fmt.Errorf( + return agentResponse{}, fmt.Errorf( "processSystemMessage called with non-system message channel: %s", msg.Channel, ) @@ -1439,13 +1569,13 @@ func (al *AgentLoop) processSystemMessage( "content_len": len(content), "channel": originChannel, }) - return "", nil + return agentResponse{}, nil } // Use default agent for system messages agent := al.GetRegistry().GetDefaultAgent() if agent == nil { - return "", fmt.Errorf("no default agent for system message") + return agentResponse{}, fmt.Errorf("no default agent for system message") } // Use the origin session for context @@ -1468,7 +1598,7 @@ func (al *AgentLoop) runAgentLoop( ctx context.Context, agent *AgentInstance, opts processOptions, -) (string, error) { +) (agentResponse, error) { // Record last channel for heartbeat notifications (skip internal channels and cli) if opts.Channel != "" && opts.ChatID != "" && !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) @@ -1484,10 +1614,10 @@ func (al *AgentLoop) runAgentLoop( ts := newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey)) result, err := al.runTurn(ctx, ts) if err != nil { - return "", err + return agentResponse{}, err } if result.status == TurnEndStatusAborted { - return "", nil + return agentResponse{}, nil } for _, followUp := range result.followUps { @@ -1500,12 +1630,32 @@ func (al *AgentLoop) runAgentLoop( } } - if opts.SendResponse && result.finalContent != "" { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: result.finalContent, - }) + response := agentResponse{ + Content: result.finalContent, + Channel: opts.Channel, + ChatID: opts.ChatID, + } + + if !opts.NoHistory && result.finalContent != "" { + response.OnDelivered = func(msgIDs []string) { + assistantMsg := providers.Message{ + Role: "assistant", + Content: result.finalContent, + MessageIDs: cloneMessageIDs(msgIDs), + } + agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) + if saveErr := agent.Sessions.Save(opts.SessionKey); saveErr != nil { + logger.WarnCF("agent", "Failed to save delivered assistant message", + map[string]any{ + "session_key": opts.SessionKey, + "error": saveErr.Error(), + }) + return + } + if opts.EnableSummary { + al.maybeSummarize(agent, opts.SessionKey, ts.scope) + } + } } if result.finalContent != "" { @@ -1519,7 +1669,7 @@ func (al *AgentLoop) runAgentLoop( }) } - return result.finalContent, nil + return response, nil } func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { @@ -1671,15 +1821,14 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er // Save user message to session (from Incoming) if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { rootMsg := providers.Message{ - Role: "user", - Content: ts.userMessage, - Media: append([]string(nil), ts.media...), - } - if len(rootMsg.Media) > 0 { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) - } else { - ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) - } + Role: "user", + Content: ts.userMessage, + Media: append([]string(nil), ts.media...), + MessageIDs: singleMessageIDs(ts.opts.MessageID), + ReplyToMessageID: ts.opts.ReplyToMessageID, + Sender: ts.opts.Sender, + } + ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) ts.recordPersistedMessage(rootMsg) } @@ -2571,27 +2720,6 @@ turnLoop: ts.setPhase(TurnPhaseFinalizing) ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { - finalMsg := providers.Message{Role: "assistant", Content: finalContent} - ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) - ts.recordPersistedMessage(finalMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - - if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) - } ts.setPhase(TurnPhaseCompleted) return turnResult{ @@ -3451,3 +3579,22 @@ func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) { } return defaultAgent.Provider, true } + +// messageSenderFromInbound converts bus.SenderInfo to providers.MessageSender. +// Returns nil if no meaningful identity is present. +func messageSenderFromInbound(s bus.SenderInfo) *providers.MessageSender { + if s.Username == "" && s.FirstName == "" && s.LastName == "" && s.DisplayName == "" { + return nil + } + username := s.Username + firstName := s.FirstName + lastName := s.LastName + if firstName == "" && lastName == "" && s.DisplayName != "" { + firstName = s.DisplayName + } + return &providers.MessageSender{ + Username: username, + FirstName: firstName, + LastName: lastName, + } +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index ad00221388..84aaab50b6 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -113,8 +113,8 @@ func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { if err != nil { t.Fatalf("processMessage() error = %v", err) } - if response != "Mock response" { - t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + if response.Content != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response.Content, "Mock response") } if len(provider.lastMessages) == 0 { t.Fatal("provider did not receive any messages") @@ -169,8 +169,8 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) { if err != nil { t.Fatalf("processMessage() error = %v", err) } - if response != "Mock response" { - t.Fatalf("processMessage() response = %q, want %q", response, "Mock response") + if response.Content != "Mock response" { + t.Fatalf("processMessage() response = %q, want %q", response.Content, "Mock response") } if len(provider.lastMessages) == 0 { t.Fatal("provider did not receive any messages") @@ -259,8 +259,8 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { if err != nil { t.Fatalf("processMessage() arm error = %v", err) } - if !strings.Contains(response, `Skill "shell" is armed for your next message.`) { - t.Fatalf("arm response = %q, want armed confirmation", response) + if !strings.Contains(response.Content, `Skill "shell" is armed for your next message.`) { + t.Fatalf("arm response = %q, want armed confirmation", response.Content) } response, err = al.processMessage(context.Background(), bus.InboundMessage{ @@ -272,8 +272,8 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { if err != nil { t.Fatalf("processMessage() follow-up error = %v", err) } - if response != "Mock response" { - t.Fatalf("follow-up response = %q, want %q", response, "Mock response") + if response.Content != "Mock response" { + t.Fatalf("follow-up response = %q, want %q", response.Content, "Mock response") } if len(provider.lastMessages) == 0 { t.Fatal("provider did not receive any messages") @@ -289,6 +289,68 @@ func TestProcessMessage_UseCommandArmsSkillForNextMessage(t *testing.T) { } } +func TestProcessMessage_AssistantSavedOnDelivered(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + sessionKey := "agent:test-delivery" + response, err := al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "hello", + SessionKey: sessionKey, + MessageID: "in-42", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 1 { + t.Fatalf("expected only user message before delivery, got %d", len(history)) + } + + if response.OnDelivered == nil { + t.Fatal("expected OnDelivered callback") + } + response.OnDelivered([]string{"out-99"}) + + history = defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 2 { + t.Fatalf("expected 2 messages after delivery, got %d", len(history)) + } + if history[1].Role != "assistant" { + t.Fatalf("expected assistant message, got %+v", history[1]) + } + if len(history[1].MessageIDs) != 1 || history[1].MessageIDs[0] != "out-99" { + t.Fatalf("expected assistant message_ids [out-99], got %v", history[1].MessageIDs) + } +} + func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() @@ -699,7 +761,10 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms if err != nil { tb.Fatalf("processMessage failed: %v", err) } - return response + if response.OnDelivered != nil { + response.OnDelivered(nil) + } + return response.Content } const responseTimeout = 3 * time.Second diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index ad6613e8c5..3f95a6d747 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -292,7 +292,7 @@ func (al *AgentLoop) continueWithSteeringMessages( sessionKey, channel, chatID string, steeringMsgs []providers.Message, ) (string, error) { - return al.runAgentLoop(ctx, agent, processOptions{ + response, err := al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: channel, ChatID: chatID, @@ -302,6 +302,13 @@ func (al *AgentLoop) continueWithSteeringMessages( InitialSteeringMessages: steeringMsgs, SkipInitialSteeringPoll: true, }) + if err != nil { + return "", err + } + if response.OnDelivered != nil { + response.OnDelivered(nil) + } + return response.Content, nil } func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 37fcb74c51..339fc05502 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -95,6 +95,16 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { return mb.outbound } +// SubscribeOutbound waits for the next outbound message or until ctx is done. +func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { + select { + case msg, ok := <-mb.outbound: + return msg, ok + case <-ctx.Done(): + return OutboundMessage{}, false + } +} + func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { return publish(ctx, mb, mb.outboundMedia, msg) } diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 12da3f1dd0..0366e89dfe 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -12,7 +12,9 @@ type SenderInfo struct { PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456" CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format Username string `json:"username,omitempty"` // username (e.g. @alice) - DisplayName string `json:"display_name,omitempty"` // display name + DisplayName string `json:"display_name,omitempty"` // display name (used when first/last are not available) + FirstName string `json:"first_name,omitempty"` // given name (preferred over DisplayName when set) + LastName string `json:"last_name,omitempty"` // family name } type InboundMessage struct { @@ -30,10 +32,11 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + OnDelivered func(msgIDs []string) `json:"-"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 3b5b4f8bb2..924bcad89b 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -129,20 +129,30 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + _, err := c.SendMessageWithIDs(ctx, msg) + return err +} + +// SendMessageWithIDs implements channels.MessageIDsSender. +func (c *DiscordChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } if len([]rune(msg.Content)) == 0 { - return nil + return nil, nil } - return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + if err != nil { + return nil, err + } + return []string{msgID}, nil } // SendMedia implements the channels.MediaSender interface. @@ -267,18 +277,25 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type sendResult struct { + id string + err error + } + done := make(chan sendResult, 1) go func() { - var err error + var ( + msg *discordgo.Message + err error + ) // If we have an ID, we send the message as "Reply" if replyToID != "" { - _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Reference: &discordgo.MessageReference{ MessageID: replyToID, @@ -287,20 +304,24 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, repl }) } else { // Otherwise, we send a normal message - _, err = c.session.ChannelMessageSend(channelID, content) + msg, err = c.session.ChannelMessageSend(channelID, content) } - done <- err + if err != nil { + done <- sendResult{err: err} + return + } + done <- sendResult{id: msg.ID} }() select { - case err := <-done: - if err != nil { - return fmt.Errorf("discord send: %w", channels.ErrTemporary) + case result := <-done: + if result.err != nil { + return "", fmt.Errorf("discord send: %w", channels.ErrTemporary) } - return nil + return result.id, nil case <-sendCtx.Done(): - return sendCtx.Err() + return "", sendCtx.Err() } } diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 0cfd435b00..e4388664aa 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -62,6 +62,12 @@ type PlaceholderRecorder interface { RecordReactionUndo(channel, chatID string, undo func()) } +// MessageIDsSender is implemented by channels that can return the platform +// message IDs for a delivered outbound text message. +type MessageIDsSender interface { + SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) (messageIDs []string, err error) +} + // CommandRegistrarCapable is implemented by channels that can register // command menus with their upstream platform (e.g. Telegram BotCommand). // Channels that do not support platform-level command menus can ignore it. diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index f04d989a37..ff704ea8de 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -158,8 +158,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was already delivered (skip Send). -func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { +// Returns the delivered message IDs and true when delivery completed before a normal Send. +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { key := name + ":" + msg.ChatID // 1. Stop typing @@ -188,7 +188,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } - return true + return nil, true } // 4. Try editing placeholder @@ -196,14 +196,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { - return true // edited successfully, skip Send + return []string{entry.id}, true } // edit failed → fall through to normal Send } } } - return false + return nil, false } func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { @@ -593,48 +593,85 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) if !ok { return } - maxLen := 0 - if mlp, ok := w.ch.(MessageLengthProvider); ok { - maxLen = mlp.MaxMessageLength() - } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - chunks := SplitMessage(msg.Content, maxLen) - for _, chunk := range chunks { - chunkMsg := msg - chunkMsg.Content = chunk - m.sendWithRetry(ctx, name, w, chunkMsg) - } - } else { - m.sendWithRetry(ctx, name, w, msg) - } + m.deliverOutbound(ctx, name, w, msg) case <-ctx.Done(): return } } } +func (m *Manager) deliverOutbound(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { + msgIDs, delivered := m.sendOutbound(ctx, name, w, msg) + if delivered && msg.OnDelivered != nil { + msg.OnDelivered(msgIDs) + } +} + +func (m *Manager) sendOutbound( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMessage, +) ([]string, bool) { + maxLen := 0 + if mlp, ok := w.ch.(MessageLengthProvider); ok { + maxLen = mlp.MaxMessageLength() + } + + if maxLen > 0 && len([]rune(msg.Content)) > maxLen { + var messageIDs []string + for _, chunk := range SplitMessage(msg.Content, maxLen) { + chunkMsg := msg + chunkMsg.Content = chunk + chunkMsg.OnDelivered = nil + chunkIDs, delivered := m.sendWithRetry(ctx, name, w, chunkMsg) + if !delivered { + return nil, false + } + if len(chunkIDs) > 0 { + messageIDs = append(messageIDs, chunkIDs...) + } + } + return messageIDs, true + } + + return m.sendWithRetry(ctx, name, w, msg) +} + // sendWithRetry sends a message through the channel with rate limiting and // retry logic. It classifies errors to determine the retry strategy: // - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrRateLimit: fixed delay retry // - ErrTemporary / unknown: exponential backoff retry -func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { +func (m *Manager) sendWithRetry( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMessage, +) ([]string, bool) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down - return + return nil, false } // Pre-send: stop typing and try to edit placeholder - if m.preSend(ctx, name, msg, w.ch) { - return // placeholder was edited successfully, skip Send + if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { + return msgIDs, true } var lastErr error + var msgIDs []string + sender, hasMessageIDsSender := w.ch.(MessageIDsSender) for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = w.ch.Send(ctx, msg) + msgIDs = nil + if hasMessageIDsSender { + msgIDs, lastErr = sender.SendMessageWithIDs(ctx, msg) + } else { + lastErr = w.ch.Send(ctx, msg) + } if lastErr == nil { - return + return msgIDs, true } // Permanent failures — don't retry @@ -653,7 +690,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, false } } @@ -662,7 +699,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork select { case <-time.After(backoff): case <-ctx.Done(): - return + return nil, false } } @@ -673,6 +710,8 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork "error": lastErr.Error(), "retries": maxRetries, }) + + return nil, false } func dispatchLoop[M any]( @@ -1016,19 +1055,7 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel %s has no active worker", msg.Channel) } - maxLen := 0 - if mlp, ok := w.ch.(MessageLengthProvider); ok { - maxLen = mlp.MaxMessageLength() - } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { - chunkMsg := msg - chunkMsg.Content = chunk - m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) - } - } else { - m.sendWithRetry(ctx, msg.Channel, w, msg) - } + m.deliverOutbound(ctx, msg.Channel, w, msg) return nil } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 7dfec9ebfd..d81b91a111 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -18,6 +18,7 @@ import ( type mockChannel struct { BaseChannel sendFn func(ctx context.Context, msg bus.OutboundMessage) error + sendWithIDsFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, error) sentMessages []bus.OutboundMessage placeholdersSent int editedMessages int @@ -29,6 +30,17 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return m.sendFn(ctx, msg) } +func (m *mockChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + m.sentMessages = append(m.sentMessages, msg) + if m.sendWithIDsFn == nil { + if m.sendFn == nil { + return nil, nil + } + return nil, m.sendFn(ctx, msg) + } + return m.sendWithIDsFn(ctx, msg) +} + func (m *mockChannel) Start(ctx context.Context) error { return nil } func (m *mockChannel) Stop(ctx context.Context) error { return nil } @@ -102,6 +114,114 @@ func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { } } +func TestDeliverOutbound_CallsOnDeliveredWithMessageIDs(t *testing.T) { + m := newTestManager() + ch := &mockChannel{ + sendFn: nil, + sendWithIDsFn: func(_ context.Context, _ bus.OutboundMessage) ([]string, error) { + return []string{"msg-123"}, nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + var deliveredIDs []string + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "1", + Content: "hello", + OnDelivered: func(msgIDs []string) { + deliveredIDs = append([]string(nil), msgIDs...) + }, + } + + m.deliverOutbound(context.Background(), "test", w, msg) + + if len(deliveredIDs) != 1 || deliveredIDs[0] != "msg-123" { + t.Fatalf("expected delivered IDs [msg-123], got %v", deliveredIDs) + } +} + +func TestDeliverOutbound_CallsOnDeliveredWithPlaceholderID(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + t.Fatal("Send should not be called when placeholder edit succeeds") + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + m.RecordPlaceholder("test", "123", "ph-456") + + var deliveredIDs []string + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + OnDelivered: func(msgIDs []string) { + deliveredIDs = append([]string(nil), msgIDs...) + }, + } + + m.deliverOutbound(context.Background(), "test", w, msg) + + if len(deliveredIDs) != 1 || deliveredIDs[0] != "ph-456" { + t.Fatalf("expected delivered IDs [ph-456], got %v", deliveredIDs) + } +} + +func TestDeliverOutbound_CallsOnDeliveredWithAllSplitMessageIDs(t *testing.T) { + m := newTestManager() + callCount := 0 + ch := &mockChannel{ + sendWithIDsFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, error) { + callCount++ + return []string{fmt.Sprintf("id-%d", callCount)}, nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + ch.BaseChannel = *NewBaseChannel("test", nil, nil, nil, WithMaxMessageLength(5)) + + var deliveredIDs []string + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "1", + Content: "hello world", + OnDelivered: func(msgIDs []string) { + deliveredIDs = append([]string(nil), msgIDs...) + }, + } + + m.deliverOutbound(context.Background(), "test", w, msg) + + if len(deliveredIDs) <= 1 { + t.Fatalf("expected multiple delivered IDs for split outbound, got %v", deliveredIDs) + } + if len(deliveredIDs) != callCount { + t.Fatalf("expected %d delivered IDs, got %v", callCount, deliveredIDs) + } + for i, deliveredID := range deliveredIDs { + expected := fmt.Sprintf("id-%d", i+1) + if deliveredID != expected { + t.Fatalf("expected delivered IDs in order, got %v", deliveredIDs) + } + } +} + func TestSendWithRetry_PermanentFailure(t *testing.T) { m := newTestManager() var callCount int @@ -474,11 +594,14 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msgIDs, edited := m.preSend(context.Background(), "test", msg, ch) if !edited { t.Fatal("expected preSend to return true (placeholder edited)") } + if len(msgIDs) != 1 || msgIDs[0] != "456" { + t.Fatalf("expected placeholder IDs [456], got %v", msgIDs) + } if !editCalled { t.Fatal("expected EditMessage to be called") } @@ -504,7 +627,7 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false when edit fails") @@ -563,7 +686,7 @@ func TestPreSend_TypingStopCalled(t *testing.T) { }) msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - m.preSend(context.Background(), "test", msg, ch) + _, _ = m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop func to be called") @@ -580,7 +703,7 @@ func TestPreSend_NoRegisteredState(t *testing.T) { } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false with no registered state") @@ -610,7 +733,7 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + msgIDs, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called") @@ -621,6 +744,9 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) { if !edited { t.Fatal("expected preSend to return true") } + if len(msgIDs) != 1 || msgIDs[0] != "456" { + t.Fatalf("expected placeholder IDs [456], got %v", msgIDs) + } } func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) { @@ -871,7 +997,7 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { m.RecordPlaceholder("test", "chat1", "ph_id") msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} - edited := m.preSend(context.Background(), "test", msg, ch) + msgIDs, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called via wrapped type") @@ -882,6 +1008,9 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { if !edited { t.Fatal("expected preSend to return true") } + if len(msgIDs) != 1 || msgIDs[0] != "ph_id" { + t.Fatalf("expected placeholder IDs [ph_id], got %v", msgIDs) + } } // --- Lazy worker creation tests (Step 6) --- diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index cd66964dd6..8dc44e636c 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -201,8 +201,14 @@ func (c *QQChannel) getChatKind(chatID string) string { } func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + _, err := c.SendMessageWithIDs(ctx, msg) + return err +} + +// SendMessageWithIDs implements channels.MessageIDsSender. +func (c *QQChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) @@ -236,11 +242,14 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C. - var err error + var ( + sentMsg *dto.Message + err error + ) if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { @@ -249,10 +258,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_kind": chatKind, "error": err.Error(), }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) } - return nil + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index f03283ea41..5e2cecec0d 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -109,13 +109,19 @@ func (c *SlackChannel) Stop(ctx context.Context) error { } func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + _, err := c.SendMessageWithIDs(ctx, msg) + return err +} + +// SendMessageWithIDs implements channels.MessageIDsSender. +func (c *SlackChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ @@ -130,9 +136,9 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error opts = append(opts, slack.MsgOptionTS(threadTS)) } - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("slack send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { @@ -148,7 +154,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "thread_ts": threadTS, }) - return nil + return []string{ts}, nil } // SendMedia implements the channels.MediaSender interface. diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f62d6d008b..0b89596a9a 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -169,19 +169,25 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { } func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + _, err := c.SendMessageWithIDs(ctx, msg) + return err +} + +// SendMessageWithIDs implements channels.MessageIDsSender. +func (c *TelegramChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } if msg.Content == "" { - return nil + return nil, nil } // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), @@ -189,6 +195,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID queue := []string{msg.Content} + var messageIDs []string for len(queue) > 0 { chunk := queue[0] queue = queue[1:] @@ -206,16 +213,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } if smallerLen <= 0 { - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) replyToID = "" continue } @@ -244,21 +253,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) // Only the first chunk should be a reply; subsequent chunks are normal messages. replyToID = "" } - return nil + return messageIDs, nil } type sendChunkParams struct { @@ -275,7 +286,7 @@ type sendChunkParams struct { func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, -) error { +) (string, error) { tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg.MessageThreadID = params.threadID if params.useMarkdownV2 { @@ -292,17 +303,19 @@ func (c *TelegramChannel) sendChunk( } } - if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + msg, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { logParseFailed(err, params.useMarkdownV2) tgMsg.Text = params.mdFallback tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + msg, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } - return nil + return strconv.Itoa(msg.MessageID), nil } // maxTypingDuration limits how long the typing indicator can run. @@ -537,6 +550,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes CanonicalID: identity.BuildCanonicalID("telegram", platformID), Username: user.Username, DisplayName: user.FirstName, + FirstName: user.FirstName, + LastName: user.LastName, } // check allowlist to avoid downloading attachments for rejected users diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 6bf1077afd..09f71b33ce 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -98,7 +98,12 @@ func (s *multipartRecordingConstructor) MultipartRequest( // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: 1} + return successResponseWithMessageID(t, 1) +} + +func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { + t.Helper() + msg := &telego.Message{MessageID: messageID} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} @@ -280,6 +285,27 @@ func TestSend_LongMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call") } +func TestSendMessageWithIDs_ReturnsAllChunkIDsAfterHTMLResplit(t *testing.T) { + caller := &stubCaller{} + caller.callFn = func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponseWithMessageID(t, len(caller.calls)), nil + } + ch := newTestChannel(t, caller) + + chunk := "[x](https://example.com/" + strings.Repeat("a", 20) + ") " + content := strings.Repeat(chunk, 120) + + ids, err := ch.SendMessageWithIDs(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: content, + }) + + require.NoError(t, err) + require.Len(t, ids, 2) + assert.Equal(t, []string{"1", "2"}, ids) + assert.Len(t, caller.calls, 2) +} + func TestSend_HTMLFallback_PerChunk(t *testing.T) { callCount := 0 caller := &stubCaller{ diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6fd..331acac71b 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -62,6 +62,15 @@ type ContentBlock struct { CacheControl *CacheControl `json:"cache_control,omitempty"` } +// MessageSender carries author identity for a user message. +// Stored alongside the message in history so the LLM can address +// participants by name in multi-user conversations. +type MessageSender struct { + Username string `json:"username,omitempty"` // e.g. "@alice" (platform handle) + FirstName string `json:"first_name,omitempty"` // given name + LastName string `json:"last_name,omitempty"` // family name +} + type Message struct { Role string `json:"role"` Content string `json:"content"` @@ -70,6 +79,9 @@ type Message struct { SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + MessageIDs []string `json:"message_ids,omitempty"` // Platform message IDs + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` // Parent message ID (for threading) + Sender *MessageSender `json:"sender,omitempty"` // Author identity (user messages only) } type ToolDefinition struct { diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 9a4d126a7e..80b0938e2d 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -19,6 +19,7 @@ type ( GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl + MessageSender = protocoltypes.MessageSender ) type LLMProvider interface { From b2a9f07a85d8ad0a72b909a4975f53c119f17efd Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:07:08 +0200 Subject: [PATCH 02/11] Fix lint warning in steering delivery flow --- pkg/agent/loop.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ad167878ff..9c7fcbc8f8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -551,7 +551,6 @@ func (al *AgentLoop) Run(ctx context.Context) error { if al.pendingSteeringCountForScope(target.SessionKey) > 0 && !responsePersisted && response.Content != "" && response.OnDelivered != nil { response.OnDelivered(nil) - responsePersisted = true } for al.pendingSteeringCountForScope(target.SessionKey) > 0 { From 5ad62779beabd0708ccde0c58e59da4fc87221fa Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:25:12 +0200 Subject: [PATCH 03/11] Fix message sender, threading, and continuation delivery --- pkg/agent/context.go | 65 ++++++++++++++++++-- pkg/agent/context_test.go | 32 ++++++++++ pkg/agent/loop.go | 57 +++++++---------- pkg/agent/loop_test.go | 53 ++++++++++++++++ pkg/agent/steering.go | 69 ++++++++++++--------- pkg/agent/steering_test.go | 123 +++++++++++++++++++++++++++++++++++++ pkg/bus/types.go | 23 +++---- pkg/channels/README.md | 6 +- pkg/channels/README.zh.md | 6 +- pkg/channels/base.go | 25 +++++--- pkg/channels/base_test.go | 30 +++++++++ 11 files changed, 394 insertions(+), 95 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index bad93c6508..5d4e38e1c3 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -597,7 +597,7 @@ func (cb *ContextBuilder) BuildMessages( // so the LLM can navigate thread structure from persisted sessions. for _, msg := range history { annotated := msg - if prefix := messageThreadAnnotation(msg); prefix != "" { + if prefix := messageHistoryAnnotation(msg); prefix != "" { annotated.Content = prefix + msg.Content } messages = append(messages, annotated) @@ -851,9 +851,62 @@ func (cb *ContextBuilder) GetSkillsInfo() map[string]any { } } +func messageHistoryAnnotation(msg providers.Message) string { + parts := make([]string, 0, 2) + if sender := messageSenderAnnotation(msg.Sender); sender != "" { + parts = append(parts, sender) + } + if thread := messageThreadAnnotationBody(msg); thread != "" { + parts = append(parts, thread) + } + if len(parts) == 0 { + return "" + } + return fmt.Sprintf("[%s] ", strings.Join(parts, ", ")) +} + +func messageSenderAnnotation(sender *providers.MessageSender) string { + if sender == nil { + return "" + } + + nameParts := make([]string, 0, 2) + if first := strings.TrimSpace(sender.FirstName); first != "" { + nameParts = append(nameParts, first) + } + if last := strings.TrimSpace(sender.LastName); last != "" { + nameParts = append(nameParts, last) + } + name := strings.TrimSpace(strings.Join(nameParts, " ")) + + username := strings.TrimSpace(sender.Username) + if username != "" && !strings.HasPrefix(username, "@") { + username = "@" + username + } + + switch { + case name != "" && username != "": + return fmt.Sprintf("from:%s (%s)", name, username) + case name != "": + return fmt.Sprintf("from:%s", name) + case username != "": + return fmt.Sprintf("from:%s", username) + default: + return "" + } +} + // messageThreadAnnotation returns the thread annotation prefix for a message, // e.g. "[msg:#5, reply_to:#3] " or "" if the message has no threading IDs. func messageThreadAnnotation(msg providers.Message) string { + body := messageThreadAnnotationBody(msg) + if body == "" { + return "" + } + return fmt.Sprintf("[%s] ", body) +} + +func messageThreadAnnotationBody(msg providers.Message) string { msgIDs := msg.MessageIDs formattedIDs := strings.Join(msgIDs, ",#") if formattedIDs != "" { @@ -861,15 +914,15 @@ func messageThreadAnnotation(msg providers.Message) string { } switch { case len(msgIDs) > 1 && msg.ReplyToMessageID != "": - return fmt.Sprintf("[msgs:%s, reply_to:#%s] ", formattedIDs, msg.ReplyToMessageID) + return fmt.Sprintf("msgs:%s, reply_to:#%s", formattedIDs, msg.ReplyToMessageID) case len(msgIDs) > 1: - return fmt.Sprintf("[msgs:%s] ", formattedIDs) + return fmt.Sprintf("msgs:%s", formattedIDs) case len(msgIDs) == 1 && msg.ReplyToMessageID != "": - return fmt.Sprintf("[msg:%s, reply_to:#%s] ", formattedIDs, msg.ReplyToMessageID) + return fmt.Sprintf("msg:%s, reply_to:#%s", formattedIDs, msg.ReplyToMessageID) case len(msgIDs) == 1: - return fmt.Sprintf("[msg:%s] ", formattedIDs) + return fmt.Sprintf("msg:%s", formattedIDs) case msg.ReplyToMessageID != "": - return fmt.Sprintf("[reply_to:#%s] ", msg.ReplyToMessageID) + return fmt.Sprintf("reply_to:#%s", msg.ReplyToMessageID) default: return "" } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 0d7948eefe..6a6ec24b2c 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -213,6 +213,38 @@ func TestSanitizeHistoryForProvider_DuplicateToolResults(t *testing.T) { } } +func TestMessageHistoryAnnotation_IncludesSenderAndThreading(t *testing.T) { + msg := providers.Message{ + Role: "user", + Content: "hello", + MessageIDs: []string{"m1"}, + ReplyToMessageID: "p0", + Sender: &providers.MessageSender{ + Username: "alice", + FirstName: "Alice", + LastName: "Example", + }, + } + + if got := messageHistoryAnnotation(msg); got != "[from:Alice Example (@alice), msg:#m1, reply_to:#p0] " { + t.Fatalf("messageHistoryAnnotation() = %q", got) + } +} + +func TestMessageHistoryAnnotation_UsesUsernameWhenNameMissing(t *testing.T) { + msg := providers.Message{ + Role: "user", + Content: "hello", + Sender: &providers.MessageSender{ + Username: "alice", + }, + } + + if got := messageHistoryAnnotation(msg); got != "[from:@alice] " { + t.Fatalf("messageHistoryAnnotation() = %q", got) + } +} + func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) for i, m := range msgs { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 9c7fcbc8f8..5071970d09 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -492,8 +492,6 @@ func (al *AgentLoop) Run(ctx context.Context) error { ChatID: msg.ChatID, } } - finalResponse := response.Content - target, targetErr := al.buildContinuationTarget(msg) if targetErr != nil { logger.WarnCF("agent", "Failed to build steering continuation target", @@ -511,12 +509,8 @@ func (al *AgentLoop) Run(ctx context.Context) error { return } - responsePersisted := false - continuedOnce := false - if al.pendingSteeringCountForScope(target.SessionKey) > 0 && - response.Content != "" && response.OnDelivered != nil { - response.OnDelivered(nil) - responsePersisted = true + if response.Content != "" { + al.publishAgentResponseIfNeeded(ctx, response, target.Channel, target.ChatID) } for al.pendingSteeringCountForScope(target.SessionKey) > 0 { @@ -528,7 +522,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + continued, continueErr := al.continueResponse(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { logger.WarnCF("agent", "Failed to continue queued steering", map[string]any{ @@ -538,21 +532,14 @@ func (al *AgentLoop) Run(ctx context.Context) error { }) return } - if continued == "" { + if continued.Content == "" { return } - - finalResponse = continued - continuedOnce = true + al.publishAgentResponseIfNeeded(ctx, continued, target.Channel, target.ChatID) } cancelDrain() - if al.pendingSteeringCountForScope(target.SessionKey) > 0 && - !responsePersisted && response.Content != "" && response.OnDelivered != nil { - response.OnDelivered(nil) - } - for al.pendingSteeringCountForScope(target.SessionKey) > 0 { logger.InfoCF("agent", "Draining steering queued during turn shutdown", map[string]any{ @@ -562,7 +549,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { "queue_depth": al.pendingSteeringCountForScope(target.SessionKey), }) - continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID) + continued, continueErr := al.continueResponse(ctx, target.SessionKey, target.Channel, target.ChatID) if continueErr != nil { logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain", map[string]any{ @@ -572,20 +559,10 @@ func (al *AgentLoop) Run(ctx context.Context) error { }) return } - if continued == "" { + if continued.Content == "" { break } - - finalResponse = continued - continuedOnce = true - } - - if finalResponse != "" { - if continuedOnce { - al.publishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse) - } else { - al.publishAgentResponseIfNeeded(ctx, response, target.Channel, target.ChatID) - } + al.publishAgentResponseIfNeeded(ctx, continued, target.Channel, target.ChatID) } }() default: @@ -653,10 +630,17 @@ func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, active "scope": activeScope, }) + replyToMessageID := msg.ReplyToMessageID + if replyToMessageID == "" { + replyToMessageID = inboundMetadata(msg, metadataKeyReplyToMessage) + } if err := al.enqueueSteeringMessage(activeScope, activeAgentID, providers.Message{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + MessageIDs: singleMessageIDs(msg.MessageID), + ReplyToMessageID: replyToMessageID, + Sender: messageSenderFromInbound(msg.Sender), }); err != nil { logger.WarnCF("agent", "Failed to steer message, will be lost", map[string]any{ @@ -1445,9 +1429,12 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) EnableSummary: true, SendResponse: false, MessageID: msg.MessageID, - ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage), + ReplyToMessageID: msg.ReplyToMessageID, Sender: messageSenderFromInbound(msg.Sender), } + if opts.ReplyToMessageID == "" { + opts.ReplyToMessageID = inboundMetadata(msg, metadataKeyReplyToMessage) + } // context-dependent commands check their own Runtime fields and report // "unavailable" when the required capability is nil. diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 84aaab50b6..4d444f7da6 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -351,6 +351,59 @@ func TestProcessMessage_AssistantSavedOnDelivered(t *testing.T) { } } +func TestProcessMessage_SavesReplyToMessageIDFromInboundField(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &recordingProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + sessionKey := "agent:test-reply-to" + _, err = al.processMessage(context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:123", + ChatID: "chat-1", + Content: "hello", + SessionKey: sessionKey, + MessageID: "in-42", + ReplyToMessageID: "in-41", + }) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 1 { + t.Fatalf("expected only user message before delivery, got %d", len(history)) + } + if len(history[0].MessageIDs) != 1 || history[0].MessageIDs[0] != "in-42" { + t.Fatalf("expected user message_ids [in-42], got %v", history[0].MessageIDs) + } + if history[0].ReplyToMessageID != "in-41" { + t.Fatalf("expected ReplyToMessageID in-41, got %q", history[0].ReplyToMessageID) + } +} + func TestRecordLastChannel(t *testing.T) { al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) defer cleanup() diff --git a/pkg/agent/steering.go b/pkg/agent/steering.go index 3f95a6d747..d3b08f91a6 100644 --- a/pkg/agent/steering.go +++ b/pkg/agent/steering.go @@ -291,7 +291,7 @@ func (al *AgentLoop) continueWithSteeringMessages( agent *AgentInstance, sessionKey, channel, chatID string, steeringMsgs []providers.Message, -) (string, error) { +) (agentResponse, error) { response, err := al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: channel, @@ -303,12 +303,42 @@ func (al *AgentLoop) continueWithSteeringMessages( SkipInitialSteeringPoll: true, }) if err != nil { - return "", err + return agentResponse{}, err } - if response.OnDelivered != nil { - response.OnDelivered(nil) + return response, nil +} + +func (al *AgentLoop) continueResponse( + ctx context.Context, + sessionKey, channel, chatID string, +) (agentResponse, error) { + if active := al.GetActiveTurn(); active != nil { + return agentResponse{}, fmt.Errorf("turn %s is still active", active.TurnID) } - return response.Content, nil + if err := al.ensureHooksInitialized(ctx); err != nil { + return agentResponse{}, err + } + if err := al.ensureMCPInitialized(ctx); err != nil { + return agentResponse{}, err + } + + steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey) + if len(steeringMsgs) == 0 { + return agentResponse{}, nil + } + + agent := al.agentForSession(sessionKey) + if agent == nil { + return agentResponse{}, fmt.Errorf("no agent available for session %q", sessionKey) + } + + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { + resetter.ResetSentInRound() + } + } + + return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs) } func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { @@ -333,33 +363,14 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance { // // If no steering messages are pending, it returns an empty string. func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) { - if active := al.GetActiveTurn(); active != nil { - return "", fmt.Errorf("turn %s is still active", active.TurnID) - } - if err := al.ensureHooksInitialized(ctx); err != nil { - return "", err - } - if err := al.ensureMCPInitialized(ctx); err != nil { + response, err := al.continueResponse(ctx, sessionKey, channel, chatID) + if err != nil { return "", err } - - steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey) - if len(steeringMsgs) == 0 { - return "", nil - } - - agent := al.agentForSession(sessionKey) - if agent == nil { - return "", fmt.Errorf("no agent available for session %q", sessionKey) - } - - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() - } + if response.OnDelivered != nil { + response.OnDelivered(nil) } - - return al.continueWithSteeringMessages(ctx, agent, sessionKey, channel, chatID, steeringMsgs) + return response.Content, nil } func (al *AgentLoop) InterruptGraceful(hint string) error { diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 75ba9861df..71fe51dd6b 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -431,6 +431,129 @@ func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) { } } +func TestDrainBusToSteering_PreservesSenderAndThreadingMetadata(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + DMScope: "per-peer", + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &mockProvider{}) + + activeMsg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "telegram:100", + Sender: bus.SenderInfo{DisplayName: "Alice", Username: "alice"}, + ChatID: "chat1", + Content: "follow up", + MessageID: "in-2", + ReplyToMessageID: "in-1", + Peer: bus.Peer{ + Kind: "direct", + ID: "100", + }, + } + activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg) + if !ok { + t.Fatal("expected active message to resolve to a steering scope") + } + + if err := msgBus.PublishInbound(context.Background(), activeMsg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + al.drainBusToSteering(ctx, activeScope, activeAgentID) + + msgs := al.dequeueSteeringMessagesForScope(activeScope) + if len(msgs) != 1 { + t.Fatalf("expected 1 steering message, got %d", len(msgs)) + } + if len(msgs[0].MessageIDs) != 1 || msgs[0].MessageIDs[0] != "in-2" { + t.Fatalf("expected steering message_ids [in-2], got %v", msgs[0].MessageIDs) + } + if msgs[0].ReplyToMessageID != "in-1" { + t.Fatalf("expected ReplyToMessageID in-1, got %q", msgs[0].ReplyToMessageID) + } + if msgs[0].Sender == nil || msgs[0].Sender.FirstName != "Alice" || msgs[0].Sender.Username != "alice" { + t.Fatalf("expected sender to be preserved, got %+v", msgs[0].Sender) + } +} + +func TestContinueWithSteeringMessages_ReturnsTrackedAssistantDelivery(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "continued response"}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + sessionKey := "agent:test-continue-delivery" + response, err := al.continueWithSteeringMessages(context.Background(), defaultAgent, sessionKey, "test", "chat1", []providers.Message{ + {Role: "user", Content: "new direction"}, + }) + if err != nil { + t.Fatalf("continueWithSteeringMessages failed: %v", err) + } + if response.Content != "continued response" { + t.Fatalf("expected continued response, got %q", response.Content) + } + if response.OnDelivered == nil { + t.Fatal("expected OnDelivered callback for continued response") + } + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 1 || history[0].Role != "user" { + t.Fatalf("expected only steering user message before delivery, got %#v", history) + } + + response.OnDelivered([]string{"out-continue"}) + + history = defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 2 { + t.Fatalf("expected 2 messages after delivery, got %d", len(history)) + } + if history[1].Role != "assistant" { + t.Fatalf("expected assistant message, got %+v", history[1]) + } + if len(history[1].MessageIDs) != 1 || history[1].MessageIDs[0] != "out-continue" { + t.Fatalf("expected assistant message_ids [out-continue], got %v", history[1].MessageIDs) + } +} + // slowTool simulates a tool that takes some time to execute. type slowTool struct { name string diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 0366e89dfe..30746615cb 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -18,17 +18,18 @@ type SenderInfo struct { } type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - Sender SenderInfo `json:"sender"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + Sender SenderInfo `json:"sender"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + Peer Peer `json:"peer"` // routing peer + MessageID string `json:"message_id,omitempty"` // platform message ID + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` // parent platform message ID + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata,omitempty"` } type OutboundMessage struct { diff --git a/pkg/channels/README.md b/pkg/channels/README.md index b7c56660bf..3914e93b03 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -881,6 +881,7 @@ type InboundMessage struct { Media []string // Media reference list (media://...) Peer Peer // Routing peer (first-class field) MessageID string // Platform message ID (first-class field) + ReplyToMessageID string // Parent platform message ID (first-class field) MediaScope string // Media lifecycle scope SessionKey string // Session key Metadata map[string]string // Only for channel-specific extensions @@ -1191,10 +1192,11 @@ Timeout configuration: ReadTimeout = 30s, WriteTimeout = 30s **Do NOT put the following information in Metadata anymore**: - `peer_kind` / `peer_id` → Use `InboundMessage.Peer` - `message_id` → Use `InboundMessage.MessageID` +- `reply_to_message_id` → Use `InboundMessage.ReplyToMessageID` - `sender_platform` / `sender_username` → Use `InboundMessage.Sender` **Metadata should only be used for**: -- Channel-specific extension information (e.g., Telegram's `reply_to_message_id`) +- Channel-specific extension information that has no structured field yet - Temporary information that doesn't fit into structured fields ### 5.3 Concurrency Safety Conventions @@ -1381,4 +1383,4 @@ agentLoop.Stop() // Stop Agent 7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. -8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. \ No newline at end of file +8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 2c5e7356ee..ead0f5c4e7 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -880,6 +880,7 @@ type InboundMessage struct { Media []string // 媒体引用列表(media://...) Peer Peer // 路由对等体(一等字段) MessageID string // 平台消息 ID(一等字段) + ReplyToMessageID string // 父消息 ID(一等字段) MediaScope string // 媒体生命周期作用域 SessionKey string // 会话键 Metadata map[string]string // 仅用于 channel 特有扩展 @@ -1190,10 +1191,11 @@ Manager 创建单一 `http.Server`,自动发现和注册: **不要再把以下信息放入 Metadata**: - `peer_kind` / `peer_id` → 使用 `InboundMessage.Peer` - `message_id` → 使用 `InboundMessage.MessageID` +- `reply_to_message_id` → 使用 `InboundMessage.ReplyToMessageID` - `sender_platform` / `sender_username` → 使用 `InboundMessage.Sender` **Metadata 仅用于**: -- Channel 特有的扩展信息(如 Telegram 的 `reply_to_message_id`) +- 尚未有结构化字段承载的 Channel 特有扩展信息 - 不适合放入结构化字段的临时信息 ### 5.3 并发安全约定 @@ -1380,4 +1382,4 @@ agentLoop.Stop() // 停止 Agent 7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 -8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 \ No newline at end of file +8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 882e72d089..f50e2abefd 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -259,18 +259,23 @@ func (c *BaseChannel) HandleMessage( } scope := BuildMediaScope(c.name, chatID, messageID) + replyToMessageID := "" + if metadata != nil { + replyToMessageID = metadata["reply_to_message_id"] + } msg := bus.InboundMessage{ - Channel: c.name, - SenderID: resolvedSenderID, - Sender: sender, - ChatID: chatID, - Content: content, - Media: media, - Peer: peer, - MessageID: messageID, - MediaScope: scope, - Metadata: metadata, + Channel: c.name, + SenderID: resolvedSenderID, + Sender: sender, + ChatID: chatID, + Content: content, + Media: media, + Peer: peer, + MessageID: messageID, + ReplyToMessageID: replyToMessageID, + MediaScope: scope, + Metadata: metadata, } // Auto-trigger typing indicator, message reaction, and placeholder before publishing. diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 6132b8bf9f..3f462704a0 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -1,6 +1,7 @@ package channels import ( + "context" "testing" "github.com/sipeed/picoclaw/pkg/bus" @@ -263,3 +264,32 @@ func TestIsAllowedSender(t *testing.T) { }) } } + +func TestBaseChannelHandleMessage_PopulatesReplyToMessageID(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + ch := NewBaseChannel("test", nil, msgBus, nil) + ch.HandleMessage( + context.Background(), + bus.Peer{Kind: "direct", ID: "user1"}, + "msg-2", + "user1", + "chat1", + "hello", + nil, + map[string]string{"reply_to_message_id": "msg-1"}, + ) + + select { + case got := <-msgBus.InboundChan(): + if got.MessageID != "msg-2" { + t.Fatalf("expected MessageID msg-2, got %q", got.MessageID) + } + if got.ReplyToMessageID != "msg-1" { + t.Fatalf("expected ReplyToMessageID msg-1, got %q", got.ReplyToMessageID) + } + case <-context.Background().Done(): + t.Fatal("expected inbound message") + } +} From 7b6328f25767d5331a1d4a12dccba010d73175d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:31:26 +0200 Subject: [PATCH 04/11] Fix Telegram inbound reply threading --- pkg/agent/loop.go | 4 ++- pkg/channels/telegram/telegram.go | 3 +++ pkg/channels/telegram/telegram_test.go | 34 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5071970d09..ee95a5cc58 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1574,7 +1574,9 @@ func (al *AgentLoop) processSystemMessage( UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content), DefaultResponse: "Background task completed.", EnableSummary: false, - SendResponse: true, + // System messages are synthetic inbound events, so there is no delivery + // callback chain from a channel send to feed assistant message IDs back. + SendResponse: true, }) } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0b89596a9a..835a9c27b9 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -694,6 +694,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + if message.ReplyToMessage != nil { + metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) + } // Set parent_peer metadata for per-topic agent binding. if message.Chat.IsForum && threadID != 0 { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 09f71b33ce..27f5599289 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -667,3 +667,37 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { assert.Empty(t, inbound.Metadata["parent_peer_kind"]) assert.Empty(t, inbound.Metadata["parent_peer_id"]) } + +func TestHandleMessage_ReplyToMessageID_Preserved(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "reply in group", + MessageID: 30, + Chat: telego.Chat{ + ID: -100999, + Type: "supergroup", + }, + From: &telego.User{ + ID: 10, + FirstName: "Dana", + }, + ReplyToMessage: &telego.Message{ + MessageID: 25, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "30", inbound.MessageID) + assert.Equal(t, "25", inbound.ReplyToMessageID) + assert.Equal(t, "25", inbound.Metadata["reply_to_message_id"]) +} From ee8d2b058acad614eff284613f7e00b2658330ff Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:38:42 +0200 Subject: [PATCH 05/11] Simplify thread annotation message ID format --- pkg/agent/context.go | 8 ++------ pkg/agent/context_test.go | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5d4e38e1c3..f6f9cf049e 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -913,14 +913,10 @@ func messageThreadAnnotationBody(msg providers.Message) string { formattedIDs = "#" + formattedIDs } switch { - case len(msgIDs) > 1 && msg.ReplyToMessageID != "": + case len(msgIDs) > 0 && msg.ReplyToMessageID != "": return fmt.Sprintf("msgs:%s, reply_to:#%s", formattedIDs, msg.ReplyToMessageID) - case len(msgIDs) > 1: + case len(msgIDs) > 0: return fmt.Sprintf("msgs:%s", formattedIDs) - case len(msgIDs) == 1 && msg.ReplyToMessageID != "": - return fmt.Sprintf("msg:%s, reply_to:#%s", formattedIDs, msg.ReplyToMessageID) - case len(msgIDs) == 1: - return fmt.Sprintf("msg:%s", formattedIDs) case msg.ReplyToMessageID != "": return fmt.Sprintf("reply_to:#%s", msg.ReplyToMessageID) default: diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 6a6ec24b2c..08cec97b85 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -226,7 +226,7 @@ func TestMessageHistoryAnnotation_IncludesSenderAndThreading(t *testing.T) { }, } - if got := messageHistoryAnnotation(msg); got != "[from:Alice Example (@alice), msg:#m1, reply_to:#p0] " { + if got := messageHistoryAnnotation(msg); got != "[from:Alice Example (@alice), msgs:#m1, reply_to:#p0] " { t.Fatalf("messageHistoryAnnotation() = %q", got) } } From 9f33ccea2d7b1712fb56b5aadec52f88b5f0fbad Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:44:50 +0200 Subject: [PATCH 06/11] Align bus message struct formatting with upstream --- pkg/bus/types.go | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 30746615cb..5e1abb39f0 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -18,24 +18,26 @@ type SenderInfo struct { } type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - Sender SenderInfo `json:"sender"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` // parent platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + Sender SenderInfo `json:"sender"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + Peer Peer `json:"peer"` // routing peer + MessageID string `json:"message_id,omitempty"` // platform message ID + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata,omitempty"` + + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` // parent platform message ID } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` OnDelivered func(msgIDs []string) `json:"-"` } From 1ba8931b27518abcd9251074e0ecb71b03185c79 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:53:54 +0200 Subject: [PATCH 07/11] Preserve inbound reply IDs across channels --- pkg/channels/discord/discord.go | 3 ++ pkg/channels/discord/discord_test.go | 44 ++++++++++++++++++++++++++++ pkg/channels/matrix/matrix.go | 1 + pkg/channels/matrix/matrix_test.go | 44 ++++++++++++++++++++++++++++ pkg/channels/slack/slack.go | 6 ++++ pkg/channels/slack/slack_test.go | 30 +++++++++++++++++++ 6 files changed, 128 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 924bcad89b..573dc47e3a 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -481,6 +481,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + if m.MessageReference != nil && m.MessageReference.MessageID != "" { + metadata["reply_to_message_id"] = m.MessageReference.MessageID + } c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f40..85a4dddd3f 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,11 +1,15 @@ package discord import ( + "context" "net/http" "net/url" "testing" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) func TestApplyDiscordProxy_CustomProxy(t *testing.T) { @@ -89,3 +93,43 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestHandleMessage_PreservesReplyToMessageID(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, messageBus, nil), + ctx: context.Background(), + } + + session := &discordgo.Session{ + State: &discordgo.State{ + Ready: discordgo.Ready{ + User: &discordgo.User{ID: "bot"}, + }, + }, + } + + ch.handleMessage(session, &discordgo.MessageCreate{ + Message: &discordgo.Message{ + ID: "msg-2", + ChannelID: "chan-1", + Content: "hello", + Author: &discordgo.User{ + ID: "user-1", + Username: "alice", + }, + MessageReference: &discordgo.MessageReference{ + MessageID: "msg-1", + ChannelID: "chan-1", + }, + }, + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "msg-2" { + t.Fatalf("expected MessageID msg-2, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "msg-1" { + t.Fatalf("expected ReplyToMessageID msg-1, got %q", inbound.ReplyToMessageID) + } +} diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 98c607d0be..01e30f387c 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -626,6 +626,7 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event "sender_raw": senderID, } if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" { + metadata["reply_to_message_id"] = replyTo.String() metadata["reply_to_msg_id"] = replyTo.String() } diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 7484c8d876..43b62eeb39 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,8 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -386,3 +388,45 @@ func TestMessageContent(t *testing.T) { t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) } } + +func TestHandleMessageEvent_PreservesReplyToMessageID(t *testing.T) { + messageBus := bus.NewMessageBus() + roomID := id.RoomID("!room:matrix.test") + ch := &MatrixChannel{ + BaseChannel: channels.NewBaseChannel("matrix", nil, messageBus, nil), + client: &mautrix.Client{UserID: id.UserID("@bot:matrix.test")}, + startTime: time.Unix(0, 0), + roomKindCache: newRoomKindCache(4, time.Minute), + } + ch.roomKindCache.set(roomID.String(), false, time.Now()) + + msgContent := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "hello", + RelatesTo: &event.RelatesTo{ + InReplyTo: &event.InReplyTo{EventID: id.EventID("$parent")}, + }, + } + + ch.handleMessageEvent(context.Background(), &event.Event{ + Sender: id.UserID("@alice:matrix.test"), + Type: event.EventMessage, + Timestamp: time.Now().UnixMilli(), + ID: id.EventID("$event"), + RoomID: roomID, + Content: event.Content{ + Parsed: msgContent, + }, + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "$event" { + t.Fatalf("expected MessageID $event, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "$parent" { + t.Fatalf("expected ReplyToMessageID $parent, got %q", inbound.ReplyToMessageID) + } + if inbound.Metadata["reply_to_message_id"] != "$parent" { + t.Fatalf("expected metadata reply_to_message_id $parent, got %q", inbound.Metadata["reply_to_message_id"]) + } +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 5e2cecec0d..00061ced4c 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -375,6 +375,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "platform": "slack", "team_id": c.teamID, } + if threadTS != "" && threadTS != messageTS { + metadata["reply_to_message_id"] = threadTS + } logger.DebugCF("slack", "Received message", map[string]any{ "sender_id": senderID, @@ -447,6 +450,9 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { "is_mention": "true", "team_id": c.teamID, } + if threadTS != "" && threadTS != messageTS { + metadata["reply_to_message_id"] = threadTS + } c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) } diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index 23a7ee5c41..abeb27b3d0 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -1,9 +1,13 @@ package slack import ( + "context" "testing" + "github.com/slack-go/slack/slackevents" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) @@ -168,3 +172,29 @@ func TestSlackChannelIsAllowed(t *testing.T) { } }) } + +func TestHandleMessageEvent_PreservesReplyToMessageIDFromThreadTS(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &SlackChannel{ + BaseChannel: channels.NewBaseChannel("slack", nil, messageBus, nil), + ctx: context.Background(), + teamID: "T1", + } + + ch.handleMessageEvent(&slackevents.MessageEvent{ + Type: "message", + User: "U123", + Text: "hello", + ThreadTimeStamp: "1710000000.000100", + TimeStamp: "1710000000.000200", + Channel: "C123", + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "1710000000.000200" { + t.Fatalf("expected MessageID to be message ts, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "1710000000.000100" { + t.Fatalf("expected ReplyToMessageID thread root ts, got %q", inbound.ReplyToMessageID) + } +} From a95988a599519a23822dad1abcf444575cc698b8 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 11:07:42 +0200 Subject: [PATCH 08/11] refactor(channels): Send returns ([]string, error), remove MessageIDsSender Replace the optional MessageIDsSender interface with a direct return value on Send. Channels that support delivery IDs (Telegram, Discord, Slack, QQ) return them from Send; all others return nil. Manager.sendWithRetry drops the type-assertion branch and calls Send uniformly. --- pkg/agent/loop_test.go | 4 +- pkg/channels/README.md | 48 +++++++++++-------- pkg/channels/README.zh.md | 48 +++++++++++-------- pkg/channels/base.go | 2 +- pkg/channels/dingtalk/dingtalk.go | 10 ++-- pkg/channels/discord/discord.go | 8 +--- pkg/channels/feishu/feishu_32.go | 4 +- pkg/channels/feishu/feishu_64.go | 16 +++---- pkg/channels/interfaces.go | 6 --- pkg/channels/irc/irc.go | 10 ++-- pkg/channels/line/line.go | 8 ++-- pkg/channels/maixcam/maixcam.go | 12 ++--- pkg/channels/manager.go | 11 ++--- pkg/channels/manager_test.go | 19 +++----- pkg/channels/matrix/matrix.go | 12 ++--- pkg/channels/onebot/onebot.go | 16 +++---- pkg/channels/pico/client.go | 8 ++-- pkg/channels/pico/client_test.go | 8 ++-- pkg/channels/pico/pico.go | 6 +-- pkg/channels/qq/qq.go | 8 +--- pkg/channels/slack/slack.go | 8 +--- pkg/channels/telegram/telegram.go | 8 +--- pkg/channels/telegram/telegram_test.go | 24 +++++----- pkg/channels/wecom/aibot.go | 16 +++---- pkg/channels/wecom/aibot_ws.go | 22 ++++----- pkg/channels/wecom/app.go | 8 ++-- pkg/channels/wecom/bot.go | 6 +-- pkg/channels/weixin/weixin.go | 16 +++---- pkg/channels/whatsapp/whatsapp.go | 14 +++--- .../whatsapp_native/whatsapp_native.go | 16 +++---- 30 files changed, 188 insertions(+), 214 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4d444f7da6..eea16cf0f2 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -27,7 +27,9 @@ type fakeChannel struct{ id string } func (f *fakeChannel) Name() string { return "fake" } func (f *fakeChannel) Start(ctx context.Context) error { return nil } func (f *fakeChannel) Stop(ctx context.Context) error { return nil } -func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, nil +} func (f *fakeChannel) IsRunning() bool { return true } func (f *fakeChannel) IsAllowed(string) bool { return true } func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 3914e93b03..99d9c76421 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -253,27 +253,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { ```go // Old code: returns plain error -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.running { return fmt.Errorf("not running") } +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.running { return nil, fmt.Errorf("not running") } // ... - if err != nil { return err } + if err != nil { return nil, err } } // New code: must return sentinel errors for Manager to determine retry strategy -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager will not retry + return nil, channels.ErrNotRunning // ← Manager will not retry } // ... if err != nil { // Use ClassifySendError to wrap error based on HTTP status code - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // Or manually wrap: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return nil, nil } ``` @@ -301,6 +301,8 @@ sender := bus.SenderInfo{ CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), Username: from.Username, DisplayName: from.FirstName, + FirstName: from.FirstName, + LastName: from.LastName, } peer := bus.Peer{ @@ -502,10 +504,10 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. Check running state if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. Send message to Matrix @@ -513,14 +515,14 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if err != nil { // 3. Must use error classification wrapping // If you have an HTTP status code: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // If it's a network error: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // If manual classification is needed: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return nil, nil } // ========== Incoming Message Handling ========== @@ -868,7 +870,9 @@ type SenderInfo struct { PlatformID string `json:"platform_id,omitempty"` // Platform-native ID CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" canonical format Username string `json:"username,omitempty"` - DisplayName string `json:"display_name,omitempty"` + DisplayName string `json:"display_name,omitempty"` // fallback display name when first/last not available + FirstName string `json:"first_name,omitempty"` // given name (preferred over DisplayName when set) + LastName string `json:"last_name,omitempty"` // family name } // Inbound message @@ -889,9 +893,11 @@ type InboundMessage struct { // Outbound text message type OutboundMessage struct { - Channel string - ChatID string - Content string + Channel string // Target channel name + ChatID string // Target chat/room ID + Content string // Message text + ReplyToMessageID string // Optional: reply to this platform message ID + OnDelivered func(msgIDs []string) // Optional: called with delivered platform message IDs (not serialized) } // Outbound media message @@ -1273,7 +1279,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index ead0f5c4e7..5be229b009 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -253,27 +253,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { ```go // 旧代码:返回普通 error -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.running { return fmt.Errorf("not running") } +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.running { return nil, fmt.Errorf("not running") } // ... - if err != nil { return err } + if err != nil { return nil, err } } // 新代码:必须返回哨兵错误,供 Manager 判断重试策略 -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager 不会重试 + return nil, channels.ErrNotRunning // ← Manager 不会重试 } // ... if err != nil { // 使用 ClassifySendError 根据 HTTP 状态码包装错误 - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // 或手动包装: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return nil, nil } ``` @@ -301,6 +301,8 @@ sender := bus.SenderInfo{ CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), Username: from.Username, DisplayName: from.FirstName, + FirstName: from.FirstName, + LastName: from.LastName, } peer := bus.Peer{ @@ -502,10 +504,10 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. 检查运行状态 if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. 发送消息到 Matrix @@ -513,14 +515,14 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if err != nil { // 3. 必须使用错误分类包装 // 如果你有 HTTP 状态码: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // 如果是网络错误: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // 如果需要手动分类: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return nil, nil } // ========== 消息接收处理 ========== @@ -867,7 +869,9 @@ type SenderInfo struct { PlatformID string `json:"platform_id,omitempty"` // 平台原始 ID CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" 规范格式 Username string `json:"username,omitempty"` - DisplayName string `json:"display_name,omitempty"` + DisplayName string `json:"display_name,omitempty"` // 无 first/last 时的回退显示名 + FirstName string `json:"first_name,omitempty"` // 名(优先于 DisplayName) + LastName string `json:"last_name,omitempty"` // 姓 } // 入站消息 @@ -888,9 +892,11 @@ type InboundMessage struct { // 出站文本消息 type OutboundMessage struct { - Channel string - ChatID string - Content string + Channel string // 目标 channel 名称 + ChatID string // 目标聊天/房间 ID + Content string // 消息文本 + ReplyToMessageID string // 可选:回复的平台消息 ID + OnDelivered func(msgIDs []string) // 可选:投递成功后携带平台消息 ID 回调(不序列化) } // 出站媒体消息 @@ -1272,7 +1278,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool diff --git a/pkg/channels/base.go b/pkg/channels/base.go index f50e2abefd..82b7fa92e6 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -48,7 +48,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 7ac2c073fb..023e07e970 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -103,20 +103,20 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { } // Send sends a message to DingTalk via the chatbot reply API -func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + return nil, fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + return nil, fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ @@ -125,7 +125,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) // Use the session webhook to send the reply - return c.SendDirectReply(ctx, sessionWebhook, msg.Content) + return nil, c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 573dc47e3a..948460c6e5 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -128,13 +128,7 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { return nil } -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - _, err := c.SendMessageWithIDs(ctx, msg) - return err -} - -// SendMessageWithIDs implements channels.MessageIDsSender. -func (c *DiscordChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { +func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa2249..7bb006bdd8 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -36,8 +36,8 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errUnsupported +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, errUnsupported } // EditMessage is a stub method to satisfy MessageEditor diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 0ab70649f6..146de5962d 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -131,26 +131,26 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { // Send sends a message using Interactive Card format for markdown rendering. // Falls back to plain text message if card sending fails (e.g., table limit exceeded). -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { // If card build fails, fall back to plain text - return c.sendText(ctx, msg.ChatID, msg.Content) + return nil, c.sendText(ctx, msg.ChatID, msg.Content) } // First attempt: try sending as interactive card err = c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil + return nil, nil } // Check if error is due to card table limit (error code 11310) @@ -167,14 +167,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error // Second attempt: fall back to plain text message textErr := c.sendText(ctx, msg.ChatID, msg.Content) if textErr == nil { - return nil + return nil, nil } // If text also fails, return the text error - return textErr + return nil, textErr } // For other errors, return the original card error - return err + return nil, err } // EditMessage implements channels.MessageEditor. diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index e4388664aa..0cfd435b00 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -62,12 +62,6 @@ type PlaceholderRecorder interface { RecordReactionUndo(channel, chatID string, undo func()) } -// MessageIDsSender is implemented by channels that can return the platform -// message IDs for a delivered outbound text message. -type MessageIDsSender interface { - SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) (messageIDs []string, err error) -} - // CommandRegistrarCapable is implemented by channels that can register // command menus with their upstream platform (e.g. Telegram BotCommand). // Channels that do not support platform-level command menus can ignore it. diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 289ce2c9bf..0b0e8e0c8a 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -130,18 +130,18 @@ func (c *IRCChannel) Stop(ctx context.Context) error { } // Send sends a message to an IRC channel or user. -func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } target := msg.ChatID if target == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } if strings.TrimSpace(msg.Content) == "" { - return nil + return nil, nil } // Send each line separately (IRC is line-oriented) @@ -158,7 +158,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "target": target, "lines": len(lines), }) - return nil + return nil, nil } // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 4eaadae70c..8fd21f0c06 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -496,9 +496,9 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. -func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Load and consume quote token for this chat @@ -516,14 +516,14 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_id": msg.ChatID, "quoted": quoteToken != "", }) - return nil + return nil, nil } logger.DebugC("line", "Reply API failed, falling back to Push API") } } // Fall back to Push API - return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } // SendMedia implements the channels.MediaSender interface. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index ff9a3ed1ad..bbbf2da56a 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -240,15 +240,15 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { return nil } -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -257,7 +257,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro if len(c.clients) == 0 { logger.WarnC("maixcam", "No MaixCam devices connected") - return fmt.Errorf("no connected MaixCam devices") + return nil, fmt.Errorf("no connected MaixCam devices") } response := map[string]any{ @@ -269,7 +269,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro data, err := json.Marshal(response) if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf("failed to marshal response: %w", err) } var sendErr error @@ -285,5 +285,5 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro _ = conn.SetWriteDeadline(time.Time{}) } - return sendErr + return nil, sendErr } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index ff704ea8de..bbc17053ca 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -662,14 +662,8 @@ func (m *Manager) sendWithRetry( var lastErr error var msgIDs []string - sender, hasMessageIDsSender := w.ch.(MessageIDsSender) for attempt := 0; attempt <= maxRetries; attempt++ { - msgIDs = nil - if hasMessageIDsSender { - msgIDs, lastErr = sender.SendMessageWithIDs(ctx, msg) - } else { - lastErr = w.ch.Send(ctx, msg) - } + msgIDs, lastErr = w.ch.Send(ctx, msg) if lastErr == nil { return msgIDs, true } @@ -1086,5 +1080,6 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten // Fallback: direct send (should not happen) channel, _ := m.channels[channelName] - return channel.Send(ctx, msg) + _, err := channel.Send(ctx, msg) + return err } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index d81b91a111..73ab94c90d 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -25,20 +25,15 @@ type mockChannel struct { lastPlaceholderID string } -func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { m.sentMessages = append(m.sentMessages, msg) - return m.sendFn(ctx, msg) -} - -func (m *mockChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { - m.sentMessages = append(m.sentMessages, msg) - if m.sendWithIDsFn == nil { - if m.sendFn == nil { - return nil, nil - } - return nil, m.sendFn(ctx, msg) + if m.sendWithIDsFn != nil { + return m.sendWithIDsFn(ctx, msg) + } + if m.sendFn == nil { + return nil, nil } - return m.sendWithIDsFn(ctx, msg) + return nil, m.sendFn(ctx, msg) } func (m *mockChannel) Start(ctx context.Context) error { return nil } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 01e30f387c..8e620edd20 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -276,26 +276,26 @@ func markdownToHTML(md string) string { return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { - return fmt.Errorf("matrix send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return nil + return nil, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 048be48eb4..4e3b528428 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -391,15 +391,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { return nil } -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -408,12 +408,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { - return err + return nil, err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -426,7 +426,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -439,10 +439,10 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // SendMedia implements the channels.MediaSender interface. diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 2c335050d8..82fc7ca263 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -273,22 +273,22 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { } // Send sends a message to the remote server. -func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.mu.Lock() pc := c.conn c.mu.Unlock() if pc == nil || pc.closed.Load() { - return channels.ErrSendFailed + return nil, channels.ErrSendFailed } outMsg := newMessage(TypeMessageSend, map[string]any{ "content": msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") - return pc.writeJSON(outMsg) + return nil, pc.writeJSON(outMsg) } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 118c9abeab..3b6b017ff6 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -46,7 +46,7 @@ func TestSend_NotRunning(t *testing.T) { if err != nil { t.Fatal(err) } - err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) if !errors.Is(err, channels.ErrNotRunning) { t.Fatalf("expected ErrNotRunning, got %v", err) } @@ -124,7 +124,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer ch.Stop(ctx) // Send a message - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-1", Content: "hello", }) @@ -179,7 +179,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { defer ch.Stop(ctx) // Send a message; the echo server replies with message.create - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-echo", Content: "ping", }) @@ -252,7 +252,7 @@ func TestSend_ClosedConnection(t *testing.T) { ch.conn.close() ch.mu.Unlock() - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-close", Content: "should fail", }) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 86ce98b061..55d56ff8b2 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -142,16 +142,16 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Send implements Channel — sends a message to the appropriate WebSocket connection. -func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": msg.Content, }) - return c.broadcastToSession(msg.ChatID, outMsg) + return nil, c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 8dc44e636c..b345f22f10 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -200,13 +200,7 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - _, err := c.SendMessageWithIDs(ctx, msg) - return err -} - -// SendMessageWithIDs implements channels.MessageIDsSender. -func (c *QQChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 00061ced4c..9ac95735f4 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -108,13 +108,7 @@ func (c *SlackChannel) Stop(ctx context.Context) error { return nil } -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - _, err := c.SendMessageWithIDs(ctx, msg) - return err -} - -// SendMessageWithIDs implements channels.MessageIDsSender. -func (c *SlackChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 835a9c27b9..3510be2b4c 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,13 +168,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - _, err := c.SendMessageWithIDs(ctx, msg) - return err -} - -// SendMessageWithIDs implements channels.MessageIDsSender. -func (c *TelegramChannel) SendMessageWithIDs(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 27f5599289..b12b52397e 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -236,7 +236,7 @@ func TestSend_EmptyContent(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "", }) @@ -253,7 +253,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello, world!", }) @@ -276,7 +276,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) { longContent := strings.Repeat("a", 4000) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -295,7 +295,7 @@ func TestSendMessageWithIDs_ReturnsAllChunkIDsAfterHTMLResplit(t *testing.T) { chunk := "[x](https://example.com/" + strings.Repeat("a", 20) + ") " content := strings.Repeat(chunk, 120) - ids, err := ch.SendMessageWithIDs(context.Background(), bus.OutboundMessage{ + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: content, }) @@ -320,7 +320,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello **world**", }) @@ -338,7 +338,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -360,7 +360,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { longContent := strings.Repeat("x", 4001) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -390,7 +390,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { "HTML expansion must exceed Telegram limit for this test to be meaningful", ) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: markdownContent, }) @@ -425,7 +425,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { // Ensure the test content matches the intended boundary conditions. assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "123456", Content: content, }) @@ -461,7 +461,7 @@ func TestSend_NotRunning(t *testing.T) { ch := newTestChannel(t, caller) ch.SetRunning(false) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -479,7 +479,7 @@ func TestSend_InvalidChatID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "not-a-number", Content: "Hello", }) @@ -536,7 +536,7 @@ func TestSend_WithForumThreadID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "-1001234567890/42", Content: "Hello from topic", }) diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index c5e1481856..664b528385 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -211,9 +211,9 @@ func (c *WeComAIBotChannel) Stop(ctx context.Context) error { // Send delivers the agent reply into the active streamTask for msg.ChatID. // It writes into the earliest unfinished task in the queue (FIFO per chatID). // If the stream has already closed (deadline passed), it posts directly to response_url. -func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.taskMu.Lock() queue := c.chatTasks[msg.ChatID] @@ -246,7 +246,7 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "chat_id": msg.ChatID, }, ) - return nil + return nil, nil } if streamClosed { @@ -263,7 +263,7 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e "stream_id": task.StreamID, }) c.removeTask(task) - return fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("response_url delivery failed: %w", channels.ErrSendFailed) } } else { logger.WarnCF("wecom_aibot", "Stream closed but no response_url available", map[string]any{ @@ -271,7 +271,7 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e }) } c.removeTask(task) - return nil + return nil, nil } // Stream still open: deliver via answerCh for the next poll response. @@ -279,11 +279,11 @@ func (c *WeComAIBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) e case task.answerCh <- msg.Content: case <-task.ctx.Done(): // Task was canceled (cleanup removed it); silently drop the reply. - return nil + return nil, nil case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } - return nil + return nil, nil } // WebhookPath returns the path for registering on the shared HTTP server diff --git a/pkg/channels/wecom/aibot_ws.go b/pkg/channels/wecom/aibot_ws.go index 53dd7071f2..c329ff74e3 100644 --- a/pkg/channels/wecom/aibot_ws.go +++ b/pkg/channels/wecom/aibot_ws.go @@ -276,9 +276,9 @@ func (c *WeComAIBotWSChannel) Stop(_ context.Context) error { // Send delivers the agent reply for msg.ChatID. // The waiting task goroutine picks it up and writes the final stream response. -func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // msg.ChatID carries the inbound req_id (set by dispatchWSAgentTask). @@ -293,9 +293,9 @@ func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) if err := c.wsSendActivePush(msg.ChatID, 0, msg.Content); err != nil { logger.WarnCF("wecom_aibot", "Proactive push failed", map[string]any{"chat_id": msg.ChatID, "error": err.Error()}) - return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) } - return nil + return nil, nil } if task == nil { @@ -304,18 +304,18 @@ func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) // push unless wsStreamMaxDuration has elapsed. logger.WarnCF("wecom_aibot", "Send: stream window still open, skip proactive push", map[string]any{"req_id": msg.ChatID, "ready_at": route.ReadyAt.Format(time.RFC3339)}) - return nil + return nil, nil } if err := c.wsSendActivePush(route.ChatID, route.ChatType, msg.Content); err != nil { logger.WarnCF("wecom_aibot", "Late reply proactive push failed", map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "error": err.Error()}) - return fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("websocket delivery failed: %w", channels.ErrSendFailed) } logger.InfoCF("wecom_aibot", "Late reply delivered via proactive push", map[string]any{"req_id": msg.ChatID, "chat_id": route.ChatID, "chat_type": route.ChatType}) c.deleteReqState(msg.ChatID) - return nil + return nil, nil } // Non-blocking fast path: when answerCh has space, deliver without racing @@ -323,18 +323,18 @@ func (c *WeComAIBotWSChannel) Send(ctx context.Context, msg bus.OutboundMessage) // incoming message, but the response must still be sent). select { case task.answerCh <- msg.Content: - return nil + return nil, nil default: } // answerCh was full; block with cancellation guards. select { case task.answerCh <- msg.Content: case <-task.ctx.Done(): - return nil + return nil, nil case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } - return nil + return nil, nil } // ---- Connection management ---- diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index fccfc60a34..b1b6e10547 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -192,14 +192,14 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error { } // Send sends a message to WeCom user proactively using access token -func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } accessToken := c.getAccessToken() if accessToken == "" { - return fmt.Errorf("no valid access token available") + return nil, fmt.Errorf("no valid access token available") } logger.DebugCF("wecom_app", "Sending message", map[string]any{ @@ -207,7 +207,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err "preview": utils.Truncate(msg.Content, 100), }) - return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) + return nil, c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } // SendMedia implements the channels.MediaSender interface. diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 22461b7686..1b768d2789 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -147,9 +147,9 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error { // Send sends a message to WeCom user via webhook API // Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message // For delayed responses, we use the webhook URL -func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ @@ -157,7 +157,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err "preview": utils.Truncate(msg.Content, 100), }) - return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) + return nil, c.sendWebhookReply(ctx, msg.ChatID, msg.Content) } // WebhookPath returns the path for registering on the shared HTTP server. diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index b9e821ef1a..e627d58996 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -313,16 +313,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } // Send implements channels.Channel by sending a text message to the WeChat user. -func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } if msg.Content == "" { - return nil + return nil, nil } // We need a context_token to send a reply. It should be stored in the conversation metadata. @@ -341,7 +341,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{ "to_user_id": toUserID, }) - return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) + return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) } if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil { @@ -350,10 +350,10 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed) } - return fmt.Errorf("weixin send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary) } - return nil + return nil, nil } diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 70b3e02bfd..98622fe375 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before acquiring lock select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err data, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) + return nil, fmt.Errorf("failed to marshal message: %w", err) } _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { _ = c.conn.SetWriteDeadline(time.Time{}) - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) - return nil + return nil, nil } func (c *WhatsAppChannel) listen() { diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa4..d0a74a405f 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } -func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } // Detect unpaired state: the client is connected (to WhatsApp servers) // but has not completed QR-login yet, so sending would fail. if client.Store.ID == nil { - return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) } waMsg := &waE2E.Message{ @@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag } if _, err = client.SendMessage(ctx, to, waMsg); err != nil { - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // parseJID converts a chat ID (phone number or JID string) to types.JID. From 5785a9dfc508e3139a0b7271b532e25c49bab014 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 11:10:55 +0200 Subject: [PATCH 09/11] =?UTF-8?q?fix(tests):=20adapt=20to=20upstream=20Mod?= =?UTF-8?q?el=E2=86=92ModelName=20rename=20in=20AgentDefaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/agent/loop_test.go | 4 ++-- pkg/agent/steering_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index eea16cf0f2..f01f971e61 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -302,7 +302,7 @@ func TestProcessMessage_AssistantSavedOnDelivered(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -364,7 +364,7 @@ func TestProcessMessage_SavesReplyToMessageIDFromInboundField(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 71fe51dd6b..1cca44ca7e 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -442,7 +442,7 @@ func TestDrainBusToSteering_PreservesSenderAndThreadingMetadata(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, @@ -507,7 +507,7 @@ func TestContinueWithSteeringMessages_ReturnsTrackedAssistantDelivery(t *testing Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, - Model: "test-model", + ModelName: "test-model", MaxTokens: 4096, MaxToolIterations: 10, }, From 72ba249f5fc16b6bfc3f14ee8fb29efc70464067 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 11:20:37 +0200 Subject: [PATCH 10/11] fix(lint): remove unused funcs, fix formatting after Send refactor - Remove unused messageThreadAnnotation (superseded by messageHistoryAnnotation) - Remove unused publishResponseIfNeeded (superseded by publishAgentResponseIfNeeded) - Reformat fakeChannel method alignment broken by Send signature change - Wrap long line in steering_test.go --- pkg/agent/context.go | 10 ---------- pkg/agent/loop.go | 37 ------------------------------------- pkg/agent/loop_test.go | 14 +++++++------- pkg/agent/steering_test.go | 7 ++++--- 4 files changed, 11 insertions(+), 57 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index f6f9cf049e..b10dec16f3 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -896,16 +896,6 @@ func messageSenderAnnotation(sender *providers.MessageSender) string { } } -// messageThreadAnnotation returns the thread annotation prefix for a message, -// e.g. "[msg:#5, reply_to:#3] " or "" if the message has no threading IDs. -func messageThreadAnnotation(msg providers.Message) string { - body := messageThreadAnnotationBody(msg) - if body == "" { - return "" - } - return fmt.Sprintf("[%s] ", body) -} - func messageThreadAnnotationBody(msg providers.Message) string { msgIDs := msg.MessageIDs formattedIDs := strings.Join(msgIDs, ",#") diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ee95a5cc58..18ab891603 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -655,43 +655,6 @@ func (al *AgentLoop) Stop() { al.running.Store(false) } -func (al *AgentLoop) publishResponseIfNeeded(ctx context.Context, channel, chatID, response string) { - if response == "" { - return - } - - alreadySent := false - defaultAgent := al.GetRegistry().GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() - } - } - } - - if alreadySent { - logger.DebugCF( - "agent", - "Skipped outbound (message tool already sent)", - map[string]any{"channel": channel}, - ) - return - } - - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: response, - }) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": channel, - "chat_id": chatID, - "content_len": len(response), - }) -} - func (al *AgentLoop) publishAgentResponseIfNeeded( ctx context.Context, response agentResponse, diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index f01f971e61..463cd58b7a 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -24,16 +24,16 @@ import ( type fakeChannel struct{ id string } -func (f *fakeChannel) Name() string { return "fake" } -func (f *fakeChannel) Start(ctx context.Context) error { return nil } -func (f *fakeChannel) Stop(ctx context.Context) error { return nil } +func (f *fakeChannel) Name() string { return "fake" } +func (f *fakeChannel) Start(ctx context.Context) error { return nil } +func (f *fakeChannel) Stop(ctx context.Context) error { return nil } func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { return nil, nil } -func (f *fakeChannel) IsRunning() bool { return true } -func (f *fakeChannel) IsAllowed(string) bool { return true } -func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } -func (f *fakeChannel) ReasoningChannelID() string { return f.id } +func (f *fakeChannel) IsRunning() bool { return true } +func (f *fakeChannel) IsAllowed(string) bool { return true } +func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } +func (f *fakeChannel) ReasoningChannelID() string { return f.id } type recordingProvider struct { lastMessages []providers.Message diff --git a/pkg/agent/steering_test.go b/pkg/agent/steering_test.go index 1cca44ca7e..227a52e760 100644 --- a/pkg/agent/steering_test.go +++ b/pkg/agent/steering_test.go @@ -522,9 +522,10 @@ func TestContinueWithSteeringMessages_ReturnsTrackedAssistantDelivery(t *testing } sessionKey := "agent:test-continue-delivery" - response, err := al.continueWithSteeringMessages(context.Background(), defaultAgent, sessionKey, "test", "chat1", []providers.Message{ - {Role: "user", Content: "new direction"}, - }) + response, err := al.continueWithSteeringMessages( + context.Background(), defaultAgent, sessionKey, "test", "chat1", + []providers.Message{{Role: "user", Content: "new direction"}}, + ) if err != nil { t.Fatalf("continueWithSteeringMessages failed: %v", err) } From 4d267d9f862362a8072bda0360b631c9176342d1 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 11:30:04 +0200 Subject: [PATCH 11/11] fix(agent): use semicolon as separator in history annotation to avoid ambiguity with IDs --- pkg/agent/context.go | 2 +- pkg/agent/context_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index b10dec16f3..a5eee5514c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -862,7 +862,7 @@ func messageHistoryAnnotation(msg providers.Message) string { if len(parts) == 0 { return "" } - return fmt.Sprintf("[%s] ", strings.Join(parts, ", ")) + return fmt.Sprintf("[%s] ", strings.Join(parts, "; ")) } func messageSenderAnnotation(sender *providers.MessageSender) string { diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 08cec97b85..7337e8b4a7 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -226,7 +226,7 @@ func TestMessageHistoryAnnotation_IncludesSenderAndThreading(t *testing.T) { }, } - if got := messageHistoryAnnotation(msg); got != "[from:Alice Example (@alice), msgs:#m1, reply_to:#p0] " { + if got := messageHistoryAnnotation(msg); got != "[from:Alice Example (@alice); msgs:#m1, reply_to:#p0] " { t.Fatalf("messageHistoryAnnotation() = %q", got) } }