fix(FN-7965): surface duplicate decisions
Show a clear operator-decision badge and deliver an idempotent mailbox prompt for triage duplicate markers. Fusion-Task-Id: FN-7965
This commit is contained in:
7
.changeset/fn-7965-duplicate-feedback.md
Normal file
7
.changeset/fn-7965-duplicate-feedback.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Surface duplicate-decision tasks on cards and in the operator mailbox.
|
||||||
|
category: fix
|
||||||
|
dev: Triage-marker duplicate decisions now use an idempotent task-linked system message.
|
||||||
@@ -430,6 +430,12 @@ The Plan Review "Reviewing" badge must read as an active review state without ad
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-status-badge.needs-user-feedback {
|
||||||
|
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.card-status-badge.awaiting-approval {
|
.card-status-badge.awaiting-approval {
|
||||||
background: var(--status-triage-bg-deep);
|
background: var(--status-triage-bg-deep);
|
||||||
color: var(--triage);
|
color: var(--triage);
|
||||||
|
|||||||
@@ -1263,6 +1263,10 @@ function TaskCardComponent({
|
|||||||
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingRecovery;
|
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingRecovery;
|
||||||
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
|
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
|
||||||
const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true);
|
const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true);
|
||||||
|
const isTriageDuplicateDecision = isPaused
|
||||||
|
&& task.pausedReason === "duplicate-decision-required"
|
||||||
|
&& task.sourceMetadata?.duplicateSource === "triage-marker"
|
||||||
|
&& typeof task.sourceMetadata?.nearDuplicateOf === "string";
|
||||||
const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId);
|
const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId);
|
||||||
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
||||||
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
|
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
|
||||||
@@ -2984,9 +2988,15 @@ function TaskCardComponent({
|
|||||||
<div className="card-header-badges" data-testid="card-header-badges">
|
<div className="card-header-badges" data-testid="card-header-badges">
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<span
|
<span
|
||||||
className="card-status-badge paused"
|
className={`card-status-badge paused${isTriageDuplicateDecision ? " needs-user-feedback" : ""}`}
|
||||||
|
title={isTriageDuplicateDecision
|
||||||
|
? t("tasks.duplicateDecisionRequiredTitle", "This task is a duplicate candidate and needs your decision.")
|
||||||
|
: undefined}
|
||||||
|
data-testid={isTriageDuplicateDecision ? `card-needs-user-feedback-${task.id}` : undefined}
|
||||||
>
|
>
|
||||||
{pausedByAgent ? t("tasks.pausedByAgent", "paused by agent") : t("tasks.paused", "paused")}
|
{isTriageDuplicateDecision
|
||||||
|
? t("tasks.needsUserFeedback", "Needs your decision")
|
||||||
|
: pausedByAgent ? t("tasks.pausedByAgent", "paused by agent") : t("tasks.paused", "paused")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showStatusBadge && (
|
{showStatusBadge && (
|
||||||
|
|||||||
@@ -6175,6 +6175,23 @@ describe("TaskCard provider icons on agent row", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskCard near-duplicate chip", () => {
|
describe("TaskCard near-duplicate chip", () => {
|
||||||
|
it("shows a needs-user-feedback status when a triage duplicate is paused for a decision", () => {
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
paused: true,
|
||||||
|
pausedReason: "duplicate-decision-required",
|
||||||
|
sourceMetadata: { nearDuplicateOf: "FN-1234", duplicateSource: "triage-marker" },
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("card-needs-user-feedback-FN-001")).toHaveTextContent("Needs your decision");
|
||||||
|
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders duplicate chip when nearDuplicateOf is present", () => {
|
it("renders duplicate chip when nearDuplicateOf is present", () => {
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
|||||||
@@ -262,6 +262,42 @@ describe("NotificationService", () => {
|
|||||||
expect(input.content).toContain("https://dash.example/?project=p1&task=FN-1");
|
expect(input.content).toContain("https://dash.example/?project=p1&task=FN-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes one mailbox message when a triage duplicate needs an operator decision", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: false });
|
||||||
|
const idempotencyKeys = new Set<string>();
|
||||||
|
let insertedCount = 0;
|
||||||
|
const sendMessageOnce = vi.fn(async (input: any, key: string) => {
|
||||||
|
const inserted = !idempotencyKeys.has(key);
|
||||||
|
idempotencyKeys.add(key);
|
||||||
|
if (inserted) insertedCount += 1;
|
||||||
|
return { message: { ...input, id: "msg-once-duplicate", read: false, createdAt: "", updatedAt: "" }, inserted };
|
||||||
|
});
|
||||||
|
const messageStore = Object.assign(new EventEmitter(), { sendMessageOnce });
|
||||||
|
const service = new NotificationService(store as any, { projectId: "p1", messageStore: messageStore as any });
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
const duplicate = task({
|
||||||
|
paused: true,
|
||||||
|
pausedReason: "duplicate-decision-required",
|
||||||
|
sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: "FN-7961" },
|
||||||
|
});
|
||||||
|
store.emit("task:updated", duplicate);
|
||||||
|
store.emit("task:updated", duplicate);
|
||||||
|
await vi.waitFor(() => expect(sendMessageOnce).toHaveBeenCalledTimes(2));
|
||||||
|
expect(insertedCount).toBe(1);
|
||||||
|
|
||||||
|
for (const [input, key] of sendMessageOnce.mock.calls) {
|
||||||
|
expect(key).toBe("triage-duplicate-decision:FN-1");
|
||||||
|
expect(input).toMatchObject({
|
||||||
|
type: "system",
|
||||||
|
toId: "dashboard",
|
||||||
|
toType: "user",
|
||||||
|
metadata: { taskId: "FN-1", canonicalTaskId: "FN-7961", kind: "triage-duplicate-decision" },
|
||||||
|
});
|
||||||
|
expect(input.content).toContain("flagged as a duplicate of FN-7961");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("labels the replan-cap escalation reason in the approval mailbox message", async () => {
|
it("labels the replan-cap escalation reason in the approval mailbox message", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
const sendMessageOnce = vi.fn(async (input: any, _key: string) => ({
|
const sendMessageOnce = vi.fn(async (input: any, _key: string) => ({
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ export class NotificationService {
|
|||||||
void this.writeAwaitingApprovalMailboxMessage(task);
|
void this.writeAwaitingApprovalMailboxMessage(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isTriageDuplicateDecision(task)) {
|
||||||
|
void this.writeTriageDuplicateDecisionMailboxMessage(task);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.notificationsEnabled) {
|
if (!this.notificationsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -386,6 +390,51 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isTriageDuplicateDecision(task: Task): boolean {
|
||||||
|
return task.paused === true
|
||||||
|
&& task.pausedReason === "duplicate-decision-required"
|
||||||
|
&& task.sourceMetadata?.duplicateSource === "triage-marker"
|
||||||
|
&& typeof task.sourceMetadata.nearDuplicateOf === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write one durable operator prompt for a duplicate candidate, independent of push configuration. */
|
||||||
|
private async writeTriageDuplicateDecisionMailboxMessage(task: Task): Promise<void> {
|
||||||
|
try {
|
||||||
|
const messageStore = this.options.messageStore;
|
||||||
|
if (!messageStore?.sendMessageOnce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canonicalTaskId = task.sourceMetadata?.nearDuplicateOf;
|
||||||
|
if (typeof canonicalTaskId !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = buildNtfyClickUrl({
|
||||||
|
dashboardHost: this.dashboardHost,
|
||||||
|
projectId: this.options.projectId,
|
||||||
|
taskId: task.id,
|
||||||
|
});
|
||||||
|
const content = [
|
||||||
|
`**${formatTaskIdentifier(task)} needs your decision**`,
|
||||||
|
"",
|
||||||
|
`It was flagged as a duplicate of ${canonicalTaskId}. Keep it to continue planning, or delete it if the work is already covered.`,
|
||||||
|
...(link ? ["", `[Open ${task.id}](${link})`] : []),
|
||||||
|
].join("\n");
|
||||||
|
await messageStore.sendMessageOnce({
|
||||||
|
fromId: "system",
|
||||||
|
fromType: "system",
|
||||||
|
toId: DASHBOARD_USER_ID,
|
||||||
|
toType: "user",
|
||||||
|
type: "system",
|
||||||
|
content,
|
||||||
|
metadata: { taskId: task.id, canonicalTaskId, kind: "triage-duplicate-decision" },
|
||||||
|
}, `triage-duplicate-decision:${task.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
schedulerLog.log(
|
||||||
|
`[notify] ${task.id} triage duplicate-decision mailbox message failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleTaskMerged = (result: MergeResult): void => {
|
private handleTaskMerged = (result: MergeResult): void => {
|
||||||
void this.handleTaskMergedAsync(result);
|
void this.handleTaskMergedAsync(result);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user