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).
108 lines
3.1 KiB
Go
108 lines
3.1 KiB
Go
package orchestrator
|
|
|
|
import v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
|
|
// UserMessageMeta holds metadata fields for a user message.
|
|
// Use NewUserMessageMeta to construct and ToMap to serialize.
|
|
type UserMessageMeta struct {
|
|
PlanMode bool
|
|
HasReviewComments bool
|
|
Attachments []v1.MessageAttachment
|
|
ContextFiles []v1.ContextFileMeta
|
|
SenderTaskID string
|
|
SenderTaskTitle string
|
|
SenderSessionID string
|
|
}
|
|
|
|
// NewUserMessageMeta creates a UserMessageMeta builder.
|
|
func NewUserMessageMeta() *UserMessageMeta {
|
|
return &UserMessageMeta{}
|
|
}
|
|
|
|
// WithPlanMode marks the message as having plan mode enabled.
|
|
func (m *UserMessageMeta) WithPlanMode(enabled bool) *UserMessageMeta {
|
|
m.PlanMode = enabled
|
|
return m
|
|
}
|
|
|
|
// WithReviewComments marks the message as containing review comments.
|
|
func (m *UserMessageMeta) WithReviewComments(has bool) *UserMessageMeta {
|
|
m.HasReviewComments = has
|
|
return m
|
|
}
|
|
|
|
// WithAttachments sets the message attachments.
|
|
func (m *UserMessageMeta) WithAttachments(attachments []v1.MessageAttachment) *UserMessageMeta {
|
|
m.Attachments = attachments
|
|
return m
|
|
}
|
|
|
|
// WithContextFiles sets the context files attached to the message.
|
|
func (m *UserMessageMeta) WithContextFiles(files []v1.ContextFileMeta) *UserMessageMeta {
|
|
m.ContextFiles = files
|
|
return m
|
|
}
|
|
|
|
// WithSenderTask records that the message originated from another task's agent
|
|
// (via the message_task_kandev MCP tool). Title is a snapshot at send time;
|
|
// session ID is optional and identifies the sender's specific session.
|
|
func (m *UserMessageMeta) WithSenderTask(taskID, taskTitle, sessionID string) *UserMessageMeta {
|
|
m.SenderTaskID = taskID
|
|
m.SenderTaskTitle = taskTitle
|
|
m.SenderSessionID = sessionID
|
|
return m
|
|
}
|
|
|
|
// ToMap returns the metadata as a map suitable for message creation.
|
|
// Returns nil if no metadata fields are set.
|
|
func (m *UserMessageMeta) ToMap() map[string]interface{} {
|
|
if !m.PlanMode && !m.HasReviewComments && len(m.Attachments) == 0 && len(m.ContextFiles) == 0 && m.SenderTaskID == "" {
|
|
return nil
|
|
}
|
|
meta := make(map[string]interface{})
|
|
if m.PlanMode {
|
|
meta["plan_mode"] = true
|
|
}
|
|
if m.HasReviewComments {
|
|
meta["has_review_comments"] = true
|
|
}
|
|
if len(m.Attachments) > 0 {
|
|
meta["attachments"] = m.Attachments
|
|
}
|
|
if len(m.ContextFiles) > 0 {
|
|
meta["context_files"] = m.ContextFiles
|
|
}
|
|
if m.SenderTaskID != "" {
|
|
meta["sender_task_id"] = m.SenderTaskID
|
|
meta["sender_task_title"] = m.SenderTaskTitle
|
|
if m.SenderSessionID != "" {
|
|
meta["sender_session_id"] = m.SenderSessionID
|
|
}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// mergeMetadata returns a single metadata map combining base and extra. extra
|
|
// values take precedence on key collision. Returns nil only when both inputs
|
|
// are nil/empty so callers can keep the "no metadata" branch in the message
|
|
// store.
|
|
func mergeMetadata(base, extra map[string]interface{}) map[string]interface{} {
|
|
if len(base) == 0 && len(extra) == 0 {
|
|
return nil
|
|
}
|
|
if len(extra) == 0 {
|
|
return base
|
|
}
|
|
if len(base) == 0 {
|
|
return extra
|
|
}
|
|
merged := make(map[string]interface{}, len(base)+len(extra))
|
|
for k, v := range base {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range extra {
|
|
merged[k] = v
|
|
}
|
|
return merged
|
|
}
|