Files
kandev/apps/backend/internal/orchestrator/messagequeue/service.go
T
José Almeida a4e0fc0452 feat: attribute cross-task agent messages with a sender badge (#819)
* feat(orchestrator): carry sender_task metadata through user message paths

Add WithSenderTask builder to UserMessageMeta and a metadata-aware
QueueMessageWithMetadata variant on the message queue. The drain path
in event_handlers_agent merges queued metadata into the recorded user
message so attribution survives the queue → DB transition.

* feat(mcp): attribute message_task_kandev calls to the sending agent

The MCP server inside agentctl now injects sender_task_id and
sender_session_id from its own server context. The backend handler
validates the sender (rejecting missing IDs, self-messages, and unknown
tasks), looks up the sender title, wraps the prompt in a <kandev-system>
attribution block, and propagates structured metadata across all three
delivery paths (queue, created-session, record+prompt). The wrapped
content lets the receiving agent identify the peer both live and on ACP
session resume; the existing sysprompt strip pipeline hides the wrapper
from the UI.

* feat(web): render sender-task badge on cross-task agent messages

User messages whose metadata carries sender_task_id now render a
clickable purple badge above the bubble, linking to the source task.
The badge live-resolves the sender's title from the kanban store and
falls back to a greyed snapshot when the task isn't loaded
(cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById
selector so badges don't fan out WS subscriptions per message.

* test(e2e): cover cross-task agent message attribution

Eight scenarios driven through the mock-agent's MCP script directive:
queue path, prompt path, self-message rejection, unknown-target error,
badge link target, live title re-resolution after rename, kandev-system
wrapper preservation in raw_content, and prompt bodies containing their
own kandev-system tags. Adds an updateTaskTitle helper to ApiClient.

* fix: address CodeRabbit + Greptile P1 findings on PR #819

- requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata
  so sender attribution survives transient delivery failures and retries.
- CREATED-session branch in handleMessageTask records the user message AFTER
  StartCreatedSession succeeds, so a launch failure no longer leaves an
  orphaned attributed chat row.
- SenderTaskBadge: collapse identical displayTitle/fullTitle locals.
- E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the
  collision-check test with the same poll the rest of the suite uses.

* fix: address Claude review suggestions on PR #819

- wrapAgentMessage strips </kandev-system> from the embedded sender title
  before formatting. Without this, a title containing the closing tag
  would short-circuit the non-greedy strip regex and leak the attribution
  tail into the visible UI bubble. Metadata snapshot keeps the original
  title so the badge still displays it.
- Add a regression test covering the malicious-title case.
- Document the deliberate cross-workspace messaging policy on the sender
  lookup so future readers don't add a workspace-scope check (Q13 of the
  feature plan locked this as intentional).
2026-05-04 19:30:10 +01:00

207 lines
6.4 KiB
Go

package messagequeue
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Service manages queued messages for sessions.
// Uses in-memory storage as queued messages are best-effort and transient.
type Service struct {
// In-memory storage: sessionID -> QueuedMessage
queued map[string]*QueuedMessage
// In-memory storage: sessionID -> PendingMove (deferred move_task_kandev moves)
pendingMoves map[string]*PendingMove
mu sync.RWMutex
logger *logger.Logger
}
// NewService creates a new message queue service
func NewService(log *logger.Logger) *Service {
return &Service{
queued: make(map[string]*QueuedMessage),
pendingMoves: make(map[string]*PendingMove),
logger: log.WithFields(zap.String("component", "message-queue")),
}
}
// TransferSession moves any queued message and pending move from one session
// to another. Used by workflow session switches (when a target step has a
// different agent profile and switchSessionForStep creates a new session) so
// a prompt queued by move_task_kandev on the old session reaches the new one.
// Existing entries on the destination session are preserved if no source entry
// is present; otherwise the source entry replaces the destination entry.
func (s *Service) TransferSession(ctx context.Context, oldSessionID, newSessionID string) {
s.mu.Lock()
defer s.mu.Unlock()
if msg, ok := s.queued[oldSessionID]; ok {
msg.SessionID = newSessionID
s.queued[newSessionID] = msg
delete(s.queued, oldSessionID)
s.logger.Info("transferred queued message between sessions",
zap.String("from_session_id", oldSessionID),
zap.String("to_session_id", newSessionID),
zap.String("queue_id", msg.ID))
}
if move, ok := s.pendingMoves[oldSessionID]; ok {
s.pendingMoves[newSessionID] = move
delete(s.pendingMoves, oldSessionID)
s.logger.Info("transferred pending move between sessions",
zap.String("from_session_id", oldSessionID),
zap.String("to_session_id", newSessionID))
}
}
// SetPendingMove records a pending move for a session (replaces any existing one).
// The move is applied by handleAgentReady when the agent's current turn completes.
func (s *Service) SetPendingMove(ctx context.Context, sessionID string, move *PendingMove) {
s.mu.Lock()
defer s.mu.Unlock()
move.QueuedAt = time.Now()
s.pendingMoves[sessionID] = move
s.logger.Info("pending move recorded",
zap.String("session_id", sessionID),
zap.String("task_id", move.TaskID),
zap.String("workflow_step_id", move.WorkflowStepID))
}
// TakePendingMove retrieves and removes the pending move for a session.
func (s *Service) TakePendingMove(ctx context.Context, sessionID string) (*PendingMove, bool) {
s.mu.Lock()
defer s.mu.Unlock()
move, exists := s.pendingMoves[sessionID]
if !exists {
return nil, false
}
delete(s.pendingMoves, sessionID)
return move, true
}
// QueueMessage queues a message for a session (replaces existing queued message)
func (s *Service) QueueMessage(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment) (*QueuedMessage, error) {
return s.QueueMessageWithMetadata(ctx, sessionID, taskID, content, model, userID, planMode, attachments, nil)
}
// QueueMessageWithMetadata is like QueueMessage but stores extra metadata that
// is propagated to the resulting Message row when the queued message is
// drained (e.g. sender_task_id for messages sent via message_task_kandev).
func (s *Service) QueueMessageWithMetadata(ctx context.Context, sessionID, taskID, content, model, userID string, planMode bool, attachments []MessageAttachment, metadata map[string]interface{}) (*QueuedMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
msg := &QueuedMessage{
ID: uuid.New().String(),
SessionID: sessionID,
TaskID: taskID,
Content: content,
Model: model,
PlanMode: planMode,
Attachments: attachments,
Metadata: metadata,
QueuedAt: time.Now(),
QueuedBy: userID,
}
s.queued[sessionID] = msg
s.logger.Info("message queued",
zap.String("session_id", sessionID),
zap.String("task_id", taskID),
zap.Int("content_length", len(content)))
return msg, nil
}
// TakeQueued retrieves and removes the queued message for a session
func (s *Service) TakeQueued(ctx context.Context, sessionID string) (*QueuedMessage, bool) {
s.mu.Lock()
defer s.mu.Unlock()
msg, exists := s.queued[sessionID]
if !exists {
return nil, false
}
delete(s.queued, sessionID)
s.logger.Info("message dequeued",
zap.String("session_id", sessionID),
zap.String("queue_id", msg.ID))
return msg, true
}
// CancelQueued removes a queued message without consuming it
func (s *Service) CancelQueued(ctx context.Context, sessionID string) (*QueuedMessage, error) {
s.mu.Lock()
defer s.mu.Unlock()
msg, exists := s.queued[sessionID]
if !exists {
return nil, fmt.Errorf("no queued message for session %s", sessionID)
}
delete(s.queued, sessionID)
s.logger.Info("message queue cancelled",
zap.String("session_id", sessionID),
zap.String("queue_id", msg.ID))
return msg, nil
}
// GetStatus returns the queue status for a session
func (s *Service) GetStatus(ctx context.Context, sessionID string) *QueueStatus {
s.mu.RLock()
defer s.mu.RUnlock()
msg, exists := s.queued[sessionID]
return &QueueStatus{
IsQueued: exists,
Message: msg,
}
}
// AppendContent appends content to an existing queued message.
// Returns an error if no message is queued (caller should use QueueMessage instead).
func (s *Service) AppendContent(ctx context.Context, sessionID, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
msg, exists := s.queued[sessionID]
if !exists {
return fmt.Errorf("no queued message for session %s", sessionID)
}
msg.Content = msg.Content + "\n\n---\n\n" + content
s.logger.Info("content appended to queued message",
zap.String("session_id", sessionID),
zap.Int("appended_length", len(content)),
zap.Int("total_length", len(msg.Content)))
return nil
}
// UpdateMessage updates an existing queued message (for arrow up editing)
func (s *Service) UpdateMessage(ctx context.Context, sessionID, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
msg, exists := s.queued[sessionID]
if !exists {
return fmt.Errorf("no queued message for session %s", sessionID)
}
msg.Content = content
s.logger.Info("queued message updated",
zap.String("session_id", sessionID),
zap.Int("new_length", len(content)))
return nil
}