Files
buzz/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx
Duncan 242dbf2680 fix(acp): close Thufir addendum gaps — unconditional first attempt, interactive delivery-seam test
F2 (retransmit structural fix): move delivery-deadline check to after the first
attempt. The first publish is now unconditional — an already-expired deadline
supplied by the caller (e.g. ordinary-timeout path where entry_deadline was past
at resolution time) no longer silently skips the one relay write that resolves
the card. Retries still consult the deadline.

F2 (test fixes): replace the channel-closed bounded-exit test with two focused
tests:
- retransmit_resolved_edit_unconditional_first_attempt_on_expired_deadline:
  supplies an already-expired deadline, verifies the kind-40003 event is still
  published. Mutation: revert !first_attempt guard → zero publishes.
- retransmit_resolved_edit_always_uncertain_bounded_exit: uses test_pair_silent
  (always Uncertain) under paused time, advances past the 4s delivery window,
  verifies the loop exits. Mutation: large RESOLVED_DELIVERY_WINDOW_SECS → hangs.

F3 (interactive delivery-seam): add _deliveryFn testability seam to
PermissionDecisionButtons and thread it through LifecycleActivity. Add
test_f3_interactive_delivery_seam_allow_once_and_reject_once_fire_delivery:
builds an acp_read transcript card (four option kinds), renders via
LifecycleActivity with a mock _deliveryFn, clicks allow_once button, asserts
delivery called with opt-allow-once + nonce-interactive; clicks reject_once
button in a fresh render, asserts delivery called with opt-reject-once.
allow_always/reject_always produce no buttons (ACTIONABLE_KINDS contract).
JS suite: 10/10 pass. Mutation: remove kind from ACTIONABLE_KINDS → button
absent → delivery assertion fails.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-09-01 11:41:18 -04:00

307 lines
11 KiB
TypeScript

