a4e0fc0452
* 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).
207 lines
6.4 KiB
Go
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
|
|
}
|