From 44442622c5c814beb2373ef7a6ec13f5d184da6b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 21:08:11 -0700 Subject: [PATCH] FN-7609: show gated action payload details on approval requests Approval cards previously showed only a generic gating message with no visibility into the underlying command/arguments being approved, and repeated pending requests for the same action could pile up as duplicates. - Add GatedActionApprovalDetails component to render the gated command/arguments payload on agent-gating approval cards in MailboxView - Persist approvalDedupeKey in targetAction.context and a payload-bearing summary via buildAgentGatedActionSummary in permanent-agent-gating - Wire agent-heartbeat, executor, and pi to pass through the richer gated-action context/summary - Add changeset (patch) documenting the fix - Update docs/dashboard-guide.md - Add/extend tests: GatedActionApprovalDetails, MailboxView, permanent-agent-gating, pi-create-fn-agent Files changed: .changeset/FN-7609-gated-action-approval-payload.md | 7 ++ docs/dashboard-guide.md | 1 + packages/core/src/types.ts | 8 +++ .../app/components/GatedActionApprovalDetails.css | 50 ++++++++++++++ .../app/components/GatedActionApprovalDetails.tsx | 72 +++++++++++++++++++ packages/dashboard/app/components/MailboxView.tsx | 12 ++++ .../__tests__/GatedActionApprovalDetails.test.tsx | 66 ++++++++++++++++++ .../app/components/__tests__/MailboxView.test.tsx | 41 +++++++++++ .../src/__tests__/permanent-agent-gating.test.ts | 31 +++++++++ .../src/__tests__/pi-create-fn-agent.test.ts | 80 ++++++++++++++++++++++ packages/engine/src/agent-heartbeat.ts | 19 ++++- packages/engine/src/executor.ts | 19 ++++- packages/engine/src/permanent-agent-gating.ts | 53 ++++++++++++++ packages/engine/src/pi.ts | 6 ++ 14 files changed, 461 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7609 Fusion-Task-Lineage: 80a6bb5b-79f7-4b78-9204-402c2dea6171 Co-authored-by: Fusion (runfusion.ai) --- .../FN-7609-gated-action-approval-payload.md | 7 ++ docs/dashboard-guide.md | 1 + packages/core/src/types.ts | 8 ++ .../components/GatedActionApprovalDetails.css | 50 ++++++++++++ .../components/GatedActionApprovalDetails.tsx | 72 +++++++++++++++++ .../dashboard/app/components/MailboxView.tsx | 12 +++ .../GatedActionApprovalDetails.test.tsx | 66 +++++++++++++++ .../components/__tests__/MailboxView.test.tsx | 41 ++++++++++ .../__tests__/permanent-agent-gating.test.ts | 31 +++++++ .../src/__tests__/pi-create-fn-agent.test.ts | 80 +++++++++++++++++++ packages/engine/src/agent-heartbeat.ts | 19 ++++- packages/engine/src/executor.ts | 19 ++++- packages/engine/src/permanent-agent-gating.ts | 53 ++++++++++++ packages/engine/src/pi.ts | 6 ++ 14 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 .changeset/FN-7609-gated-action-approval-payload.md create mode 100644 packages/dashboard/app/components/GatedActionApprovalDetails.css create mode 100644 packages/dashboard/app/components/GatedActionApprovalDetails.tsx create mode 100644 packages/dashboard/app/components/__tests__/GatedActionApprovalDetails.test.tsx diff --git a/.changeset/FN-7609-gated-action-approval-payload.md b/.changeset/FN-7609-gated-action-approval-payload.md new file mode 100644 index 0000000000..15623619d2 --- /dev/null +++ b/.changeset/FN-7609-gated-action-approval-payload.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Approval cards now show the gated command/arguments and dedupe repeated pending requests. +category: fix +dev: Permanent-agent gate persists approvalDedupeKey in targetAction.context and a payload-bearing summary (buildAgentGatedActionSummary); MailboxView renders GatedActionApprovalDetails for source="agent-gating". diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7cd3333754..6de422c05d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -572,6 +572,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. - reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading - when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: `) with metadata for `artifactId`, `artifactType`, `title`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration - mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests +- for approvals gated by an agent's permission policy (permanent agents and task-worker heartbeats), the Approvals detail pane renders the gated action's real payload — tool name, shell command line or structured arguments, and working directory when present — instead of only a generic "Agent gated action for `<tool>`" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609) - in the **Agents** tab, the agent selector now includes **All agents**, which shows one combined agent-to-agent stream (with sender + recipient labels); selecting a specific agent still shows Inbox/Outbox subtabs - mailbox entry points now show unread/pending indicators: the desktop/tablet Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, the mobile bottom-nav Mailbox tab carries the mobile badges/dots, and the compact Header actions overflow keeps a Mailbox entry only when the mobile bottom nav is disabled - approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a9e69963ce..87b99b6966 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6725,6 +6725,14 @@ export interface PermanentAgentGatingContext { category: AgentPermissionPolicyActionCategory; toolName: string; args: Record<string, unknown>; + /** + * FNXC:AgentGating 2026-07-05-00:00: + * FN-7609: the dedupe key must be persisted into the created request's + * targetAction.context so a retrying heartbeat's findPendingApprovalRequest + * lookup (which matches on context.approvalDedupeKey) can actually find and + * reuse the pending request instead of minting a new blank one every tick. + */ + approvalDedupeKey?: string; }) => Promise<ApprovalRequest | null>; findPendingApprovalRequest?: (dedupeKey: string) => Promise<ApprovalRequest | null>; } diff --git a/packages/dashboard/app/components/GatedActionApprovalDetails.css b/packages/dashboard/app/components/GatedActionApprovalDetails.css new file mode 100644 index 0000000000..c963f94dd1 --- /dev/null +++ b/packages/dashboard/app/components/GatedActionApprovalDetails.css @@ -0,0 +1,50 @@ +.gated-action-approval-details { + margin-bottom: var(--space-md); + padding: var(--space-md); +} + +.gated-action-approval-details__title { + margin: 0 0 var(--space-sm) 0; + color: var(--text); + font-size: 0.875rem; +} + +.gated-action-approval-details__list { + margin: 0; + display: grid; + gap: var(--space-xs); +} + +.gated-action-approval-details__row { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + gap: var(--space-sm); +} + +.gated-action-approval-details__row dt { + color: var(--text-muted); + font-weight: 600; +} + +.gated-action-approval-details__row dd { + margin: 0; + color: var(--text); + overflow-wrap: anywhere; +} + +.gated-action-approval-details__code { + margin: 0; + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.8125rem; +} + +@media (max-width: 768px) { + .gated-action-approval-details__row { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/dashboard/app/components/GatedActionApprovalDetails.tsx b/packages/dashboard/app/components/GatedActionApprovalDetails.tsx new file mode 100644 index 0000000000..6ae1d6a893 --- /dev/null +++ b/packages/dashboard/app/components/GatedActionApprovalDetails.tsx @@ -0,0 +1,72 @@ +import "./GatedActionApprovalDetails.css"; +import { useTranslation } from "react-i18next"; +import type { ApprovalRequestDetail } from "../api"; + +interface GatedActionApprovalDetailsProps { + targetAction: ApprovalRequestDetail["targetAction"]; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function readToolArgs(context: Record<string, unknown> | undefined): Record<string, unknown> | null { + const toolArgs = context?.toolArgs; + if (!toolArgs || typeof toolArgs !== "object") return null; + return toolArgs as Record<string, unknown>; +} + +/* +FNXC:Approvals 2026-07-05-00:00: +FN-7609: operators approving an agent-gated action (bash command, fn_* tool +call, etc.) must be able to see the real gated payload — tool name, the +shell command line or structured arguments, and working directory — instead +of just the tool name. This component renders `targetAction.context` (which +the engine's permanent-agent gating closures now populate with `toolName`, +`toolArgs`, `command`, and `cwd`) generically for any request whose +context.source === "agent-gating", so it covers both the executor and +heartbeat gating paths without duplicating rendering logic. +*/ +export function GatedActionApprovalDetails({ targetAction }: GatedActionApprovalDetailsProps) { + const { t } = useTranslation("app"); + const context = targetAction.context as Record<string, unknown> | undefined; + const toolName = readString(context?.toolName) ?? targetAction.resourceId; + const command = readString(context?.command); + const cwd = readString(context?.cwd); + const toolArgs = readToolArgs(context); + const argsJson = toolArgs && Object.keys(toolArgs).length > 0 ? JSON.stringify(toolArgs, null, 2) : null; + + return ( + <section className="card gated-action-approval-details" data-testid="gated-action-approval-details"> + <h4 className="gated-action-approval-details__title">{t("approvals.gatedActionTitle", "Gated action payload")}</h4> + <dl className="gated-action-approval-details__list"> + <div className="gated-action-approval-details__row"> + <dt>{t("approvals.gatedActionTool", "Tool")}</dt> + <dd>{toolName}</dd> + </div> + {command && ( + <div className="gated-action-approval-details__row"> + <dt>{t("approvals.gatedActionCommand", "Command")}</dt> + <dd> + <pre className="gated-action-approval-details__code"><code>{command}</code></pre> + </dd> + </div> + )} + {argsJson && ( + <div className="gated-action-approval-details__row"> + <dt>{t("approvals.gatedActionArgs", "Arguments")}</dt> + <dd> + <pre className="gated-action-approval-details__code"><code>{argsJson}</code></pre> + </dd> + </div> + )} + {cwd && ( + <div className="gated-action-approval-details__row"> + <dt>{t("approvals.gatedActionCwd", "Working directory")}</dt> + <dd>{cwd}</dd> + </div> + )} + </dl> + </section> + ); +} diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index d85f45b69d..db9fa3a260 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -41,6 +41,7 @@ import { MailboxMessageContent } from "./MailboxMessageContent"; import { MessageComposer } from "./MessageComposer"; import { ViewHeader } from "./ViewHeader"; import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails"; +import { GatedActionApprovalDetails } from "./GatedActionApprovalDetails"; import { subscribeSse } from "../sse-bus"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; @@ -1208,6 +1209,17 @@ export function MailboxView({ {selectedApproval.targetAction.category === "network_api" && selectedApproval.targetAction.action === "worktrunk_install" && ( <WorktrunkInstallApprovalDetails targetAction={selectedApproval.targetAction} /> )} + {/* + FNXC:Approvals 2026-07-05-00:00: + FN-7609: render the generic gated-action payload (command/args/cwd) + whenever the request came from the agent-gating path, on both + desktop and mobile layouts. Mutually exclusive with the dedicated + worktrunk_install branch above, which must keep rendering unchanged. + */} + {selectedApproval.targetAction.action !== "worktrunk_install" + && (selectedApproval.targetAction.context as Record<string, unknown> | undefined)?.source === "agent-gating" && ( + <GatedActionApprovalDetails targetAction={selectedApproval.targetAction} /> + )} <div className="mailbox-conversation" data-testid="mailbox-approval-history"> {selectedApproval.history.map((event) => ( <div key={event.id} className="mailbox-conversation-msg"> diff --git a/packages/dashboard/app/components/__tests__/GatedActionApprovalDetails.test.tsx b/packages/dashboard/app/components/__tests__/GatedActionApprovalDetails.test.tsx new file mode 100644 index 0000000000..7b2fb511a5 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/GatedActionApprovalDetails.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { GatedActionApprovalDetails } from "../GatedActionApprovalDetails"; +import type { ApprovalRequestDetail } from "../../api"; + +describe("GatedActionApprovalDetails", () => { + it("renders the tool name, command, and working directory for a bash payload", () => { + const targetAction: ApprovalRequestDetail["targetAction"] = { + category: "command_execution", + action: "bash", + summary: "Run: pnpm test", + resourceType: "tool", + resourceId: "bash", + context: { + toolName: "bash", + toolArgs: { command: "pnpm test", cwd: "/project/.worktrees/fn-001" }, + source: "agent-gating", + command: "pnpm test", + cwd: "/project/.worktrees/fn-001", + }, + }; + + render(<GatedActionApprovalDetails targetAction={targetAction} />); + + expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument(); + expect(screen.getByText("bash")).toBeInTheDocument(); + expect(screen.getByText("pnpm test")).toBeInTheDocument(); + expect(screen.getByText("/project/.worktrees/fn-001")).toBeInTheDocument(); + }); + + it("renders structured arguments for an fn_* tool payload", () => { + const targetAction: ApprovalRequestDetail["targetAction"] = { + category: "task_agent_mutation", + action: "fn_task_update", + summary: "fn_task_update {id: FN-1, status: done}", + resourceType: "tool", + resourceId: "fn_task_update", + context: { + toolName: "fn_task_update", + toolArgs: { id: "FN-1", status: "done" }, + source: "agent-gating", + }, + }; + + render(<GatedActionApprovalDetails targetAction={targetAction} />); + + expect(screen.getByText("fn_task_update")).toBeInTheDocument(); + expect(screen.getByText(/"id": "FN-1"/)).toBeInTheDocument(); + expect(screen.getByText(/"status": "done"/)).toBeInTheDocument(); + }); + + it("gracefully handles missing context", () => { + const targetAction: ApprovalRequestDetail["targetAction"] = { + category: "command_execution", + action: "bash", + summary: "Agent gated action for bash", + resourceType: "tool", + resourceId: "bash", + }; + + render(<GatedActionApprovalDetails targetAction={targetAction} />); + + expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument(); + expect(screen.getByText("bash")).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index 2fe38089ac..e160833fab 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -379,6 +379,47 @@ describe("MailboxView", () => { }); }); + // FN-7609: an agent-gating approval must render the real gated payload + // (command visible), and this must be mutually exclusive with the + // dedicated worktrunk_install branch (covered by the test above). + it("renders gated action payload details for agent-gating approvals", async () => { + const now = new Date().toISOString(); + mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 }); + mockFetchApprovals.mockResolvedValue({ + requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now }], + total: 1, + pendingCount: 1, + }); + mockFetchApprovalDetail.mockResolvedValue({ + id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run: pnpm test", agentId: "agent-001", createdAt: now, updatedAt: now, + requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, + targetAction: { + category: "command_execution", + action: "bash", + summary: "Run: pnpm test", + resourceType: "tool", + resourceId: "bash", + context: { + toolName: "bash", + toolArgs: { command: "pnpm test" }, + source: "agent-gating", + command: "pnpm test", + }, + }, + history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }], + }); + + render(<MailboxView {...defaultProps} />); + await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); }); + await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); }); + + await waitFor(() => { + expect(screen.getByTestId("gated-action-approval-details")).toBeInTheDocument(); + expect(screen.getByText("pnpm test")).toBeInTheDocument(); + expect(screen.queryByTestId("worktrunk-install-approval-details")).not.toBeInTheDocument(); + }); + }); + it("allows approving a pending approval request", async () => { const now = new Date().toISOString(); mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 }); diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 41127060fd..1cb314fe83 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -1,6 +1,7 @@ import type { AgentPermissionPolicy } from "@fusion/core"; import { describe, expect, it } from "vitest"; import { + buildAgentGatedActionSummary, classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision, } from "../permanent-agent-gating.js"; @@ -251,3 +252,33 @@ describe("permanent-agent-gating", () => { expect(approvalDecision.disposition).toBe("require-approval"); }); }); + +// FN-7609: approval cards must show the real gated payload instead of a +// generic "Agent gated action for <tool>" placeholder. +describe("buildAgentGatedActionSummary", () => { + it("renders the trimmed shell command for bash calls", () => { + expect(buildAgentGatedActionSummary("bash", { command: " pnpm test --run " })).toBe("Run: pnpm test --run"); + }); + + it("truncates very long shell commands to a sane cap", () => { + const longCommand = `echo ${"x".repeat(300)}`; + const summary = buildAgentGatedActionSummary("bash", { command: longCommand }); + expect(summary.startsWith("Run: echo ")).toBe(true); + expect(summary.length).toBeLessThanOrEqual(210); + expect(summary.endsWith("\u2026")).toBe(true); + }); + + it("renders a compact args summary for fn_* tools", () => { + const summary = buildAgentGatedActionSummary("fn_task_update", { id: "FN-1", status: "done" }); + expect(summary).toBe("fn_task_update {id: FN-1, status: done}"); + }); + + it("falls back to a generic summary when no args are meaningful", () => { + expect(buildAgentGatedActionSummary("fn_task_list", {})).toBe("Agent gated action for fn_task_list"); + expect(buildAgentGatedActionSummary("fn_task_list", undefined)).toBe("Agent gated action for fn_task_list"); + }); + + it("falls back to a generic summary for bash calls with no command", () => { + expect(buildAgentGatedActionSummary("bash", {})).toBe("Agent gated action for bash"); + }); +}); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 774cde8361..2e2ccf674b 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -663,6 +663,86 @@ describe("wrapToolsWithPermanentAgentGating", () => { expect(tool.execute).not.toHaveBeenCalled(); }); + // FN-7609: the permanent-agent gate must pass its computed dedupe key + // through to createApprovalRequest so the closure can persist it into + // targetAction.context.approvalDedupeKey — without this, a stateless + // heartbeat retrying the same gated command mints a brand-new blank + // approval every tick instead of reusing the pending one. + it("passes the computed approvalDedupeKey through to createApprovalRequest", async () => { + const tool = { name: "bash", label: "Bash", description: "", parameters: {}, execute: vi.fn() }; + const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-dedupe-1" }); + const findPendingApprovalRequest = vi.fn().mockResolvedValue(null); + const { wrapToolsWithPermanentAgentGating } = await import("../pi.js"); + const wrapped = wrapToolsWithPermanentAgentGating([tool as any], { + requester: { actorId: "agent-1", actorType: "agent", actorName: "Perm" }, + taskId: "FN-1", + permissionPolicy: { + presetId: "approval-required", + rules: { + git_write: "require-approval", + file_write_delete: "require-approval", + command_execution: "require-approval", + network_api: "require-approval", + task_agent_mutation: "require-approval", + }, + }, + createApprovalRequest, + findPendingApprovalRequest, + }); + + await (wrapped[0] as any).execute("t1", { command: "pnpm test" }); + + expect(findPendingApprovalRequest).toHaveBeenCalledWith("agent-1|FN-1|bash|command_execution"); + expect(createApprovalRequest).toHaveBeenCalledWith(expect.objectContaining({ + toolName: "bash", + approvalDedupeKey: "agent-1|FN-1|bash|command_execution", + })); + }); + + it("reuses a pending approval instead of creating a duplicate on a repeated gated tick", async () => { + const tool = { name: "bash", label: "Bash", description: "", parameters: {}, execute: vi.fn() }; + const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-dedupe-2" }); + // Simulate a real findPendingApprovalRequest backed by a store that + // persists context.approvalDedupeKey (as executor.ts/agent-heartbeat.ts + // now do): first tick finds nothing and creates a request; the store then + // "remembers" it, so the second identical tick finds it and reuses it. + let stored: { id: string; targetAction: { context: Record<string, unknown> } } | null = null; + const findPendingApprovalRequest = vi.fn(async (dedupeKey: string) => { + if (stored && stored.targetAction.context.approvalDedupeKey === dedupeKey) { + return stored as any; + } + return null; + }); + const gating = { + requester: { actorId: "agent-1", actorType: "agent" as const, actorName: "Perm" }, + taskId: "FN-1", + permissionPolicy: { + presetId: "approval-required" as const, + rules: { + git_write: "require-approval" as const, + file_write_delete: "require-approval" as const, + command_execution: "require-approval" as const, + network_api: "require-approval" as const, + task_agent_mutation: "require-approval" as const, + }, + }, + createApprovalRequest: vi.fn(async (input: { toolName: string; approvalDedupeKey?: string }) => { + const created = await createApprovalRequest(input); + stored = { id: created.id, targetAction: { context: { approvalDedupeKey: input.approvalDedupeKey } } }; + return created; + }), + findPendingApprovalRequest, + }; + const { wrapToolsWithPermanentAgentGating } = await import("../pi.js"); + const wrapped = wrapToolsWithPermanentAgentGating([tool as any], gating as any); + + await (wrapped[0] as any).execute("t1", { command: "pnpm test" }); + await (wrapped[0] as any).execute("t2", { command: "pnpm test" }); + + expect(createApprovalRequest).toHaveBeenCalledTimes(1); + expect(findPendingApprovalRequest).toHaveBeenCalledTimes(2); + }); + it("requires approval for governed internal task-mutation fn_* tools", async () => { const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) }; const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-fn-1" }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 8be57b7f3b..5537e83069 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -37,6 +37,7 @@ import { createLogger, heartbeatLog, formatError } from "./logger.js"; import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type EngineRunContext } from "./run-audit.js"; import { promptWithFallback } from "./pi.js"; +import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveHeartbeatSessionModels } from "./agent-session-helpers.js"; import { resolveMcpServersForStore } from "./mcp-resolution.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; @@ -1225,20 +1226,34 @@ export class HeartbeatMonitor { requester: { actorId: agent.id, actorType: "agent", actorName: agent.name }, taskId, runId, - createApprovalRequest: async ({ category, toolName, args }) => this.getApprovalRequestStore().create({ + // FNXC:AgentGating 2026-07-05-00:00: + // FN-7609: operators approving a gated action need the real command/args, + // and a stateless heartbeat retrying the same command must reuse a single + // pending approval instead of minting duplicates. `summary` is now + // payload-bearing (shared helper) and `approvalDedupeKey`/`command`/`cwd` + // are persisted into targetAction.context so findPendingApprovalRequest + // can match and the UI can render the payload without re-parsing. + createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => this.getApprovalRequestStore().create({ requester: { actorId: agent.id, actorType: "agent", actorName: agent.name }, taskId, runId, targetAction: { category, action: toolName, - summary: `Agent gated action for ${toolName}`, + summary: buildAgentGatedActionSummary(toolName, args), resourceType: "tool", resourceId: toolName, context: { toolName, toolArgs: args, source: "agent-gating", + ...(approvalDedupeKey ? { approvalDedupeKey } : {}), + ...(typeof (args as Record<string, unknown> | undefined)?.command === "string" + ? { command: (args as Record<string, unknown>).command } + : {}), + ...(typeof (args as Record<string, unknown> | undefined)?.cwd === "string" + ? { cwd: (args as Record<string, unknown>).cwd } + : {}), }, }, }), diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index bc69e6bcb6..5be6ce3532 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -77,6 +77,7 @@ import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWor import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; import { Type, type Static } from "@earendil-works/pi-ai"; import { describeModel, formatModelMarkerDetails, promptWithFallback, compactSessionContext } from "./pi.js"; +import { buildAgentGatedActionSummary } from "./permanent-agent-gating.js"; import { accumulateSessionTokenUsage, mergeTokenUsagePerModel } from "./session-token-usage.js"; import { createResolvedAgentSession, @@ -2302,7 +2303,14 @@ export class TaskExecutor { }, taskId, runId: taskId ? this.getRunContextFor(taskId)?.runId : undefined, - createApprovalRequest: async ({ category, toolName, args }) => this.approvalRequestStore.create({ + // FNXC:AgentGating 2026-07-05-00:00: + // FN-7609: operators approving a gated action need the real command/args, + // and a stateless heartbeat retrying the same command must reuse a single + // pending approval instead of minting duplicates. `summary` is now + // payload-bearing (shared helper) and `approvalDedupeKey`/`command`/`cwd` + // are persisted into targetAction.context so findPendingApprovalRequest + // can match and the UI can render the payload without re-parsing. + createApprovalRequest: async ({ category, toolName, args, approvalDedupeKey }) => this.approvalRequestStore.create({ requester: { actorId, actorType: "agent", @@ -2313,13 +2321,20 @@ export class TaskExecutor { targetAction: { category, action: toolName, - summary: `Agent gated action for ${toolName}`, + summary: buildAgentGatedActionSummary(toolName, args), resourceType: "tool", resourceId: toolName, context: { toolName, toolArgs: args, source: "agent-gating", + ...(approvalDedupeKey ? { approvalDedupeKey } : {}), + ...(typeof (args as Record<string, unknown> | undefined)?.command === "string" + ? { command: (args as Record<string, unknown>).command } + : {}), + ...(typeof (args as Record<string, unknown> | undefined)?.cwd === "string" + ? { cwd: (args as Record<string, unknown>).cwd } + : {}), }, }, }), diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index bf55e85200..3991b6a2e6 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -43,6 +43,59 @@ function extractShellCommand(args: Record<string, unknown>): string { return typeof command === "string" ? command.trim() : ""; } +const GATED_SUMMARY_COMMAND_MAX_LENGTH = 200; + +function truncateForSummary(value: string, maxLength: number): string { + const singleLine = value.replace(/\s+/g, " ").trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return `${singleLine.slice(0, maxLength - 1)}\u2026`; +} + +function renderCompactArgs(args: Record<string, unknown>): string { + const entries = Object.entries(args).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + return ""; + } + const rendered = entries + .slice(0, 4) + .map(([key, value]) => { + const stringValue = typeof value === "string" ? value : JSON.stringify(value); + return `${key}: ${truncateForSummary(String(stringValue ?? ""), 60)}`; + }) + .join(", "); + const suffix = entries.length > 4 ? ", \u2026" : ""; + return `{${rendered}${suffix}}`; +} + +/** + * FNXC:AgentGating 2026-07-05-00:00: + * FN-7609: operators approving a gated agent action need to see the real + * payload (shell command line, or tool arguments), not just a generic + * "Agent gated action for <tool>" placeholder. This pure helper builds a + * payload-bearing, human-readable summary shared by both permanent-agent + * gating context builders (executor.ts and agent-heartbeat.ts) so approval + * cards are actionable instead of blank. + */ +export function buildAgentGatedActionSummary(toolName: string, args: unknown): string { + const normalizedArgs = normalizeArgs(args); + + if (toolName === "bash") { + const command = extractShellCommand(normalizedArgs); + if (command) { + return `Run: ${truncateForSummary(command, GATED_SUMMARY_COMMAND_MAX_LENGTH)}`; + } + } + + const compactArgs = renderCompactArgs(normalizedArgs); + if (compactArgs) { + return `${toolName} ${compactArgs}`; + } + + return `Agent gated action for ${toolName}`; +} + export function classifyPermanentAgentToolCall( toolName: string, diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 591ee90b17..b454e35fb7 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1861,10 +1861,16 @@ export function wrapToolsWithPermanentAgentGating( let approvalRequest = await gating.findPendingApprovalRequest?.(dedupeKey); if (!approvalRequest && gating.createApprovalRequest) { + // FNXC:AgentGating 2026-07-05-00:00: + // FN-7609: pass the dedupe key through so the gating closure can persist + // it into targetAction.context.approvalDedupeKey — without this, the + // findPendingApprovalRequest lookup above can never match and every + // retrying heartbeat tick mints a fresh duplicate approval request. approvalRequest = await gating.createApprovalRequest({ category: normalizeApprovalRequestCategory(decision.category), toolName: decision.toolName, args: params, + approvalDedupeKey: dedupeKey, }); }