Skip to content

Commit 1886149

Browse files
authored
Merge pull request #499 from WaldenLee2005/fix/issue-776-ack-retry-ui
fix(desktop): add duplicate-risk retry affordance
2 parents ba1acd8 + 073a519 commit 1886149

7 files changed

Lines changed: 143 additions & 18 deletions

File tree

apps/puffer-desktop/src/lib/api/desktop.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,14 +1970,19 @@ export async function executeOutboundAction(params: {
19701970
version: number;
19711971
approvedMessage: string;
19721972
clientRequestId: string;
1973+
duplicateRiskAck?: boolean;
19731974
}): Promise<{ status: string; actionId: string; receipt?: unknown }> {
19741975
const client = await ensureLocalDaemonClient();
1975-
return client.request<{ status: string; actionId: string; receipt?: unknown }>("outbound_action_execute", {
1976+
const payload: Record<string, unknown> = {
19761977
action_id: params.actionId,
19771978
version: params.version,
19781979
approved_message: params.approvedMessage,
19791980
client_request_id: params.clientRequestId
1980-
});
1981+
};
1982+
if (params.duplicateRiskAck === true) {
1983+
payload.duplicate_risk_ack = true;
1984+
}
1985+
return client.request<{ status: string; actionId: string; receipt?: unknown }>("outbound_action_execute", payload);
19811986
}
19821987

19831988
/** Read the persisted status for an outbound action. */

apps/puffer-desktop/src/lib/api/desktop.workflow-daemon.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,31 @@ test("rejects daemon-only requests before Tauri backend fallback", async () => {
6464
expect(invoke).not.toHaveBeenCalled();
6565
});
6666

