Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/puffer-desktop/src/lib/api/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {
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. */
Expand Down
25 changes: 25 additions & 0 deletions apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
40 changes: 27 additions & 13 deletions apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
import { normalizeCanvasSpec } from "./canvasSpec";
import {
classifyOutboundSendError,
connectorDraftStateForStatus
connectorDraftStateForStatus,
DUPLICATE_RISK_ACK_COPY
} from "./connectorDraftStatus";

type Props = {
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -1338,8 +1345,7 @@
class="sc-btn pf-connector-draft-send"
data-size="sm"
disabled={connectorDraftIsBusy() ||
connectorDraftIsTerminal() ||
connectorDraftSendState === "uncertain"}
connectorDraftIsTerminal()}
onclick={() => void sendConnectorDraft(toolRender)}
>
<Icon name={connectorDraftPrimaryIcon()} size={12} />
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from "vitest";
import {
classifyOutboundSendError,
connectorDraftStateForStatus,
DUPLICATE_RISK_ACK_COPY,
UNCERTAIN_SEND_MESSAGE
} from "./connectorDraftStatus";

Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 52 additions & 3 deletions apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions specs/puffer-desktop/790.md
Original file line number Diff line number Diff line change
@@ -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.