feat(FN-3548): add agent action routes and approval pause/resume lifecycle

Merges the FN-3548 approval pause/resume system: agents now stall at workflow gates pending approval, with a full lifecycle spanning gate context lookup, action-gate pause/retry, executor and heartbeat pause callbacks, and dedicated approval decision routes. Also lands FN-3744 agent action routes an

Fusion-Task-Id: FN-3548
This commit is contained in:
Fusion
2026-05-08 20:19:15 -07:00
committed by gsxdsm
parent c27fd0a1f9
commit 92ca3a238f
17 changed files with 833 additions and 69 deletions

View File

@@ -5,6 +5,7 @@ import {
evaluateAgentActionGate,
getExemptToolNames,
reloadExemptTools,
resolveGateOutcome,
} from "../agent-action-gate.js";
import type { AgentPermissionPolicy } from "@fusion/core";
@@ -215,6 +216,65 @@ describe("agent-action-gate", () => {
expect(result.disposition).toBe("require-approval");
});
it("resolveGateOutcome waits when there is no latest request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, null)).toEqual({ outcome: "wait-for-approval" });
});
it("resolveGateOutcome reuses pending request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "pending" })).toEqual({
outcome: "wait-for-approval",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome executes once on approved request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "approved" })).toEqual({
outcome: "execute-once-then-complete",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome blocks denied request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "denied" })).toEqual({
outcome: "block",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome requires new approval after completion", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "completed" })).toEqual({ outcome: "wait-for-approval" });
});
it("computes deterministic dedupe key", () => {
const key = computeApprovalDedupeKey({
agentId: "agent-1",

View File

@@ -151,6 +151,42 @@ describe("buildExecutionPrompt", () => {
});
});
describe("TaskExecutor action gate context", () => {
it("pauses task and agent for approval and marks completion", async () => {
const store = createMockStore();
store.pauseTask = vi.fn().mockResolvedValue(undefined);
store.logEntry = vi.fn().mockResolvedValue(undefined);
const agentStore = {
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
} as any;
const executor = new TaskExecutor(store as any, "/tmp/project", { agentStore });
(executor as any).currentRunContext = { runId: "run-1" };
const context = (executor as any).buildActionGateContext("FN-1", { id: "agent-1", name: "Agent One", permissionPolicy: undefined });
await context.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, { runId: "run-1" }, { pausedByAgentId: "agent-1" });
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-1", "paused");
expect(agentStore.updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: "awaiting-approval" });
});
});
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
describe("TaskExecutor skillSelection regression (FN-1511)", () => {

View File

@@ -267,6 +267,59 @@ describe("executeHeartbeat", () => {
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
});
it("pauseForApproval pauses task and agent when taskId exists", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const pauseTask = vi.fn().mockResolvedValue(undefined);
const logEntry = vi.fn().mockResolvedValue(undefined);
mockTaskStore = createMockTaskStore({ pauseTask, logEntry });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, "FN-001", "run-1");
await ctx.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(pauseTask).toHaveBeenCalledWith("FN-001", true, undefined, { pausedByAgentId: "agent-001" });
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
});
it("pauseForApproval still pauses agent when taskId is undefined", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const pauseTask = vi.fn().mockResolvedValue(undefined);
mockTaskStore = createMockTaskStore({ pauseTask });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, undefined, "run-1");
await ctx.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(pauseTask).not.toHaveBeenCalled();
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
});
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
const store = createStoreWithAgentForExec({
taskId: "FN-001",

View File

@@ -532,6 +532,22 @@ describe("wrapToolsWithPermanentAgentGating", () => {
});
describe("wrapToolsWithActionGate", () => {
const lockedDownRules = {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
} as const;
const approvalRules = {
"git_write": "require-approval",
"file_write_delete": "require-approval",
"command_execution": "require-approval",
"network_api": "require-approval",
"task_agent_mutation": "require-approval",
} as const;
it("blocks disallowed actions and skips underlying tool", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithActionGate } = await import("../pi.js");
@@ -540,18 +556,9 @@ describe("wrapToolsWithActionGate", () => {
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
@@ -566,51 +573,100 @@ describe("wrapToolsWithActionGate", () => {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: true,
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
expect(tool.execute).toHaveBeenCalled();
});
it("creates approval request once for require-approval", async () => {
it("creates request once and pauses once while pending", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });
const findPendingApprovalByDedupeKey = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "apr-1" });
const pauseForApproval = vi.fn();
const findApprovalByDedupeKey = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "apr-1", status: "pending" });
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
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",
},
},
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest,
findPendingApprovalByDedupeKey,
findApprovalByDedupeKey,
pauseForApproval,
});
const first = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
const second = await (wrapped[0] as any).execute("t2", { path: "a.ts" });
expect((first as any).decision.metadata.approvalRequestId).toBe("apr-1");
expect((second as any).decision.metadata.approvalRequestId).toBe("apr-1");
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(pauseForApproval).toHaveBeenCalledTimes(1);
expect(tool.execute).not.toHaveBeenCalled();
});
it("executes once and marks completed for approved retry", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
const markApprovalCompleted = vi.fn();
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
markApprovalCompleted,
});
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
await (wrapped[0] as any).execute("t2", { path: "a.ts" });
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(markApprovalCompleted).toHaveBeenCalledWith("apr-2");
});
it("does not mark completed when approved execution throws", async () => {
const error = new Error("write failed");
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockRejectedValue(error) };
const markApprovalCompleted = vi.fn();
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
markApprovalCompleted,
});
await expect((wrapped[0] as any).execute("t1", { path: "a.ts" })).rejects.toThrow("write failed");
expect(markApprovalCompleted).not.toHaveBeenCalled();
});
it("returns rejection and never executes when latest decision is denied", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-3", status: "denied" }),
});
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
expect((result as any).isError).toBe(true);
expect((result as any).error).toContain("denied by approver");
expect(tool.execute).not.toHaveBeenCalled();
});
@@ -623,18 +679,9 @@ describe("wrapToolsWithActionGate", () => {
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await (wrapped[0] as any).execute("t1", {});