67+
test("sends duplicate risk acknowledgement only for explicit outbound retries", async () => {
68+
const { request } = mockDesktopDaemonClient();
69+
request.mockResolvedValueOnce({ status: "sent", actionId: "action-1", receipt: { ok: true } });
70+
const api = await import("./desktop");
71+
72+
await api.executeOutboundAction({
73+
actionId: "action-1",
74+
version: 5,
75+
approvedMessage: "Approved text",
76+
clientRequestId: "client-ack",
77+
duplicateRiskAck: true
78+
});
79+
80+
expect(request).toHaveBeenCalledWith(
81+
"outbound_action_execute",
82+
{
83+
action_id: "action-1",
84+
version: 5,
85+
approved_message: "Approved text",
86+
client_request_id: "client-ack",
87+
duplicate_risk_ack: true
88+
}
89+
);
90+
});
91+
6792
test("marks automation and workflow runtime API calls as daemon-only", async () => {
6893
const { invoke, request } = mockDesktopDaemonClient();
6994
const api = await import("./desktop");

apps/puffer-desktop/src/lib/screens/agent/ToolCard.svelte

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
import { normalizeCanvasSpec } from "./canvasSpec";
1717
import {
1818
classifyOutboundSendError,
19-
connectorDraftStateForStatus
19+
connectorDraftStateForStatus,
20+
DUPLICATE_RISK_ACK_COPY
2021
} from "./connectorDraftStatus";
2122
2223
type Props = {
@@ -1159,6 +1160,7 @@
11591160
if (connectorDraftSendState === "sent") return "Sent";
11601161
if (connectorDraftSendState === "cancelled") return "Cancelled";
11611162
if (connectorDraftSendState === "expired") return "Expired";
1163+
if (connectorDraftSendState === "uncertain") return "Confirm no duplicate & retry";
11621164
return "Approve and send";
11631165
}
11641166
@@ -1213,22 +1215,27 @@
12131215
});
12141216
12151217
async function sendConnectorDraft(draft: ConnectorDraftRender) {
1216-
// `uncertain` blocks approve too: the server demands duplicate_risk_ack and
1217-
// the operator must cancel (still allowed) or resolve out-of-band first.
1218-
if (
1219-
["sending", "cancelling", "sent", "cancelled", "expired", "uncertain"].includes(
1220-
connectorDraftSendState
1221-
)
1222-
)
1223-
return;
1218+
const isDuplicateRiskRetry = connectorDraftSendState === "uncertain";
1219+
if (["sending", "cancelling", "sent", "cancelled", "expired"].includes(connectorDraftSendState)) return;
1220+
if (isDuplicateRiskRetry) {
1221+
const confirmed = window.confirm(DUPLICATE_RISK_ACK_COPY);
1222+
if (!confirmed) return;
1223+
}
12241224
connectorDraftSendState = "sending";
12251225
connectorDraftSendError = "";
12261226
try {
1227+
const version = isDuplicateRiskRetry
1228+
? (await outboundActionStatus({
1229+
actionId: draft.draftId,
1230+
version: draft.version
1231+
})).version
1232+
: draft.version;
12271233
const result = await executeOutboundAction({
12281234
actionId: draft.draftId,
1229-
version: draft.version,
1235+
version,
12301236
approvedMessage: draft.message,
1231-
clientRequestId: clientRequestId(draft.draftId)
1237+
clientRequestId: clientRequestId(draft.draftId),
1238+
duplicateRiskAck: isDuplicateRiskRetry
12321239
});
12331240
applyConnectorDraftStatus(result.status);
12341241
if (result.status !== "sent") {
@@ -1338,8 +1345,7 @@
13381345
class="sc-btn pf-connector-draft-send"
13391346
data-size="sm"
13401347
disabled={connectorDraftIsBusy() ||
1341-
connectorDraftIsTerminal() ||
1342-
connectorDraftSendState === "uncertain"}
1348+
connectorDraftIsTerminal()}
13431349
onclick={() => void sendConnectorDraft(toolRender)}
13441350
>
13451351
<Icon name={connectorDraftPrimaryIcon()} size={12} />
@@ -1797,6 +1803,14 @@
17971803
.pf-connector-draft-send:disabled {
17981804
opacity: 0.72;
17991805
}
1806+
.pf-connector-draft[data-state="uncertain"] .pf-connector-draft-send {
1807+
border-color: color-mix(in oklab, var(--destructive) 70%, var(--border));
1808+
background: color-mix(in oklab, var(--destructive) 88%, black);
1809+
color: white;
1810+
}
1811+
.pf-connector-draft[data-state="uncertain"] .pf-connector-draft-send:hover:not(:disabled) {
1812+
background: color-mix(in oklab, var(--destructive) 78%, black);
1813+
}
18001814
.pf-connector-draft-cancel {
18011815
color: var(--muted-foreground);
18021816
}

apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from "vitest";
22
import {
33
classifyOutboundSendError,
44
connectorDraftStateForStatus,
5+
DUPLICATE_RISK_ACK_COPY,
56
UNCERTAIN_SEND_MESSAGE
67
} from "./connectorDraftStatus";
78

@@ -59,6 +60,11 @@ test("routes duplicate-risk rejection to the uncertain warning state", () => {
5960
});
6061
});
6162

