diff --git a/apps/puffer-desktop/src/lib/api/desktop.ts b/apps/puffer-desktop/src/lib/api/desktop.ts index 8a24cf1b3..7801011a8 100644 --- a/apps/puffer-desktop/src/lib/api/desktop.ts +++ b/apps/puffer-desktop/src/lib/api/desktop.ts @@ -1970,14 +1970,19 @@ export async function executeOutboundAction(params: { version: number; approvedMessage: string; clientRequestId: string; + duplicateRiskAck?: boolean; }): Promise<{ status: string; actionId: string; receipt?: unknown }> { const client = await ensureLocalDaemonClient(); - return client.request<{ status: string; actionId: string; receipt?: unknown }>("outbound_action_execute", { + const payload: Record = { action_id: params.actionId, version: params.version, approved_message: params.approvedMessage, client_request_id: params.clientRequestId - }); + }; + if (params.duplicateRiskAck === true) { + payload.duplicate_risk_ack = true; + } + return client.request<{ status: string; actionId: string; receipt?: unknown }>("outbound_action_execute", payload); } /** Read the persisted status for an outbound action. */ diff --git a/apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts b/apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts index ccbcb7f91..f4a2ce74c 100644 --- a/apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts +++ b/apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts @@ -64,6 +64,31 @@ test("rejects daemon-only requests before Tauri backend fallback", async () => { expect(invoke).not.toHaveBeenCalled(); }); +test("sends duplicate risk acknowledgement only for explicit outbound retries", async () => { + const { request } = mockDesktopDaemonClient(); + request.mockResolvedValueOnce({ status: "sent", actionId: "action-1", receipt: { ok: true } }); + const api = await import("./desktop"); + + await api.executeOutboundAction({ + actionId: "action-1", + version: 5, + approvedMessage: "Approved text", + clientRequestId: "client-ack", + duplicateRiskAck: true + }); + + expect(request).toHaveBeenCalledWith( + "outbound_action_execute", + { + action_id: "action-1", + version: 5, + approved_message: "Approved text", + client_request_id: "client-ack", + duplicate_risk_ack: true + } + ); +}); + test("marks automation and workflow runtime API calls as daemon-only", async () => { const { invoke, request } = mockDesktopDaemonClient(); const api = await import("./desktop"); diff --git a/apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte b/apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte index 92de46e61..ac2f490cc 100644 --- a/apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte +++ b/apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte @@ -16,7 +16,8 @@ import { normalizeCanvasSpec } from "./canvasSpec"; import { classifyOutboundSendError, - connectorDraftStateForStatus + connectorDraftStateForStatus, + DUPLICATE_RISK_ACK_COPY } from "./connectorDraftStatus"; type Props = { @@ -1159,6 +1160,7 @@ if (connectorDraftSendState === "sent") return "Sent"; if (connectorDraftSendState === "cancelled") return "Cancelled"; if (connectorDraftSendState === "expired") return "Expired"; + if (connectorDraftSendState === "uncertain") return "Confirm no duplicate & retry"; return "Approve and send"; } @@ -1213,22 +1215,27 @@ }); async function sendConnectorDraft(draft: ConnectorDraftRender) { - // `uncertain` blocks approve too: the server demands duplicate_risk_ack and - // the operator must cancel (still allowed) or resolve out-of-band first. - if ( - ["sending", "cancelling", "sent", "cancelled", "expired", "uncertain"].includes( - connectorDraftSendState - ) - ) - return; + const isDuplicateRiskRetry = connectorDraftSendState === "uncertain"; + if (["sending", "cancelling", "sent", "cancelled", "expired"].includes(connectorDraftSendState)) return; + if (isDuplicateRiskRetry) { + const confirmed = window.confirm(DUPLICATE_RISK_ACK_COPY); + if (!confirmed) return; + } connectorDraftSendState = "sending"; connectorDraftSendError = ""; try { + const version = isDuplicateRiskRetry + ? (await outboundActionStatus({ + actionId: draft.draftId, + version: draft.version + })).version + : draft.version; const result = await executeOutboundAction({ actionId: draft.draftId, - version: draft.version, + version, approvedMessage: draft.message, - clientRequestId: clientRequestId(draft.draftId) + clientRequestId: clientRequestId(draft.draftId), + duplicateRiskAck: isDuplicateRiskRetry }); applyConnectorDraftStatus(result.status); if (result.status !== "sent") { @@ -1338,8 +1345,7 @@ class="sc-btn pf-connector-draft-send" data-size="sm" disabled={connectorDraftIsBusy() || - connectorDraftIsTerminal() || - connectorDraftSendState === "uncertain"} + connectorDraftIsTerminal()} onclick={() => void sendConnectorDraft(toolRender)} > @@ -1797,6 +1803,14 @@ .pf-connector-draft-send:disabled { opacity: 0.72; } + .pf-connector-draft[data-state="uncertain"] .pf-connector-draft-send { + border-color: color-mix(in oklab, var(--destructive) 70%, var(--border)); + background: color-mix(in oklab, var(--destructive) 88%, black); + color: white; + } + .pf-connector-draft[data-state="uncertain"] .pf-connector-draft-send:hover:not(:disabled) { + background: color-mix(in oklab, var(--destructive) 78%, black); + } .pf-connector-draft-cancel { color: var(--muted-foreground); } diff --git a/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.test.ts b/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.test.ts index 5c7f56dec..d299f8515 100644 --- a/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.test.ts +++ b/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "vitest"; import { classifyOutboundSendError, connectorDraftStateForStatus, + DUPLICATE_RISK_ACK_COPY, UNCERTAIN_SEND_MESSAGE } from "./connectorDraftStatus"; @@ -59,6 +60,11 @@ test("routes duplicate-risk rejection to the uncertain warning state", () => { }); }); +test("duplicate-risk confirmation copy names the unknown send outcome", () => { + expect(DUPLICATE_RISK_ACK_COPY).toContain("previous send outcome is unknown"); + expect(DUPLICATE_RISK_ACK_COPY).toContain("confirmed the message was not delivered"); +}); + test("routes version mismatch to a refresh-from-truth state", () => { const routed = classifyOutboundSendError("outbound_action_version_mismatch"); expect(routed.state).toBe("error"); diff --git a/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.ts b/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.ts index f15b9d00b..43ea434ef 100644 --- a/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.ts +++ b/apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.ts @@ -15,6 +15,9 @@ export type ConnectorDraftSendState = export const UNCERTAIN_SEND_MESSAGE = "Send status is uncertain. Check Telegram before retrying."; +export const DUPLICATE_RISK_ACK_COPY = + "The previous send outcome is unknown. Only retry after you have checked Telegram and confirmed the message was not delivered. Retrying can send a duplicate."; + export type ConnectorDraftStatusResult = { state: ConnectorDraftSendState; error: string; diff --git a/apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts b/apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts index a0646f84f..7df48247a 100644 --- a/apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts +++ b/apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts @@ -270,12 +270,61 @@ test("uncertain status: card shows the uncertain warning and is not left idle", ); // Not left in the pristine idle state that would imply a safe re-send. await expect(card).toHaveAttribute("data-state", "uncertain"); - // Approve is blocked until the duplicate risk is resolved; cancel stays - // available (the server allows cancelling an uncertain action). - await expect(card.locator(".pf-connector-draft-send")).toBeDisabled(); + // Normal approve is replaced by an explicit duplicate-risk acknowledgement. + await expect(card.locator(".pf-connector-draft-send")).toBeEnabled(); + await expect(card.locator(".pf-connector-draft-send")).toContainText( + "Confirm no duplicate & retry" + ); await expect(card.locator(".pf-connector-draft-cancel")).toBeEnabled(); }); +test("uncertain status: ack retry confirms risk, refetches version, and executes with duplicate_risk_ack", async ({ + page +}) => { + const sessionId = "session-outbound-ack-retry"; + const daemon = daemonWithSession(sessionId); + daemon.seedOutboundAction("oa-ack-retry-1", { status: "uncertain", version: 7 }); + await daemon.install(page); + await daemon.open(page); + await openSession(page, /session-outbound-ack-retry/); + + streamDrafts(daemon, sessionId, "turn-ack-retry", [ + draftInvocation({ + draftId: "oa-ack-retry-1", + status: "uncertain", + version: 1, + message: "Retry after checking." + }) + ]); + + const card = page.locator(".pf-connector-draft"); + await expect(card).toHaveAttribute("data-state", "uncertain"); + + page.once("dialog", async (dialog) => { + expect(dialog.message()).toContain("previous send outcome is unknown"); + expect(dialog.message()).toContain("confirmed the message was not delivered"); + await dialog.accept(); + }); + + const statusPromise = daemon.waitForRequest( + "outbound_action_status", + (request) => request.params.action_id === "oa-ack-retry-1" + ); + const executePromise = daemon.waitForRequest( + "outbound_action_execute", + (request) => request.params.action_id === "oa-ack-retry-1" + ); + + await card.locator(".pf-connector-draft-send").click(); + await statusPromise; + const executeRequest = await executePromise; + + expect(executeRequest.params.version).toBe(7); + expect(executeRequest.params.duplicate_risk_ack).toBe(true); + expect(executeRequest.params.approved_message).toBe("Retry after checking."); + await expect(card).toHaveAttribute("data-state", "sent"); +}); + test("stamped recipient renders without the model-chosen badge", async ({ page }) => { const sessionId = "session-outbound-stamped"; const daemon = daemonWithSession(sessionId); diff --git a/specs/puffer-desktop/790.md b/specs/puffer-desktop/790.md new file mode 100644 index 000000000..5a7f8e349 --- /dev/null +++ b/specs/puffer-desktop/790.md @@ -0,0 +1,23 @@ +# Duplicate-Risk Retry Affordance + +## Scope + +Desktop outbound approval cards now expose an explicit retry affordance for persisted `uncertain` +outbound actions. + +## Behavior + +- `uncertain` still renders as a warning state and keeps Cancel available. +- The primary action becomes `Confirm no duplicate & retry`. +- Clicking it opens a native confirmation dialog explaining that the previous send outcome is + unknown and the user must have verified that no duplicate was delivered. +- After confirmation, the card refreshes `outbound_action_status`, uses the returned live `version`, + and calls `outbound_action_execute` with `duplicate_risk_ack: true`. +- Existing sentinel routing remains intact for expiry, terminal states, duplicate-risk rejection, + and version mismatch. + +## Verification + +- Unit coverage checks the duplicate-risk copy and existing status/error classification. +- Playwright coverage exercises `uncertain -> confirmation -> status refetch -> execute` and asserts + `duplicate_risk_ack` plus the fresh version.