mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
fix(comments): align trigger preview context (#4147)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -283,6 +283,10 @@ function useEditAttachmentState(
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSuppressedAgentIds(new Set());
|
||||
}, [issueId, entry.id, entry.parent_id]);
|
||||
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
enabled: editing,
|
||||
@@ -579,6 +583,7 @@ function CommentRow({
|
||||
agents={edit.triggerPreview.agents}
|
||||
suppressedAgentIds={edit.suppressedAgentIds}
|
||||
onToggle={edit.toggleSuppressedAgent}
|
||||
context="edit"
|
||||
/>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
@@ -859,6 +864,7 @@ function CommentCardImpl({
|
||||
agents={edit.triggerPreview.agents}
|
||||
suppressedAgentIds={edit.suppressedAgentIds}
|
||||
onToggle={edit.toggleSuppressedAgent}
|
||||
context="edit"
|
||||
/>
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
|
||||
@@ -71,6 +71,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSuppressedAgentIds(new Set());
|
||||
}, [issueId]);
|
||||
|
||||
useEffect(() => {
|
||||
const visible = new Set(triggerPreview.agents.map((agent) => agent.id));
|
||||
setSuppressedAgentIds((prev) => {
|
||||
@@ -151,6 +155,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
agents={triggerPreview.agents}
|
||||
suppressedAgentIds={suppressedAgentIds}
|
||||
onToggle={toggleSuppressedAgent}
|
||||
context="comment"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("CommentTriggerChips", () => {
|
||||
);
|
||||
|
||||
const chip = screen.getByRole("button");
|
||||
expect(chip).toHaveTextContent("Comment");
|
||||
expect(chip).toHaveTextContent("Starts working when sent");
|
||||
expect(chip).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
@@ -52,6 +53,19 @@ describe("CommentTriggerChips", () => {
|
||||
expect(onToggle).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("labels reply trigger chips by composer context", () => {
|
||||
renderWithI18n(
|
||||
<CommentTriggerChips
|
||||
agents={[walt]}
|
||||
suppressedAgentIds={new Set()}
|
||||
onToggle={vi.fn()}
|
||||
context="reply"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Reply");
|
||||
});
|
||||
|
||||
it("dims a suppressed single agent into the skip state", () => {
|
||||
renderWithI18n(
|
||||
<CommentTriggerChips
|
||||
@@ -109,11 +123,13 @@ describe("CommentTriggerChips", () => {
|
||||
agents={[walt, bob]}
|
||||
suppressedAgentIds={new Set()}
|
||||
onToggle={onToggle}
|
||||
context="reply"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(screen.getByText("This reply will trigger")).toBeInTheDocument();
|
||||
const row = screen.getByRole("button", { name: /Bob/ });
|
||||
expect(row).toHaveTextContent("Bob");
|
||||
fireEvent.click(row);
|
||||
|
||||
@@ -29,9 +29,11 @@ interface CommentTriggerChipsProps {
|
||||
agents: CommentTriggerPreviewAgent[];
|
||||
suppressedAgentIds: Set<string>;
|
||||
onToggle: (agentId: string) => void;
|
||||
context?: CommentTriggerContext;
|
||||
}
|
||||
|
||||
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
|
||||
type CommentTriggerContext = "comment" | "reply" | "edit";
|
||||
|
||||
function sourceLabel(source: string, t: IssuesT): string {
|
||||
switch (source) {
|
||||
@@ -59,6 +61,28 @@ function sourceReason(agent: CommentTriggerPreviewAgent, t: IssuesT): string {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerContextLabel(context: CommentTriggerContext, t: IssuesT): string {
|
||||
switch (context) {
|
||||
case "reply":
|
||||
return t(($) => $.comment.trigger_context_reply);
|
||||
case "edit":
|
||||
return t(($) => $.comment.trigger_context_edit);
|
||||
default:
|
||||
return t(($) => $.comment.trigger_context_comment);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPreviewTitle(context: CommentTriggerContext, t: IssuesT): string {
|
||||
switch (context) {
|
||||
case "reply":
|
||||
return t(($) => $.comment.trigger_preview_title_reply);
|
||||
case "edit":
|
||||
return t(($) => $.comment.trigger_preview_title_edit);
|
||||
default:
|
||||
return t(($) => $.comment.trigger_preview_title_comment);
|
||||
}
|
||||
}
|
||||
|
||||
// Presence is display metadata only — the trigger list itself is always the
|
||||
// backend preview. Online-ish agents start right away; offline ones queue.
|
||||
function useTriggerPresenceLine(agentId: string, t: IssuesT): string | null {
|
||||
@@ -107,6 +131,7 @@ export function CommentTriggerChips({
|
||||
agents,
|
||||
suppressedAgentIds,
|
||||
onToggle,
|
||||
context = "comment",
|
||||
}: CommentTriggerChipsProps) {
|
||||
const { t } = useT("issues");
|
||||
|
||||
@@ -121,6 +146,7 @@ export function CommentTriggerChips({
|
||||
agent={agent}
|
||||
suppressed={suppressedAgentIds.has(agent.id)}
|
||||
onToggle={onToggle}
|
||||
context={context}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
@@ -131,20 +157,42 @@ export function CommentTriggerChips({
|
||||
agents={agents}
|
||||
suppressedAgentIds={suppressedAgentIds}
|
||||
onToggle={onToggle}
|
||||
context={context}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerContextPrefix({
|
||||
context,
|
||||
t,
|
||||
}: {
|
||||
context: CommentTriggerContext;
|
||||
t: IssuesT;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-normal text-muted-foreground/80">
|
||||
{triggerContextLabel(context, t)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleTriggerChip({
|
||||
agent,
|
||||
suppressed,
|
||||
onToggle,
|
||||
context,
|
||||
t,
|
||||
}: {
|
||||
agent: CommentTriggerPreviewAgent;
|
||||
suppressed: boolean;
|
||||
onToggle: (agentId: string) => void;
|
||||
context: CommentTriggerContext;
|
||||
t: IssuesT;
|
||||
}) {
|
||||
const state = suppressed
|
||||
@@ -172,6 +220,7 @@ function SingleTriggerChip({
|
||||
suppressed && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<TriggerContextPrefix context={context} t={t} />
|
||||
<TriggerAgentAvatar agent={agent} suppressed={suppressed} />
|
||||
<span className="truncate">{sentence}</span>
|
||||
</button>
|
||||
@@ -188,11 +237,13 @@ function MultiTriggerChip({
|
||||
agents,
|
||||
suppressedAgentIds,
|
||||
onToggle,
|
||||
context,
|
||||
t,
|
||||
}: {
|
||||
agents: CommentTriggerPreviewAgent[];
|
||||
suppressedAgentIds: Set<string>;
|
||||
onToggle: (agentId: string) => void;
|
||||
context: CommentTriggerContext;
|
||||
t: IssuesT;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -225,6 +276,7 @@ function MultiTriggerChip({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TriggerContextPrefix context={context} t={t} />
|
||||
<span className="inline-flex items-center">
|
||||
{heads.map((agent, i) => (
|
||||
<span
|
||||
@@ -267,7 +319,7 @@ function MultiTriggerChip({
|
||||
</Tooltip>
|
||||
<PopoverContent align="start" className="w-64 p-2">
|
||||
<div className="px-1.5 pb-1 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.comment.trigger_preview_title)}
|
||||
{triggerPreviewTitle(context, t)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{agents.map((agent) => {
|
||||
|
||||
@@ -94,6 +94,10 @@ function ReplyInput({
|
||||
return result;
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSuppressedAgentIds(new Set());
|
||||
}, [issueId, parentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const visible = new Set(triggerPreview.agents.map((agent) => agent.id));
|
||||
setSuppressedAgentIds((prev) => {
|
||||
@@ -185,6 +189,7 @@ function ReplyInput({
|
||||
agents={triggerPreview.agents}
|
||||
suppressedAgentIds={suppressedAgentIds}
|
||||
onToggle={toggleSuppressedAgent}
|
||||
context="reply"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 flex items-center gap-1">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
|
||||
import {
|
||||
commentTriggerPreviewSignature,
|
||||
isNoteCommentDraft,
|
||||
@@ -16,6 +17,18 @@ vi.mock("@multica/core/api", () => ({
|
||||
}));
|
||||
|
||||
const previewCommentTriggers = vi.mocked(api.previewCommentTriggers);
|
||||
const waltAgent: CommentTriggerPreviewAgent = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
name: "Walt",
|
||||
source: "issue_assignee",
|
||||
reason: "",
|
||||
};
|
||||
const kimAgent: CommentTriggerPreviewAgent = {
|
||||
id: "00000000-0000-0000-0000-000000000002",
|
||||
name: "Kim",
|
||||
source: "mention_agent",
|
||||
reason: "",
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -107,6 +120,76 @@ describe("useCommentTriggerPreview", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show previous agents while parent context changes", async () => {
|
||||
previewCommentTriggers
|
||||
.mockResolvedValueOnce({ agents: [waltAgent] })
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ parentId }) =>
|
||||
useCommentTriggerPreview({
|
||||
issueId: "issue-1",
|
||||
parentId,
|
||||
content: "plain reply",
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
initialProps: { parentId: "walt-thread" },
|
||||
},
|
||||
);
|
||||
|
||||
await advancePreviewDebounce();
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.agents).toEqual([waltAgent]);
|
||||
});
|
||||
|
||||
rerender({ parentId: "kim-thread" });
|
||||
await act(async () => {});
|
||||
|
||||
expect(result.current.agents).toEqual([]);
|
||||
expect(previewCommentTriggers).toHaveBeenLastCalledWith(
|
||||
"issue-1",
|
||||
"plain reply",
|
||||
"kim-thread",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps previous agents while the same context refetches", async () => {
|
||||
previewCommentTriggers
|
||||
.mockResolvedValueOnce({ agents: [waltAgent] })
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) =>
|
||||
useCommentTriggerPreview({
|
||||
issueId: "issue-1",
|
||||
parentId: "same-thread",
|
||||
content,
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
initialProps: { content: `[@Walt](mention://agent/${waltAgent.id})` },
|
||||
},
|
||||
);
|
||||
|
||||
await advancePreviewDebounce();
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.agents).toEqual([waltAgent]);
|
||||
});
|
||||
|
||||
rerender({ content: `[@Kim](mention://agent/${kimAgent.id})` });
|
||||
await advancePreviewDebounce();
|
||||
|
||||
expect(result.current.agents).toEqual([waltAgent]);
|
||||
expect(previewCommentTriggers).toHaveBeenLastCalledWith(
|
||||
"issue-1",
|
||||
`[@Kim](mention://agent/${kimAgent.id})`,
|
||||
"same-thread",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("revalidates when the debounced signature repeats — the answer is queue-state dependent", async () => {
|
||||
const agentA = "00000000-0000-0000-0000-000000000001";
|
||||
const content = `[@A](mention://agent/${agentA})`;
|
||||
|
||||
@@ -36,6 +36,21 @@ export function commentTriggerPreviewSignature(content: string): string {
|
||||
return `nonempty|${tokens.join(",")}`;
|
||||
}
|
||||
|
||||
function queryKeyMatchesPreviewContext(
|
||||
queryKey: readonly unknown[] | undefined,
|
||||
issueId: string,
|
||||
parentId: string,
|
||||
editingCommentId: string,
|
||||
) {
|
||||
if (!queryKey) return false;
|
||||
const prefix = issueKeys.commentTriggerPreview(issueId);
|
||||
return (
|
||||
prefix.every((part, index) => queryKey[index] === part) &&
|
||||
queryKey[prefix.length] === parentId &&
|
||||
queryKey[prefix.length + 1] === editingCommentId
|
||||
);
|
||||
}
|
||||
|
||||
function useDebouncedSignature(signature: string) {
|
||||
const [debouncedSignature, setDebouncedSignature] = useState("empty");
|
||||
|
||||
@@ -69,13 +84,15 @@ export function useCommentTriggerPreview({
|
||||
const signature = useMemo(() => commentTriggerPreviewSignature(content), [content]);
|
||||
const debouncedSignature = useDebouncedSignature(signature);
|
||||
const contentRef = useRef(content);
|
||||
const parentKey = parentId ?? "";
|
||||
const editingKey = editingCommentId ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current = content;
|
||||
}, [content]);
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: [...issueKeys.commentTriggerPreview(issueId), parentId ?? "", editingCommentId ?? "", debouncedSignature],
|
||||
queryKey: [...issueKeys.commentTriggerPreview(issueId), parentKey, editingKey, debouncedSignature],
|
||||
queryFn: () => api.previewCommentTriggers(issueId, contentRef.current, parentId, editingCommentId),
|
||||
enabled: signature !== "empty" && debouncedSignature !== "empty",
|
||||
retry: false,
|
||||
@@ -84,10 +101,13 @@ export function useCommentTriggerPreview({
|
||||
// reappears — Infinity here once pinned a stale "nobody triggers"
|
||||
// snapshot taken while the agent was still queued.
|
||||
staleTime: 0,
|
||||
// Keep the previous agent list while a new signature is fetching:
|
||||
// without it the in-flight gap renders as "no agents", flickering the
|
||||
// chips and wiping the composer's suppressed-id set.
|
||||
placeholderData: keepPreviousData,
|
||||
// Keep the previous agent list only while the same composer context is
|
||||
// re-fetching. Crossing issue/parent/edit context must not display stale
|
||||
// chips from another composer.
|
||||
placeholderData: (previousData, previousQuery) =>
|
||||
queryKeyMatchesPreviewContext(previousQuery?.queryKey, issueId, parentKey, editingKey)
|
||||
? keepPreviousData(previousData)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Loading and errors intentionally surface as "no agents": the preview is
|
||||
|
||||
@@ -279,6 +279,9 @@
|
||||
"trigger_source_mention_agent": "@mention",
|
||||
"trigger_source_mention_squad_leader": "squad",
|
||||
"trigger_source_unknown": "trigger",
|
||||
"trigger_context_comment": "Comment",
|
||||
"trigger_context_reply": "Reply",
|
||||
"trigger_context_edit": "Edit",
|
||||
"trigger_skipped_label": "Skipped",
|
||||
"trigger_wont_trigger": "Won't be triggered",
|
||||
"trigger_none_will_trigger": "No agents will be triggered",
|
||||
@@ -295,6 +298,9 @@
|
||||
"trigger_click_to_manage": "Click to manage who gets triggered this time.",
|
||||
"trigger_click_to_restore": "Won't be triggered this time. Click to restore.",
|
||||
"trigger_preview_title": "This comment will trigger",
|
||||
"trigger_preview_title_comment": "This comment will trigger",
|
||||
"trigger_preview_title_reply": "This reply will trigger",
|
||||
"trigger_preview_title_edit": "This edit will trigger",
|
||||
"trigger_chip_aria": "{{name}} trigger: {{state}}",
|
||||
"resolve": {
|
||||
"resolve_thread_action": "Resolve thread",
|
||||
|
||||
@@ -271,6 +271,9 @@
|
||||
"trigger_source_mention_agent": "@メンション",
|
||||
"trigger_source_mention_squad_leader": "Squad",
|
||||
"trigger_source_unknown": "トリガー",
|
||||
"trigger_context_comment": "コメント",
|
||||
"trigger_context_reply": "返信",
|
||||
"trigger_context_edit": "編集",
|
||||
"trigger_skipped_label": "スキップ",
|
||||
"trigger_wont_trigger": "トリガーされません",
|
||||
"trigger_none_will_trigger": "いずれもトリガーされません",
|
||||
@@ -286,6 +289,9 @@
|
||||
"trigger_click_to_manage": "クリックして今回のトリガー対象を管理します。",
|
||||
"trigger_click_to_restore": "今回はトリガーされません。クリックで元に戻せます。",
|
||||
"trigger_preview_title": "このコメントでトリガーされる対象",
|
||||
"trigger_preview_title_comment": "このコメントでトリガーされる対象",
|
||||
"trigger_preview_title_reply": "この返信でトリガーされる対象",
|
||||
"trigger_preview_title_edit": "この編集でトリガーされる対象",
|
||||
"trigger_chip_aria": "{{name}} のトリガー: {{state}}",
|
||||
"resolve": {
|
||||
"resolve_thread_action": "スレッドを解決",
|
||||
|
||||
@@ -279,6 +279,9 @@
|
||||
"trigger_source_mention_agent": "@멘션",
|
||||
"trigger_source_mention_squad_leader": "Squad",
|
||||
"trigger_source_unknown": "트리거",
|
||||
"trigger_context_comment": "댓글",
|
||||
"trigger_context_reply": "답글",
|
||||
"trigger_context_edit": "편집",
|
||||
"trigger_skipped_label": "건너뜀",
|
||||
"trigger_wont_trigger": "트리거되지 않습니다",
|
||||
"trigger_none_will_trigger": "모두 트리거되지 않습니다",
|
||||
@@ -294,6 +297,9 @@
|
||||
"trigger_click_to_manage": "클릭하여 이번 트리거 대상을 관리합니다.",
|
||||
"trigger_click_to_restore": "이번에는 트리거되지 않습니다. 클릭하면 복원됩니다.",
|
||||
"trigger_preview_title": "이 댓글로 트리거되는 대상",
|
||||
"trigger_preview_title_comment": "이 댓글로 트리거되는 대상",
|
||||
"trigger_preview_title_reply": "이 답글로 트리거되는 대상",
|
||||
"trigger_preview_title_edit": "이 편집으로 트리거되는 대상",
|
||||
"trigger_chip_aria": "{{name}} 트리거: {{state}}",
|
||||
"resolve": {
|
||||
"resolve_thread_action": "스레드 해결",
|
||||
|
||||
@@ -276,6 +276,9 @@
|
||||
"trigger_source_mention_agent": "@mention",
|
||||
"trigger_source_mention_squad_leader": "squad",
|
||||
"trigger_source_unknown": "将触发",
|
||||
"trigger_context_comment": "评论",
|
||||
"trigger_context_reply": "回复",
|
||||
"trigger_context_edit": "编辑",
|
||||
"trigger_skipped_label": "已跳过",
|
||||
"trigger_wont_trigger": "不会触发",
|
||||
"trigger_none_will_trigger": "全部不会触发",
|
||||
@@ -291,6 +294,9 @@
|
||||
"trigger_click_to_manage": "点击管理本次触发对象。",
|
||||
"trigger_click_to_restore": "本次不触发,点击恢复。",
|
||||
"trigger_preview_title": "这条评论将触发",
|
||||
"trigger_preview_title_comment": "这条评论将触发",
|
||||
"trigger_preview_title_reply": "这条回复将触发",
|
||||
"trigger_preview_title_edit": "本次编辑将触发",
|
||||
"trigger_chip_aria": "{{name}} 触发状态:{{state}}",
|
||||
"resolve": {
|
||||
"resolve_thread_action": "解决该讨论",
|
||||
|
||||
@@ -873,7 +873,7 @@ func (h *Handler) PreviewCommentTriggers(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
var parentID pgtype.UUID
|
||||
if req.ParentID != nil {
|
||||
parentID, ok := parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
|
||||
parentID, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func createCommentTriggerPreviewIssue(t *testing.T, title string, assigneeType,
|
||||
return issueID
|
||||
}
|
||||
|
||||
func previewCommentTriggersForTest(t *testing.T, issueID string, body map[string]any) CommentTriggerPreviewResponse {
|
||||
func previewCommentTriggersForTest(t *testing.T, issueID string, body any) CommentTriggerPreviewResponse {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -85,6 +85,20 @@ func postCommentForTriggerPreviewTest(t *testing.T, issueID string, body map[str
|
||||
return resp.ID
|
||||
}
|
||||
|
||||
func insertMemberRootCommentForTriggerPreviewTest(t *testing.T, issueID, content string) string {
|
||||
t.Helper()
|
||||
|
||||
var commentID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content)
|
||||
VALUES ($1, $2, 'member', $3, $4)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, issueID, testUserID, content).Scan(&commentID); err != nil {
|
||||
t.Fatalf("insert member root comment: %v", err)
|
||||
}
|
||||
return commentID
|
||||
}
|
||||
|
||||
func updateCommentForTriggerPreviewTest(t *testing.T, commentID string, body map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
@@ -143,6 +157,53 @@ func requirePreviewAgents(t *testing.T, preview CommentTriggerPreviewResponse, w
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCommentTriggers_MatchesCreateForInheritedParentMention(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
waltID := createHandlerTestAgent(t, "Preview Inherit Walt", nil)
|
||||
kimID := createHandlerTestAgent(t, "Preview Inherit Kim", nil)
|
||||
issueID := createCommentTriggerPreviewIssue(t, "comment trigger preview inherits parent mention", "agent", waltID)
|
||||
|
||||
topLevelPreview := previewCommentTriggersForTest(t, issueID, CommentTriggerPreviewRequest{
|
||||
Content: "hello from the root composer",
|
||||
})
|
||||
requirePreviewAgents(t, topLevelPreview, waltID)
|
||||
|
||||
rootContent := fmt.Sprintf("[@Kim](mention://agent/%s) can you inspect this?", kimID)
|
||||
rootID := insertMemberRootCommentForTriggerPreviewTest(t, issueID, rootContent)
|
||||
if got := countQueuedCommentTriggerTasks(t, issueID, kimID); got != 0 {
|
||||
t.Fatalf("fixture queued Kim tasks = %d, want 0", got)
|
||||
}
|
||||
if got := countQueuedCommentTriggerTasks(t, issueID, waltID); got != 0 {
|
||||
t.Fatalf("fixture queued Walt tasks = %d, want 0", got)
|
||||
}
|
||||
|
||||
replyContent := "plain reply with no mention"
|
||||
replyParentID := rootID
|
||||
replyBody := map[string]any{
|
||||
"content": replyContent,
|
||||
"parent_id": rootID,
|
||||
}
|
||||
replyPreview := previewCommentTriggersForTest(t, issueID, CommentTriggerPreviewRequest{
|
||||
Content: replyContent,
|
||||
ParentID: &replyParentID,
|
||||
})
|
||||
requirePreviewAgents(t, replyPreview, kimID)
|
||||
if replyPreview.Agents[0].Source != string(commentTriggerSourceMentionAgent) {
|
||||
t.Fatalf("reply preview source = %q, want %q", replyPreview.Agents[0].Source, commentTriggerSourceMentionAgent)
|
||||
}
|
||||
|
||||
postCommentForTriggerPreviewTest(t, issueID, replyBody)
|
||||
if got := countQueuedCommentTriggerTasks(t, issueID, kimID); got != 1 {
|
||||
t.Fatalf("plain reply queued Kim tasks = %d, want 1", got)
|
||||
}
|
||||
if got := countQueuedCommentTriggerTasks(t, issueID, waltID); got != 0 {
|
||||
t.Fatalf("plain reply queued Walt tasks = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCommentTriggers_ReturnsMentionedAgentsAndSuppressFiltersCreate(t *testing.T) {
|
||||
if testHandler == nil || testPool == nil {
|
||||
t.Skip("database not available")
|
||||
|
||||
Reference in New Issue
Block a user