63+
test("duplicate-risk confirmation copy names the unknown send outcome", () => {
64+
expect(DUPLICATE_RISK_ACK_COPY).toContain("previous send outcome is unknown");
65+
expect(DUPLICATE_RISK_ACK_COPY).toContain("confirmed the message was not delivered");
66+
});
67+
6268
test("routes version mismatch to a refresh-from-truth state", () => {
6369
const routed = classifyOutboundSendError("outbound_action_version_mismatch");
6470
expect(routed.state).toBe("error");

apps/puffer-desktop/src/lib/screens/agent/connectorDraftStatus.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export type ConnectorDraftSendState =
1515
export const UNCERTAIN_SEND_MESSAGE =
1616
"Send status is uncertain. Check Telegram before retrying.";
1717

18+
export const DUPLICATE_RISK_ACK_COPY =
19+
"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.";
20+
1821
export type ConnectorDraftStatusResult = {
1922
state: ConnectorDraftSendState;
2023
error: string;

apps/puffer-desktop/tests/outbound-gate-matrix.spec.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,61 @@ test("uncertain status: card shows the uncertain warning and is not left idle",
270270
);
271271
// Not left in the pristine idle state that would imply a safe re-send.
272272
await expect(card).toHaveAttribute("data-state", "uncertain");
273-
// Approve is blocked until the duplicate risk is resolved; cancel stays
274-
// available (the server allows cancelling an uncertain action).
275-
await expect(card.locator(".pf-connector-draft-send")).toBeDisabled();
273+
// Normal approve is replaced by an explicit duplicate-risk acknowledgement.
274+
await expect(card.locator(".pf-connector-draft-send")).toBeEnabled();
275+
await expect(card.locator(".pf-connector-draft-send")).toContainText(
276+
"Confirm no duplicate & retry"
277+
);
276278
await expect(card.locator(".pf-connector-draft-cancel")).toBeEnabled();
277279
});
278280

281+
test("uncertain status: ack retry confirms risk, refetches version, and executes with duplicate_risk_ack", async ({
282+
page
283+
}) => {
284+
const sessionId = "session-outbound-ack-retry";
285+
const daemon = daemonWithSession(sessionId);
286+
daemon.seedOutboundAction("oa-ack-retry-1", { status: "uncertain", version: 7 });
287+
await daemon.install(page);
288+
await daemon.open(page);
289+
await openSession(page, /session-outbound-ack-retry/);
290+
291+
streamDrafts(daemon, sessionId, "turn-ack-retry", [
292+
draftInvocation({
293+
draftId: "oa-ack-retry-1",
294+
status: "uncertain",
295+
version: 1,
296+
message: "Retry after checking."
297+
})
298+
]);
299+
300+
const card = page.locator(".pf-connector-draft");
301+
await expect(card).toHaveAttribute("data-state", "uncertain");
302+
303+
page.once("dialog", async (dialog) => {
304+
expect(dialog.message()).toContain("previous send outcome is unknown");
305+
expect(dialog.message()).toContain("confirmed the message was not delivered");
306+
await dialog.accept();
307+
});
308+
309+
const statusPromise = daemon.waitForRequest(
310+
"outbound_action_status",
311+
(request) => request.params.action_id === "oa-ack-retry-1"
312+
);
313+
const executePromise = daemon.waitForRequest(
314+
"outbound_action_execute",
315+
(request) => request.params.action_id === "oa-ack-retry-1"
316+
);
317+
318+
await card.locator(".pf-connector-draft-send").click();
319+
await statusPromise;
320+
const executeRequest = await executePromise;
321+
322+
expect(executeRequest.params.version).toBe(7);
323+
expect(executeRequest.params.duplicate_risk_ack).toBe(true);
324+
expect(executeRequest.params.approved_message).toBe("Retry after checking.");
325+
await expect(card).toHaveAttribute("data-state", "sent");
326+
});
327+
279328
test("stamped recipient renders without the model-chosen badge", async ({ page }) => {
280329
const sessionId = "session-outbound-stamped";
281330
const daemon = daemonWithSession(sessionId);

specs/puffer-desktop/790.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Duplicate-Risk Retry Affordance
2+
3+
## Scope
4+
5+
Desktop outbound approval cards now expose an explicit retry affordance for persisted `uncertain`
6+
outbound actions.
7+
8+
## Behavior
9+
10+
- `uncertain` still renders as a warning state and keeps Cancel available.
11+
- The primary action becomes `Confirm no duplicate & retry`.
12+
- Clicking it opens a native confirmation dialog explaining that the previous send outcome is
13+
unknown and the user must have verified that no duplicate was delivered.
14+
- After confirmation, the card refreshes `outbound_action_status`, uses the returned live `version`,
15+
and calls `outbound_action_execute` with `duplicate_risk_ack: true`.
16+
- Existing sentinel routing remains intact for expiry, terminal states, duplicate-risk rejection,
17+
and version mismatch.
18+
19+
## Verification
20+
21+
- Unit coverage checks the duplicate-risk copy and existing status/error classification.
22+
- Playwright coverage exercises `uncertain -> confirmation -> status refetch -> execute` and asserts
23+
`duplicate_risk_ack` plus the fresh version.

0 commit comments

Comments
 (0)