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).
188 lines
5.6 KiB
Go
188 lines
5.6 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"testing"
|
|
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
func TestUserMessageMeta_ToMap_Empty(t *testing.T) {
|
|
meta := NewUserMessageMeta()
|
|
result := meta.ToMap()
|
|
if result != nil {
|
|
t.Errorf("expected nil for empty meta, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_PlanModeOnly(t *testing.T) {
|
|
meta := NewUserMessageMeta().WithPlanMode(true)
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
if v, ok := result["plan_mode"]; !ok || v != true {
|
|
t.Errorf("expected plan_mode=true, got %v", result)
|
|
}
|
|
if _, ok := result["has_review_comments"]; ok {
|
|
t.Error("unexpected has_review_comments key")
|
|
}
|
|
if _, ok := result["attachments"]; ok {
|
|
t.Error("unexpected attachments key")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_ReviewCommentsOnly(t *testing.T) {
|
|
meta := NewUserMessageMeta().WithReviewComments(true)
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
if v, ok := result["has_review_comments"]; !ok || v != true {
|
|
t.Errorf("expected has_review_comments=true, got %v", result)
|
|
}
|
|
if _, ok := result["plan_mode"]; ok {
|
|
t.Error("unexpected plan_mode key")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_AttachmentsOnly(t *testing.T) {
|
|
attachments := []v1.MessageAttachment{{Type: "image", Data: "base64data", MimeType: "image/png"}}
|
|
meta := NewUserMessageMeta().WithAttachments(attachments)
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
att, ok := result["attachments"]
|
|
if !ok {
|
|
t.Fatal("expected attachments key")
|
|
}
|
|
if len(att.([]v1.MessageAttachment)) != 1 {
|
|
t.Errorf("expected 1 attachment, got %d", len(att.([]v1.MessageAttachment)))
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_ContextFilesOnly(t *testing.T) {
|
|
files := []v1.ContextFileMeta{{Path: "src/main.go", Name: "main.go"}}
|
|
meta := NewUserMessageMeta().WithContextFiles(files)
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
cf, ok := result["context_files"]
|
|
if !ok {
|
|
t.Fatal("expected context_files key")
|
|
}
|
|
if len(cf.([]v1.ContextFileMeta)) != 1 {
|
|
t.Errorf("expected 1 context file, got %d", len(cf.([]v1.ContextFileMeta)))
|
|
}
|
|
if _, ok := result["plan_mode"]; ok {
|
|
t.Error("unexpected plan_mode key")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_AllFields(t *testing.T) {
|
|
attachments := []v1.MessageAttachment{{Type: "image", Data: "data", MimeType: "image/jpeg"}}
|
|
contextFiles := []v1.ContextFileMeta{{Path: "README.md", Name: "README.md"}}
|
|
meta := NewUserMessageMeta().
|
|
WithPlanMode(true).
|
|
WithReviewComments(true).
|
|
WithAttachments(attachments).
|
|
WithContextFiles(contextFiles)
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
if len(result) != 4 {
|
|
t.Errorf("expected 4 keys, got %d", len(result))
|
|
}
|
|
if result["plan_mode"] != true {
|
|
t.Error("expected plan_mode=true")
|
|
}
|
|
if result["has_review_comments"] != true {
|
|
t.Error("expected has_review_comments=true")
|
|
}
|
|
if _, ok := result["attachments"]; !ok {
|
|
t.Error("expected attachments key")
|
|
}
|
|
if _, ok := result["context_files"]; !ok {
|
|
t.Error("expected context_files key")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_FalseValues(t *testing.T) {
|
|
meta := NewUserMessageMeta().
|
|
WithPlanMode(false).
|
|
WithReviewComments(false)
|
|
result := meta.ToMap()
|
|
if result != nil {
|
|
t.Errorf("expected nil for all-false meta, got %v", result)
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_Chaining(t *testing.T) {
|
|
meta := NewUserMessageMeta()
|
|
returned := meta.WithPlanMode(true)
|
|
if returned != meta {
|
|
t.Error("WithPlanMode should return the same pointer for chaining")
|
|
}
|
|
returned = meta.WithReviewComments(true)
|
|
if returned != meta {
|
|
t.Error("WithReviewComments should return the same pointer for chaining")
|
|
}
|
|
returned = meta.WithAttachments(nil)
|
|
if returned != meta {
|
|
t.Error("WithAttachments should return the same pointer for chaining")
|
|
}
|
|
returned = meta.WithContextFiles(nil)
|
|
if returned != meta {
|
|
t.Error("WithContextFiles should return the same pointer for chaining")
|
|
}
|
|
returned = meta.WithSenderTask("t", "title", "s")
|
|
if returned != meta {
|
|
t.Error("WithSenderTask should return the same pointer for chaining")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_SenderTaskOnly(t *testing.T) {
|
|
meta := NewUserMessageMeta().WithSenderTask("task-uuid", "Fix login bug", "session-uuid")
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
if v, ok := result["sender_task_id"]; !ok || v != "task-uuid" {
|
|
t.Errorf("expected sender_task_id=task-uuid, got %v", result)
|
|
}
|
|
if v, ok := result["sender_task_title"]; !ok || v != "Fix login bug" {
|
|
t.Errorf("expected sender_task_title=Fix login bug, got %v", result)
|
|
}
|
|
if v, ok := result["sender_session_id"]; !ok || v != "session-uuid" {
|
|
t.Errorf("expected sender_session_id=session-uuid, got %v", result)
|
|
}
|
|
if _, ok := result["plan_mode"]; ok {
|
|
t.Error("unexpected plan_mode key")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_SenderTaskWithoutSession(t *testing.T) {
|
|
meta := NewUserMessageMeta().WithSenderTask("task-uuid", "Fix login bug", "")
|
|
result := meta.ToMap()
|
|
if result == nil {
|
|
t.Fatal("expected non-nil map")
|
|
}
|
|
if _, ok := result["sender_task_id"]; !ok {
|
|
t.Error("expected sender_task_id key")
|
|
}
|
|
if _, ok := result["sender_session_id"]; ok {
|
|
t.Error("unexpected sender_session_id key when sessionID is empty")
|
|
}
|
|
}
|
|
|
|
func TestUserMessageMeta_ToMap_SenderTaskEmptyIDIsNoop(t *testing.T) {
|
|
// Empty taskID means no sender — must not emit sender keys even if title was set.
|
|
meta := NewUserMessageMeta().WithSenderTask("", "ghost title", "session-uuid")
|
|
result := meta.ToMap()
|
|
if result != nil {
|
|
t.Errorf("expected nil map when sender_task_id is empty, got %v", result)
|
|
}
|
|
}
|