import { AlertCircle, CheckCircle2, ShieldCheck, XCircle } from "lucide-react";
import * as React from "react";
import {
resolveDecisionDeadlineSecs,
startPermissionDecisionDelivery,
} from "@/features/agents/lib/permissionDecisionDelivery";
import { formatTranscriptTimestampTitle } from "../agentSessionUtils";
import { ActivityRow, ActivityRowLabel } from "./ActivityRow";
import { ToolActivity } from "./ToolActivity";
import type { ActivityRenderClassItemProps } from "./types";
/**
* Split the permission item's text into the request description lines and the
* options line. The text is newline-joined by describePermissionRequest:
* [request title?] [toolCallId?] ["Options: ..."]
* We surface the options line separately so the render can style it distinctly.
*/
function splitPermissionText(text: string): {
requestLines: string;
optionsLine: string | null;
} {
const lines = text.split("\n");
const optionsIdx = lines.findIndex((l) => l.startsWith("Options: "));
if (optionsIdx === -1) {
return { requestLines: text, optionsLine: null };
}
return {
requestLines: lines.slice(0, optionsIdx).join("\n"),
optionsLine: lines[optionsIdx],
};
}
/**
* Derive the visual tone and icon for a resolved permission outcome string.
* Outcome strings come from describePermissionOutcome:
* "Approved (...)" | "Denied (...)" | "Cancelled" | "uncertain" pinned copy
*/
function permissionOutcomeTone(outcome: string): "approve" | "deny" | "cancel" {
if (outcome.startsWith("Approved")) return "approve";
if (outcome.startsWith("Denied")) return "deny";
return "cancel";
}
/**
* Exact recognized permission-option kinds the observer feed can act on.
* Only `allow_once` and `reject_once` are actionable — matching the thread
* card's two-option contract. `reject_always` is deliberately excluded: the
* read loop accepts only the two snapshotted ruled IDs (allow_once/reject_once),
* so a `reject_always` click would be sent, acknowledged as "sent", but silently
* ignored by the loop — the request would stay pending until timeout with no
* persistent denial installed. Any kind not in this set is treated as unknown
* and rendered non-actionable.
*/
const ACTIONABLE_KINDS = new Set(["allow_once", "reject_once"]);
function isActionableKind(kind: string): boolean {
return ACTIONABLE_KINDS.has(kind);
}
/**
* Default button label when the harness omits one.
*/
function defaultOptionLabel(kind: string): string {
if (kind === "reject_once") return "Deny";
return "Allow";
}
/**
* Allow/Deny buttons for an actionable permission card.
* Renders the agent's exact options as labeled buttons; a click sends the
* `permission_decision` control event (fire-and-forget).
*
* On send failure (relay reject or non-`sent` delivery status), buttons are
* re-enabled so the user can retry. The harness's 300 s fail-closed timeout
* is the backstop for permanently lost frames.
*/
function PermissionDecisionButtons({
agentPubkey,
channelId,
options,
requestNonce,
deliveryFailed,
deadlineSecs,
_deliveryFn,
}: {
agentPubkey: string;
channelId: string;
options: Array<{ optionId: string; kind: string; label?: string }>;
requestNonce: string;
/**
* Monotonically increasing failure token from the reducer — incremented on
* every non-`sent` `control_result`. Keying the effect on this number (not a
* boolean) ensures a second failure after a retry also re-enables buttons.
*/
deliveryFailed?: number;
/**
* Effective expiry deadline (unix seconds) bounding the retransmit loop.
*/
deadlineSecs: number;
/**
* Seam for testing — injects a mock delivery function without needing
* `mock.module`. Production callers omit this; the real
* `startPermissionDecisionDelivery` is used by default.
*/
_deliveryFn?: typeof startPermissionDecisionDelivery;
}) {
const deliveryFn = _deliveryFn ?? startPermissionDecisionDelivery;
const [pending, setPending] = React.useState<string | null>(null);
// Re-enable buttons when the reducer signals delivery failure (non-`sent`
// control_result status). The relay send succeeded but the harness couldn't
// route the click — the user should be able to retry.
React.useEffect(() => {
if (deliveryFailed) {
setPending(null);
}
}, [deliveryFailed]);
// Classify each option into an actionable bucket or a non-actionable
// display-only slot. Recognition is an EXACT allowlist, never a prefix:
// an unknown kind (including an unrecognized `reject_*` such as
// `reject_later_v2`) fails closed and is not rendered, so the user cannot
// click a trusted-looking button whose semantics this UI doesn't understand.
const actionableOptions = options.filter(({ kind }) =>
isActionableKind(kind),
);
if (actionableOptions.length === 0) {
return null;
}
return (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{actionableOptions.map(({ optionId, kind, label }) => {
const isDeny = kind === "reject_once";
const displayLabel = label ?? defaultOptionLabel(kind);
return (
<button
key={optionId}
type="button"
className={
isDeny
? "rounded px-2 py-0.5 text-xs font-medium border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50"
: "rounded px-2 py-0.5 text-xs font-medium border border-green-600/40 text-green-700 dark:text-green-400 hover:bg-green-600/10 disabled:opacity-50"
}
data-testid={`permission-decision-${optionId}`}
disabled={pending !== null}
onClick={() => {
setPending(optionId);
void deliveryFn({
agentPubkey,
channelId,
requestNonce,
optionId,
deadlineSecs,
})
.then((outcome) => {
// `"failed"` means the harness received the frame but could
// not route it — re-enable so the user can retry. The reducer
// `deliveryFailed` path also re-enables via the `control_result`
// frame; this fast path handles the case before the reducer
// fires. `"acked"` / `"expired"` are terminal; the transcript
// item updates via the observer relay and no retry is needed.
if (outcome === "failed") setPending(null);
})
.catch(() => {
// The delivery loop never rejects — it resolves one of
// "acked" | "expired" | "failed". This branch guards against
// any unexpected error and re-enables for safety.
setPending(null);
});
}}
>
{pending === optionId ? "…" : displayLabel}
</button>
);
})}
</div>
);
}
export function LifecycleActivity(
props: ActivityRenderClassItemProps & {
/**
* Seam for testing — injected mock delivery function threaded through to
* `PermissionDecisionButtons`. Production callers omit this prop.
*/
_deliveryFn?: typeof startPermissionDecisionDelivery;
},
) {
if (props.item.type === "tool") {
return <ToolActivity {...props} />;
}
if (props.item.type !== "lifecycle") {
return null;
}
const isError =
props.item.renderClass === "error" ||
props.item.title.toLowerCase().includes("error");
const isPermission = props.item.renderClass === "permission";
const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp);
if (isPermission) {
const { requestLines, optionsLine } = splitPermissionText(props.item.text);
const outcome = props.item.outcome;
const tone = outcome ? permissionOutcomeTone(outcome) : null;
const actionable = props.item.actionable ?? false;
const requestNonce = props.item.requestNonce;
const options = props.item.options ?? [];
const authorizationReason = props.item.authorizationReason;
const deliveryFailed = props.item.deliveryFailed;
return (
<div
className="rounded-md border border-amber-500/20 bg-amber-500/5 px-2 py-1.5 text-left text-xs text-amber-700 dark:text-amber-400"
data-testid="transcript-permission-item"
title={timestampTitle}
>
{/* Row 1: request */}
<div>
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{requestLines ? (
<span className="opacity-80"> · {requestLines}</span>
) : null}
</div>
{/* Row 2: authorization reason (from envelope), if present */}
{authorizationReason ? (
<div className="mt-0.5 pl-5 opacity-70">{authorizationReason}</div>
) : null}
{/* Row 3: options sub-line (legacy fallback) */}
{optionsLine && !authorizationReason ? (
<div className="mt-0.5 pl-5 opacity-60">{optionsLine}</div>
) : null}
{/* Row 4: Allow/Deny buttons (actionable card awaiting decision) */}
{actionable && requestNonce && !outcome ? (
<PermissionDecisionButtons
agentPubkey={props.agentPubkey}
channelId={props.item.channelId ?? ""}
options={options}
requestNonce={requestNonce}
deliveryFailed={deliveryFailed}
deadlineSecs={resolveDecisionDeadlineSecs(
props.item.expiresAt,
props.item.timestamp,
Date.now() / 1000,
)}
_deliveryFn={props._deliveryFn}
/>
) : null}
{/* Row 5: decision — only when outcome is resolved */}
{outcome && tone ? (
<>
<div className="my-1 border-t border-amber-500/20" />
<div
className={
tone === "approve"
? "flex items-center gap-1 font-medium text-green-600 dark:text-green-400"
: tone === "deny"
? "flex items-center gap-1 font-medium text-destructive"
: "flex items-center gap-1 font-medium text-muted-foreground"
}
data-testid="transcript-permission-outcome"
>
{tone === "approve" ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : tone === "deny" ? (
<XCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 opacity-50" />
)}
{outcome}
</div>
</>
) : null}
</div>
);
}
if (isError) {
return (
<div
className="rounded-md border border-destructive/20 bg-destructive/5 px-2 py-1.5 text-left text-xs text-destructive"
data-testid="transcript-lifecycle-item"
title={timestampTitle}
>
<AlertCircle className="mr-1.5 inline h-3.5 w-3.5 align-text-bottom" />
<span className="font-medium">{props.item.title}</span>
{props.item.text ? (
<span className="opacity-80"> · {props.item.text}</span>
) : null}
</div>
);
}
return (
<ActivityRow testId="transcript-lifecycle-item" title={timestampTitle}>
<ActivityRowLabel
object={props.item.text || undefined}
openToneScope="none"
verb={props.item.title}
/>
</ActivityRow>
);
}