package daemon import ( "strings" "testing" "github.com/multica-ai/multica/server/internal/daemon/execenv" ) // TestBuildQuickCreatePromptRules locks in the rules that govern how the // quick-create agent is allowed to translate raw user input into the issue // description body. Each substring corresponds to a concrete failure mode // observed in production output: // - meta-instructions ("create an issue", "cc @X") leaking into the body // - the Context section being misused as an apology log when no external // references were actually fetched // - hard-line rules being silently dropped on prompt rewrites func TestBuildQuickCreatePromptRules(t *testing.T) { out := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"}) mustContain := []string{ // high-fidelity invariant "Faithfully restate what the user wants", "Preserve specific names, identifiers, file paths", // strip non-spec material: verbal routing wrappers + conversational fillers "verbal routing wrappers about creating the issue", "pure conversational fillers", // cc routing must survive: mention link stays in description so the // auto-subscribe path fires (multica issue create has no --subscriber flag) "CC exception", "auto-subscribes members", // context section is conditional and must not be an apology log "include ONLY when the input cited external resources", "never use it as an apology log", // output/reporting must be workspace-prefix agnostic. Workspaces can // use custom issue prefixes, so a successful issue creation should // not look failed merely because the identifier does not match one // fixed prefix. "multica issue create --output json", "JSON response", "identifier", "Do not scrape human output", "do not assume any workspace issue prefix", "Created : ", // hard rules "never invent requirements", "never reduce multi-sentence input", } for _, s := range mustContain { if !strings.Contains(out, s) { t.Errorf("buildQuickCreatePrompt output missing required rule: %q", s) } } } // TestBuildQuickCreatePromptAssigneeIncludesSquads locks in the MUL-2165 // fix: the assignee-resolution rules must tell the agent to consult the // squad list alongside members and agents. Before this, a quick-create // input like "assign to <SquadName>" silently fell through to // "Unrecognized assignee" because squads were never queried. func TestBuildQuickCreatePromptAssigneeIncludesSquads(t *testing.T) { out := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"}) mustContain := []string{ "multica squad list", "Squads are first-class assignees", "Treat bare @-routing as an assignee directive", "让 @独立团 review 这个 PR", "pass the squad's `id` as `--assignee-id`", } for _, s := range mustContain { if !strings.Contains(out, s) { t.Errorf("buildQuickCreatePrompt assignee block missing %q\n--- output ---\n%s", s, out) } } } // TestBuildQuickCreatePromptSquadDefaultsToSquad locks in the MUL-2203 // fix: when the picker was a squad, the task runs on the squad's leader // agent, but the default assignee for issues created by this run must // point at the SQUAD's UUID — not the leader agent's UUID. The previous // "default to YOURSELF" instruction made squad-created issues land under // the leader, hiding them from the squad's delegation flow. func TestBuildQuickCreatePromptSquadDefaultsToSquad(t *testing.T) { const ( squadID = "aaaa1111-2222-3333-4444-555555555555" squadName = "独立团" leaderID = "bbbb1111-2222-3333-4444-666666666666" ) out := buildQuickCreatePrompt(Task{ QuickCreatePrompt: "fix the login button color", Agent: &AgentData{ID: leaderID, Name: "leader-agent"}, SquadID: squadID, SquadName: squadName, }) // The default-assignee instruction must point at the squad UUID. if !strings.Contains(out, "--assignee-id \""+squadID+"\"") { t.Errorf("buildQuickCreatePrompt with SquadID must default to the squad's UUID, got:\n%s", out) } // And it must NOT tell the agent to default to itself (the leader). if strings.Contains(out, "--assignee-id \""+leaderID+"\"") { t.Errorf("buildQuickCreatePrompt with SquadID must NOT default to the leader agent's UUID, got:\n%s", out) } // The squad name should appear in the instruction so the agent has // human-readable context for the routing decision. if !strings.Contains(out, squadName) { t.Errorf("buildQuickCreatePrompt with SquadID should mention the squad name %q, got:\n%s", squadName, out) } // And the prompt must explicitly call out the squad-vs-leader rule // so the agent does not silently regress to "default to YOURSELF". mustContain := []string{ "picker SQUAD", "running on the squad's behalf", "do not assign it to your own agent UUID", } for _, s := range mustContain { if !strings.Contains(out, s) { t.Errorf("buildQuickCreatePrompt with SquadID missing %q\n--- output ---\n%s", s, out) } } } // TestBuildQuickCreatePromptProjectPinning verifies that when the user // pins a project in the quick-create modal, the prompt instructs the agent // to pass `--project <uuid>` exactly. Without this, the agent would re-read // the workspace default and silently drop the user's selection — the same // "I have to retype 'in project X' every time" failure mode the modal // addition was meant to fix. func TestBuildQuickCreatePromptProjectPinning(t *testing.T) { const projectID = "11111111-2222-3333-4444-555555555555" out := buildQuickCreatePrompt(Task{ QuickCreatePrompt: "fix the login button color", ProjectID: projectID, ProjectTitle: "Web App", }) mustContain := []string{ "--project \"" + projectID + "\"", "Web App", "modal selection is authoritative", } for _, s := range mustContain { if !strings.Contains(out, s) { t.Errorf("buildQuickCreatePrompt with project missing %q\n--- output ---\n%s", s, out) } } // Without a project, the prompt must keep the legacy "omit" instruction // so the agent doesn't accidentally start passing --project on plain // quick-create runs. plain := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"}) if !strings.Contains(plain, "**project**: omit") { t.Errorf("buildQuickCreatePrompt without project must keep the omit instruction, got:\n%s", plain) } if strings.Contains(plain, "--project") { t.Errorf("buildQuickCreatePrompt without project must NOT mention --project, got:\n%s", plain) } } func TestBuildQuickCreatePromptExplicitPriorityAndDueDate(t *testing.T) { out := buildQuickCreatePrompt(Task{ QuickCreatePrompt: "fix the login button color", QuickCreatePriority: "urgent", QuickCreateDueDate: "2026-08-01", }) for _, want := range []string{ "--priority urgent", "--due-date 2026-08-01", "quick-create selection is authoritative", } { if !strings.Contains(out, want) { t.Errorf("buildQuickCreatePrompt with explicit fields missing %q\n--- output ---\n%s", want, out) } } if strings.Contains(out, "Map P0/P1") { t.Errorf("explicit priority must replace inference rules, got:\n%s", out) } } // TestBuildQuickCreatePromptParentPinning verifies that when the user // opened quick-create from "Add sub issue" on an existing issue, the prompt // instructs the agent to pass `--parent <uuid>` so the new issue is filed // as a sub-issue. The frontend already seeds parent_issue_id silently // through the manual→agent switch, so this is the last hop that has to // hold up — without the prompt instruction the agent would create a // standalone issue and the sub-issue relationship would be silently // dropped. func TestBuildQuickCreatePromptParentPinning(t *testing.T) { const ( parentID = "33333333-2222-1111-4444-555555555555" parentIdentifier = "MUL-2534" ) out := buildQuickCreatePrompt(Task{ QuickCreatePrompt: "fix the login button color", ParentIssueID: parentID, ParentIssueIdentifier: parentIdentifier, }) mustContain := []string{ "--parent \"" + parentID + "\"", parentIdentifier, "modal entry point is authoritative", "filed as a sub-issue", } for _, s := range mustContain { if !strings.Contains(out, s) { t.Errorf("buildQuickCreatePrompt with parent missing %q\n--- output ---\n%s", s, out) } } // When only the UUID is available (identifier lookup failed on claim), // the agent must still get the --parent instruction so the sub-issue // intent isn't silently dropped. uuidOnly := buildQuickCreatePrompt(Task{ QuickCreatePrompt: "fix the login button color", ParentIssueID: parentID, }) if !strings.Contains(uuidOnly, "--parent \""+parentID+"\"") { t.Errorf("buildQuickCreatePrompt with parent UUID only must still pin --parent, got:\n%s", uuidOnly) } // Without a parent, the prompt must NOT mention --parent at all — a // plain quick-create run should not start filing sub-issues. plain := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"}) if strings.Contains(plain, "--parent") { t.Errorf("buildQuickCreatePrompt without parent must NOT mention --parent, got:\n%s", plain) } } // TestBuildPromptSquadLeaderNoActionForMemberTrigger verifies that the // squad leader no_action prohibition is injected in the per-turn prompt // regardless of whether the triggering comment was posted by an agent or // a member. This was the root cause of the "LGTM is a pure acknowledgment // — no reply needed. Exiting silently." noise comment: the prohibition // only fired for agent-triggered comments, so member-triggered ones // (like "LGTM") bypassed it. func TestBuildPromptSquadLeaderNoActionForMemberTrigger(t *testing.T) { task := Task{ IssueID: "issue-123", TriggerCommentID: "comment-456", TriggerCommentContent: "LGTM", TriggerAuthorType: "member", TriggerAuthorName: "Bohan", Agent: &AgentData{ Instructions: "Some instructions\n\n## Squad Operating Protocol\n\nYou are the LEADER...", }, } out := BuildPrompt(task, "claude") if !strings.Contains(out, "Squad leader no_action rule") { t.Errorf("buildCommentPrompt must inject squad leader no_action rule for member-triggered comments, got:\n%s", out) } if !strings.Contains(out, "DO NOT post any comment") { t.Errorf("buildCommentPrompt must contain DO NOT post prohibition for member-triggered squad leader, got:\n%s", out) } } // TestBuildPromptSquadLeaderNoActionForAgentTrigger verifies the rule also // fires for agent-triggered comments (the original path that already worked). func TestBuildPromptSquadLeaderNoActionForAgentTrigger(t *testing.T) { task := Task{ IssueID: "issue-123", TriggerCommentID: "comment-456", TriggerCommentContent: "Deploy complete.", TriggerAuthorType: "agent", TriggerAuthorName: "deploy-boy", Agent: &AgentData{ Instructions: "Some instructions\n\n## Squad Operating Protocol\n\nYou are the LEADER...", }, } out := BuildPrompt(task, "claude") if !strings.Contains(out, "Squad leader no_action rule") { t.Errorf("buildCommentPrompt must inject squad leader no_action rule for agent-triggered comments, got:\n%s", out) } } func TestBuildChatPromptAttachmentIDsCanBeBoundToCreatedIssues(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "please create an issue with this screenshot", ChatMessageAttachments: []ChatAttachmentMeta{ {ID: "019ec09d-6222-722b-bdfa-427b105d80be", Filename: "shot.png", ContentType: "image/png"}, }, } out := BuildPrompt(task, "claude") for _, want := range []string{ "Attachments on this message:", "id=019ec09d-6222-722b-bdfa-427b105d80be", "multica attachment download <id>", "--attachment-id <id>", } { if !strings.Contains(out, want) { t.Errorf("chat prompt missing %q\n--- output ---\n%s", want, out) } } } func TestBuildChatPromptChannelAwareness(t *testing.T) { t.Run("slack-backed prompt teaches both read commands", func(t *testing.T) { out := buildChatPrompt(Task{ ChatSessionID: "sess-1", ChatChannelType: "slack", ChatMessage: "你刚刚和 xxx 聊了什么", }) for _, want := range []string{"Slack", "NOT in Multica", "multica chat history", "multica chat thread", "Do NOT narrate"} { if !strings.Contains(out, want) { t.Fatalf("slack-backed prompt missing %q\n--- output ---\n%s", want, out) } } }) t.Run("top-level mention starts with history", func(t *testing.T) { out := buildChatPrompt(Task{ChatSessionID: "s", ChatChannelType: "slack", ChatInThread: false, ChatMessage: "hi"}) if !strings.Contains(out, "top level: start with `multica chat history`") { t.Fatalf("expected top-level guidance, got:\n%s", out) } }) t.Run("in-thread mention starts with thread", func(t *testing.T) { out := buildChatPrompt(Task{ChatSessionID: "s", ChatChannelType: "slack", ChatInThread: true, ChatMessage: "hi"}) if !strings.Contains(out, "inside a thread: start with `multica chat thread`") { t.Fatalf("expected in-thread guidance, got:\n%s", out) } }) t.Run("web-only session has no channel block", func(t *testing.T) { out := buildChatPrompt(Task{ ChatSessionID: "sess-1", ChatMessage: "hi", }) if strings.Contains(out, "multica chat history") { t.Fatalf("web-only chat prompt should not mention channel history, got:\n%s", out) } }) } // TestBuildChatPromptTwoLayerChannelPolicy pins the two INDEPENDENT axes of the // chat channel policy (MUL-4899). Collapsing them into one condition is exactly // the bug this matrix exists to catch: // // - delivery: `attachment upload` guidance is injected iff there is NO channel. // Any IM reply leaves Multica, where the upload has nothing to bind to. // - history: the `chat history` / `chat thread` commands are injected iff the // channel is Slack. Those endpoints are hardwired to h.SlackHistory // (handler/chat_history.go) — on Feishu they answer "no channel // integration", so teaching them there sends the agent down a dead path. // // Feishu is the case that proves the axes are separate: no upload AND no // history. A single `ChatChannelType != ""` gate cannot express it. func TestBuildChatPromptTwoLayerChannelPolicy(t *testing.T) { // Match the IMPERATIVE, not the bare command name. An IM prompt names // `multica attachment upload` on purpose — to state that it does not apply // here. That negation is the useful copy (the agent knows the command exists // from the brief's Available Commands; silence would leave it guessing), so // asserting on the bare name would forbid the very sentence we want. const uploadGuidance = "run `multica attachment upload <local-path>`" const historyGuidance = "multica chat history" cases := []struct { name string channelType string wantUpload bool wantHistory bool wantPhrases []string }{ { name: "direct chat: upload, no history", channelType: "", wantUpload: true, wantHistory: false, }, { name: "slack: no upload, has history", channelType: execenv.ChannelTypeSlack, wantUpload: false, wantHistory: true, wantPhrases: []string{"Slack", "delivered to Slack as text", "You cannot attach a file to it"}, }, { name: "feishu: no upload, no history", channelType: execenv.ChannelTypeFeishu, wantUpload: false, wantHistory: false, wantPhrases: []string{ "Feishu/Lark", "no history reader for Feishu/Lark", "delivered to Feishu/Lark as text", "You cannot attach a file to it", }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { out := buildChatPrompt(Task{ ChatSessionID: "sess-1", ChatChannelType: tc.channelType, ChatMessage: "hi", }) if got := strings.Contains(out, uploadGuidance); got != tc.wantUpload { t.Errorf("upload guidance present=%v, want %v\n--- output ---\n%s", got, tc.wantUpload, out) } if got := strings.Contains(out, historyGuidance); got != tc.wantHistory { t.Errorf("history guidance present=%v, want %v\n--- output ---\n%s", got, tc.wantHistory, out) } for _, phrase := range tc.wantPhrases { if !strings.Contains(out, phrase) { t.Errorf("missing %q\n--- output ---\n%s", phrase, out) } } }) } } // ChatInThread only ever selects between `chat history` and `chat thread`. With // no Feishu history reader there is nothing to select between, so the flag must // not leak either command into a Feishu prompt even if the server sets it. func TestBuildChatPromptFeishuIgnoresChatInThread(t *testing.T) { out := buildChatPrompt(Task{ ChatSessionID: "sess-1", ChatChannelType: execenv.ChannelTypeFeishu, ChatInThread: true, ChatMessage: "hi", }) for _, unwanted := range []string{"multica chat thread", "multica chat history"} { if strings.Contains(out, unwanted) { t.Errorf("feishu prompt must not teach %q (no Feishu history reader exists)\n--- output ---\n%s", unwanted, out) } } } func TestBuildChatPromptAgentIntro(t *testing.T) { // The proactive self-introduction chat (MUL-4230) has no user message: the // prompt must tell the agent to open the conversation itself, and must NOT // carry the generic "respond to their message" framing or an empty // "User message:" section that would confuse the agent. out := buildChatPrompt(Task{ChatSessionID: "sess-1", ChatIntro: true}) for _, want := range []string{ "You were just created", "you are opening the conversation", "introduce yourself", } { if !strings.Contains(out, want) { t.Fatalf("intro prompt missing %q\n--- output ---\n%s", want, out) } } for _, unwanted := range []string{"Respond to their message", "User message:"} { if strings.Contains(out, unwanted) { t.Fatalf("intro prompt should not contain %q\n--- output ---\n%s", unwanted, out) } } } func TestBuildChatPromptSlashSkills(t *testing.T) { t.Run("injects selected skills block", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "please [/deploy](slash://skill/abc-123) this", Agent: &AgentData{ Skills: []SkillData{{ID: "abc-123", Name: "deploy"}}, }, } out := buildChatPrompt(task) if !strings.Contains(out, "Explicitly selected skills:\n- deploy\n") { t.Fatalf("expected selected skills block, got:\n%s", out) } if !strings.Contains(out, "User message:\nplease [/deploy](slash://skill/abc-123) this") { t.Fatalf("expected raw user message preserved, got:\n%s", out) } }) t.Run("ignores skills not belonging to agent", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "[/hacker-skill](slash://skill/evil-id)", Agent: &AgentData{ Skills: []SkillData{{ID: "good-id", Name: "deploy"}}, }, } out := buildChatPrompt(task) if strings.Contains(out, "Explicitly selected skills") { t.Fatalf("should not inject block for unknown skill ID, got:\n%s", out) } }) t.Run("validates by ID not label", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "[/deploy](slash://skill/wrong-id)", Agent: &AgentData{ Skills: []SkillData{{ID: "real-id", Name: "deploy"}}, }, } out := buildChatPrompt(task) if strings.Contains(out, "Explicitly selected skills") { t.Fatalf("matching label with wrong ID must not pass, got:\n%s", out) } }) t.Run("uses canonical name not label", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "[/spoofed-name](slash://skill/real-id)", Agent: &AgentData{ Skills: []SkillData{{ID: "real-id", Name: "deploy"}}, }, } out := buildChatPrompt(task) if !strings.Contains(out, "- deploy\n") { t.Fatalf("expected canonical name 'deploy', got:\n%s", out) } if strings.Contains(out, "- spoofed-name\n") { t.Fatalf("selected skills block must not use spoofed label, got:\n%s", out) } if !strings.Contains(out, "User message:\n[/spoofed-name](slash://skill/real-id)") { t.Fatalf("expected raw user message with spoofed label preserved, got:\n%s", out) } }) t.Run("deduplicates skills", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "[/deploy](slash://skill/a) and [/deploy](slash://skill/a) again", Agent: &AgentData{ Skills: []SkillData{{ID: "a", Name: "deploy"}}, }, } out := buildChatPrompt(task) if strings.Count(out, "- deploy") != 1 { t.Fatalf("expected exactly 1 '- deploy', got:\n%s", out) } }) t.Run("omits block when no valid skills", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "just a normal message", Agent: &AgentData{Skills: []SkillData{{ID: "a", Name: "deploy"}}}, } out := buildChatPrompt(task) if strings.Contains(out, "Explicitly selected skills") { t.Fatalf("should not inject block when no slash links, got:\n%s", out) } }) t.Run("omits block when agent has no skills", func(t *testing.T) { task := Task{ ChatSessionID: "sess-1", ChatMessage: "[/deploy](slash://skill/abc-123)", Agent: &AgentData{}, } out := buildChatPrompt(task) if strings.Contains(out, "Explicitly selected skills") { t.Fatalf("should not inject block for agent with no skills, got:\n%s", out) } }) } // TestBuildPromptDefaultMentionsRecent pins that the catch-all fallback // prompt (no trigger comment, no chat, no autopilot, no quick-create) // starts assignment-triggered comment catch-up with a bounded recent read, // while still keeping older history available through pagination. func TestBuildPromptDefaultMentionsRecent(t *testing.T) { out := BuildPrompt(Task{IssueID: "issue-default-1"}, "claude") for _, s := range []string{ "multica issue comment list issue-default-1 --recent 10 --output json", "Next thread cursor:", "--since", } { if !strings.Contains(out, s) { t.Errorf("default BuildPrompt missing %q\n--- output ---\n%s", s, out) } } // And the default path must NOT inject a --thread example, because there // is no trigger comment id to anchor on. if strings.Contains(out, "--thread") { t.Errorf("default BuildPrompt should NOT mention --thread (no trigger comment to anchor on)\n--- output ---\n%s", out) } // The legacy "If you need comment history" soft phrasing conflicts with // the assignment-trigger runtime workflow, which treats reading comments // as mandatory. Guard against it sneaking back in. if strings.Contains(out, "If you need comment history") { t.Errorf("default BuildPrompt still carries the legacy 'If you need' soft phrasing that conflicts with the mandatory workflow\n--- output ---\n%s", out) } if strings.Contains(out, "multica issue comment list issue-default-1 --output json") { t.Errorf("default BuildPrompt still presents the unbounded flat read as the assignment catch-up command\n--- output ---\n%s", out) } } // TestBuildPromptNonSquadLeaderNoRule verifies that non-squad-leader agents // do NOT get the squad leader no_action rule injected. func TestBuildPromptNonSquadLeaderNoRule(t *testing.T) { task := Task{ IssueID: "issue-123", TriggerCommentID: "comment-456", TriggerCommentContent: "LGTM", TriggerAuthorType: "member", TriggerAuthorName: "Bohan", Agent: &AgentData{ Instructions: "Some instructions without the squad marker", }, } out := BuildPrompt(task, "claude") if strings.Contains(out, "Squad leader no_action rule") { t.Errorf("buildCommentPrompt must NOT inject squad leader no_action rule for non-squad-leader agents, got:\n%s", out) } } // TestBuildPromptNewCommentsHint pins that a comment-triggered task whose agent // ran before on this issue (NewCommentsSince set, NewCommentCount > 0) gets the // since-delta hint with the ISSUE-WIDE new-comment count, but is steered to read // the triggering (parent) thread first rather than blindly pulling every new // comment. func TestBuildPromptNewCommentsHint(t *testing.T) { const ( issueID = "issue-new-1" since = "2026-05-28T11:00:00Z" ) task := Task{ IssueID: issueID, TriggerCommentID: "trigger-1", TriggerThreadID: "thread-root-1", TriggerCommentContent: "please look", TriggerAuthorType: "member", NewCommentCount: 3, NewCommentsSince: since, } out := BuildPrompt(task, "claude") // Issue-wide count (reverted from the thread-scoped wording). if !strings.Contains(out, "3 new comment(s) on this issue since your last run") { t.Errorf("hint must report the issue-wide new-comment count, got:\n%s", out) } // Don't-blindly-read-all guidance. if !strings.Contains(out, "blindly") { t.Errorf("hint must discourage blindly reading every new comment, got:\n%s", out) } // Parent thread first: the --thread <trigger> read is the prioritized action. if !strings.Contains(out, "multica issue comment list "+issueID+" --thread thread-root-1 --since "+since+" --output json") { t.Errorf("hint must point at the triggering (parent) thread --since read first, got:\n%s", out) } if !strings.Contains(out, "--tail 30") { t.Errorf("hint must offer the full-thread (--tail 30) option, got:\n%s", out) } // Issue-wide catch-up is demoted to an only-if-needed fallback. if !strings.Contains(out, "multica issue comment list "+issueID+" --since "+since+" --output json") { t.Errorf("hint must keep the issue-wide --since catch-up as a fallback, got:\n%s", out) } // The old cursor-heavy paragraph must be gone. if strings.Contains(out, "Next reply cursor") || strings.Contains(out, "--before-id") { t.Errorf("the old cursor-pagination paragraph must not render, got:\n%s", out) } } // TestBuildPromptColdStartThreadRead pins the cold-start case: no prior run means // no since anchor (NewCommentsSince empty), so we suppress the delta hint and // instead point the agent at the triggering CONVERSATION (--thread <trigger> // --tail 30) rather than dumping the flat timeline. func TestBuildPromptColdStartThreadRead(t *testing.T) { const issueID = "issue-cold-1" task := Task{ IssueID: issueID, TriggerCommentID: "trigger-1", TriggerThreadID: "thread-root-1", TriggerCommentContent: "hi", TriggerAuthorType: "member", NewCommentCount: 0, NewCommentsSince: "", } out := BuildPrompt(task, "claude") if strings.Contains(out, "new comment(s) since your last run") { t.Errorf("no since-delta hint should render on cold start, got:\n%s", out) } if !strings.Contains(out, "multica issue comment list "+issueID+" --thread thread-root-1 --tail 30 --output json") { t.Errorf("cold start must point at the triggering thread read, got:\n%s", out) } if !strings.Contains(out, "multica issue comment list "+issueID+" --recent 10 --output json") { t.Errorf("cold start cross-thread fallback should use recent 10, got:\n%s", out) } if strings.Contains(out, "--recent 20") { t.Errorf("cold start cross-thread fallback still uses recent 20, got:\n%s", out) } } // TestBuildPromptResumedNoDeltaDoesNotForceThreadRead pins the warm/no-delta // path: when a prior provider session is actually being resumed, the triggering // comment is already embedded in the per-turn prompt, so the agent should not // be told to re-read the triggering thread's latest 30 replies by default. func TestBuildPromptResumedNoDeltaDoesNotForceThreadRead(t *testing.T) { const issueID = "issue-resumed-1" task := Task{ IssueID: issueID, TriggerCommentID: "trigger-1", TriggerThreadID: "thread-root-1", TriggerCommentContent: "hi again", TriggerAuthorType: "member", PriorSessionID: "session-123", NewCommentCount: 0, NewCommentsSince: "", } out := BuildPrompt(task, "claude") for _, want := range []string{ "triggering comment is already included above", "No other new comments on this issue since your last run", "active thread anchor `thread-root-1` and triggering comment ID `trigger-1`", "If your reply depends on thread context", "do not rely only on resumed session memory", "multica issue comment list " + issueID + " --thread thread-root-1 --tail 30 --output json", } { if !strings.Contains(out, want) { t.Errorf("resumed/no-delta prompt missing %q\n--- output ---\n%s", want, out) } } // The stale thread-scoped wording (since-delta used to be thread-scoped) // must not reappear. if strings.Contains(out, "scoped to the triggering thread") { t.Errorf("resumed/no-delta prompt must not claim the delta is thread-scoped, got:\n%s", out) } if strings.Contains(out, "Read the triggering conversation first") { t.Errorf("resumed/no-delta prompt must not use the cold-start forced-read wording, got:\n%s", out) } } // TestBuildCommentPromptCoalescedCrossThread pins MUL-4195 review should-fix #3: // when a run coalesces comments that span MULTIPLE threads, the prompt must // embed each folded comment's content with its OWN thread id instead of // claiming they all live in the triggering thread. The earlier version told the // agent "they are in the triggering thread" and handed a single `--thread` // command — wrong (and lossy) when the folded comments came from different // threads. func TestBuildCommentPromptCoalescedCrossThread(t *testing.T) { task := Task{ IssueID: "issue-xthread-1", TriggerCommentID: "trigger-newest", TriggerThreadID: "thread-root-A", TriggerCommentContent: "latest instruction", TriggerAuthorType: "member", CoalescedCommentIDs: []string{"c-old-1", "c-old-2"}, CoalescedComments: []CoalescedCommentData{ {ID: "c-old-1", ThreadID: "thread-root-A", AuthorType: "member", AuthorName: "Alice", Content: "first earlier comment", CreatedAt: "2026-07-08T01:00:00Z"}, {ID: "c-old-2", ThreadID: "thread-root-B", AuthorType: "member", AuthorName: "Bob", Content: "comment in a different thread", CreatedAt: "2026-07-08T02:00:00Z"}, }, } out := BuildPrompt(task, "claude") // The stale same-thread assumption must be gone. if strings.Contains(out, "they are in the triggering thread") { t.Errorf("prompt must not assume coalesced comments share the triggering thread, got:\n%s", out) } // Each folded comment's content is embedded directly, so the agent never // has to guess which thread to read to find it. for _, want := range []string{"first earlier comment", "comment in a different thread"} { if !strings.Contains(out, want) { t.Errorf("prompt must embed coalesced comment content %q, got:\n%s", want, out) } } // Each distinct thread id is surfaced so a follow-up fetch targets the // right thread — including the OTHER thread (B), not just the trigger's. for _, want := range []string{"thread-root-A", "thread-root-B"} { if !strings.Contains(out, want) { t.Errorf("prompt must surface coalesced comment thread id %q, got:\n%s", want, out) } } // Both coalesced comment ids remain referenced. for _, id := range []string{"c-old-1", "c-old-2"} { if !strings.Contains(out, id) { t.Errorf("prompt must reference coalesced comment id %s, got:\n%s", id, out) } } } // TestBuildCommentPromptCoalescedIDsOnlyFallback pins the old-server fallback: // when only coalesced ids are shipped (no embedded detail), the prompt must // still NOT assume a shared thread and must point at an issue-wide fetch. func TestBuildCommentPromptCoalescedIDsOnlyFallback(t *testing.T) { task := Task{ IssueID: "issue-fallback-1", TriggerCommentID: "trigger-newest", TriggerThreadID: "thread-root-A", TriggerCommentContent: "latest instruction", TriggerAuthorType: "member", CoalescedCommentIDs: []string{"c-old-1", "c-old-2"}, } out := BuildPrompt(task, "claude") if strings.Contains(out, "they are in the triggering thread") { t.Errorf("id-only fallback must not assume a shared thread, got:\n%s", out) } if !strings.Contains(out, "--recent 30") { t.Errorf("id-only fallback must point at an issue-wide fetch (--recent 30), got:\n%s", out) } for _, id := range []string{"c-old-1", "c-old-2"} { if !strings.Contains(out, id) { t.Errorf("id-only fallback must reference coalesced comment id %s, got:\n%s", id, out) } } } // TestCommentReplyThreadsGrouping pins the server-side grouping that drives // per-thread reply routing (MUL-4348). The invariants: // - three distinct root threads → three targets, each replying to its own // thread (the trigger's thread replies under the trigger comment itself). // - multiple coalesced follow-ups in the SAME thread → a single group, so the // caller keeps the single-parent path and the reply is never duplicated. // - no coalesced comments (ordinary single comment) → nil. func TestCommentReplyThreadsGrouping(t *testing.T) { t.Run("three distinct root threads fan out", func(t *testing.T) { task := Task{ TriggerCommentID: "c3", TriggerThreadID: "c3", // a root comment is its own thread CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "c1", Content: "背一首宋词"}, {ID: "c2", ThreadID: "c2", Content: "毛泽东诗词背一首"}, }, } targets := commentReplyThreads(task) if len(targets) != 3 { t.Fatalf("want 3 targets, got %d: %+v", len(targets), targets) } wantParent := map[string]string{"c1": "c1", "c2": "c2", "c3": "c3"} for _, tgt := range targets { if wantParent[tgt.ThreadID] != tgt.ParentID { t.Errorf("thread %s: parent = %s, want %s", tgt.ThreadID, tgt.ParentID, wantParent[tgt.ThreadID]) } } }) t.Run("same-thread follow-ups consolidate to a single group", func(t *testing.T) { task := Task{ TriggerCommentID: "c3", TriggerThreadID: "thread-A", CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "thread-A", Content: "追问 1"}, {ID: "c2", ThreadID: "thread-A", Content: "追问 2"}, }, } if targets := commentReplyThreads(task); targets != nil { t.Fatalf("same-thread follow-ups must not fan out; got %d targets: %+v", len(targets), targets) } }) t.Run("mixed: trigger thread plus one other thread", func(t *testing.T) { task := Task{ TriggerCommentID: "c3", TriggerThreadID: "thread-A", CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "thread-A", Content: "same-thread follow-up"}, {ID: "c2", ThreadID: "thread-B", Content: "other thread"}, }, } targets := commentReplyThreads(task) if len(targets) != 2 { t.Fatalf("want 2 targets (thread-A, thread-B), got %d: %+v", len(targets), targets) } got := map[string]string{} for _, tgt := range targets { got[tgt.ThreadID] = tgt.ParentID } // The trigger's own thread replies under the trigger comment, not its root. if got["thread-A"] != "c3" { t.Errorf("trigger thread parent = %q, want c3 (the trigger comment)", got["thread-A"]) } // The other thread replies under the specific comment that mentioned the // agent (a mid-thread reply), not the thread root — fixes the placement // asymmetry from the first cut. if got["thread-B"] != "c2" { t.Errorf("other thread parent = %q, want c2 (the specific mentioning comment)", got["thread-B"]) } }) t.Run("no coalesced comments → nil", func(t *testing.T) { task := Task{TriggerCommentID: "c1", TriggerThreadID: "thread-A"} if targets := commentReplyThreads(task); targets != nil { t.Fatalf("ordinary single-comment run must not fan out; got %+v", targets) } }) t.Run("non-trigger thread replies under its newest mention, not root", func(t *testing.T) { // Two mid-thread mentions in thread-B (oldest c1, newer c2); the reply // should target the newest specific comment (c2), not the root thread-B. task := Task{ TriggerCommentID: "c9", TriggerThreadID: "thread-A", CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "thread-B", Content: "older mention", CreatedAt: "2026-07-10T01:00:00Z"}, {ID: "c2", ThreadID: "thread-B", Content: "newer mention", CreatedAt: "2026-07-10T02:00:00Z"}, }, } targets := commentReplyThreads(task) got := map[string]string{} for _, tgt := range targets { got[tgt.ThreadID] = tgt.ParentID } if got["thread-B"] != "c2" { t.Errorf("thread-B parent = %q, want newest mention c2 (not root)", got["thread-B"]) } if got["thread-A"] != "c9" { t.Errorf("trigger thread parent = %q, want trigger c9", got["thread-A"]) } }) } // TestBuildCommentPromptCrossThreadFansOutReplies is the end-to-end prompt // assertion for the screenshot scenario: three separate root comments coalesced // into one run must produce a per-thread reply plan (one reply per thread), // explicitly overriding the "one comment per run" rule, instead of the single // --parent cookbook. func TestBuildCommentPromptCrossThreadFansOutReplies(t *testing.T) { task := Task{ IssueID: "issue-xthread-2", TriggerCommentID: "c3", TriggerThreadID: "c3", TriggerCommentContent: "莎士比亚名言来一句", TriggerAuthorType: "member", CoalescedCommentIDs: []string{"c1", "c2"}, CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "c1", AuthorType: "member", AuthorName: "Yushen", Content: "背一首宋词", CreatedAt: "2026-07-10T01:00:00Z"}, {ID: "c2", ThreadID: "c2", AuthorType: "member", AuthorName: "Yushen", Content: "毛泽东诗词背一首", CreatedAt: "2026-07-10T02:00:00Z"}, }, } out := BuildPrompt(task, "claude") for _, want := range []string{ "3 DISTINCT threads", "Post ONE reply per thread", "OVERRIDES", "--parent c1", "--parent c2", "--parent c3", } { if !strings.Contains(out, want) { t.Errorf("cross-thread prompt must contain %q, got:\n%s", want, out) } } // The single-parent cookbook must NOT be used when fanning out. if strings.Contains(out, "always use the trigger comment ID below") { t.Errorf("cross-thread prompt must not emit the single-parent reply cookbook, got:\n%s", out) } // Chronological ordering (MUL-4348 test-round-2 problem #1): replies must be // posted oldest thread first, the newest (triggering) thread last — so the // coalesced comments c1 (oldest) and c2 come before the trigger c3. if !strings.Contains(out, "OLDEST thread first") { t.Errorf("cross-thread prompt must instruct oldest-first chronological order, got:\n%s", out) } posC1 := strings.Index(out, "--parent c1") posC2 := strings.Index(out, "--parent c2") posC3 := strings.Index(out, "--parent c3") if !(posC1 >= 0 && posC1 < posC2 && posC2 < posC3) { t.Errorf("reply targets must be listed oldest-first (c1 < c2 < c3); got positions c1=%d c2=%d c3=%d\n%s", posC1, posC2, posC3, out) } } // TestBuildCommentPromptSameThreadKeepsSingleReply pins the hard requirement: // multiple @mentions coalesced from the SAME thread must keep the ordinary // single-parent reply path (one reply, under the trigger comment) and must NOT // trigger the multi-thread fan-out. func TestBuildCommentPromptSameThreadKeepsSingleReply(t *testing.T) { task := Task{ IssueID: "issue-samethread-1", TriggerCommentID: "c3", TriggerThreadID: "thread-A", TriggerCommentContent: "追问 3", TriggerAuthorType: "member", CoalescedCommentIDs: []string{"c1", "c2"}, CoalescedComments: []CoalescedCommentData{ {ID: "c1", ThreadID: "thread-A", AuthorType: "member", AuthorName: "Yushen", Content: "追问 1", CreatedAt: "2026-07-10T01:00:00Z"}, {ID: "c2", ThreadID: "thread-A", AuthorType: "member", AuthorName: "Yushen", Content: "追问 2", CreatedAt: "2026-07-10T02:00:00Z"}, }, } out := BuildPrompt(task, "claude") if strings.Contains(out, "DISTINCT threads") { t.Errorf("same-thread coalescing must not emit the multi-thread fan-out block, got:\n%s", out) } // The single-parent cookbook is used, threading the one reply under the // trigger comment. if !strings.Contains(out, "--parent c3 --content-file ./reply.md") { t.Errorf("same-thread run must keep the single --parent=trigger reply cookbook, got:\n%s", out) } }