FN-7130: require agent-browser for browser verification
Browser Verification steps now explicitly request and log agent-browser usage. - Mark the built-in browser verification workflow step as browser-required and document that it runs through agent-browser. - Add executor helpers to inject the agent-browser skill, probe CLI availability, and log start/finish outcomes. - Cover workflow metadata, browser verification execution logging, availability probing, and skill augmentation with tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7130-browser-verification-agent-browser.md | 7 + docs/workflow-steps.md | 4 +- .../__tests__/builtin-coding-workflow-ir.test.ts | 23 ++ .../core/src/__tests__/builtin-workflows.test.ts | 12 + .../core/src/builtin-browser-verification-group.ts | 4 + packages/core/src/types.ts | 7 + ...iltin-coding-browser-verification-group.test.ts | 8 +- .../executor-browser-verification.test.ts | 267 +++++++++++++++++++++ packages/engine/src/executor.ts | 118 +++++++++ 9 files changed, 447 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7130 Fusion-Task-Lineage: 3a76522e-70e1-4445-ad18-b9e1b9b355fc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7130-browser-verification-agent-browser.md
Normal file
7
.changeset/fn-7130-browser-verification-agent-browser.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: The Browser Verification workflow step now uses the agent-browser tool, checks availability, and logs its actions.
|
||||
category: feature
|
||||
dev: Adds a `requiresBrowser` flag to `WorkflowStep`, set on the built-in browser-verification inner node and threaded through `runGraphCustomNode` into `executeWorkflowStep`, which merges the `agent-browser-navigation` skill, runs a bounded non-fatal `agent-browser --version` availability preflight (async exec), and emits start/availability agent-log entries. Absent the flag, prompt-step execution is unchanged.
|
||||
@@ -357,9 +357,11 @@ FN-7039 (U6) DELETED the `WORKFLOW_STEP_TEMPLATES` built-in catalog array (the f
|
||||
|
||||
The built-in quality gates ship as inlined `optional-group` node builders in `@fusion/core`, not as a template catalog (the former `WORKFLOW_STEP_TEMPLATES` array was removed):
|
||||
|
||||
- **Browser Verification** (`browser-verification`, `builtin-browser-verification-group.ts`) — browser-automation-style checks for UI validation flows; an optional-group node on `builtin:coding`.
|
||||
- **Browser Verification** (`browser-verification`, `builtin-browser-verification-group.ts`) — browser-automation-style checks for UI validation flows; an optional-group node on `builtin:coding` and `builtin:stepwise-coding`.
|
||||
- **Code Review** (`code-review`, `builtin-code-review-group.ts`) — the inlined code-review gate.
|
||||
|
||||
The Browser Verification inner prompt node carries `requiresBrowser: true` while keeping `toolMode: "coding"`. When that step runs, the executor best-effort adds the `agent-browser-navigation` skill (when the agent-browser plugin is installed), runs a bounded non-fatal `agent-browser --version` preflight, and writes start, availability, and finish entries into both the task log and the task's agent log. A missing or timed-out `agent-browser` binary is logged as an actionable warning rather than failing the step solely because of the preflight; the prompt can still fast-bail or report a normal verification failure. Because Bash tool events are already streamed to the agent log, `agent-browser open ...`, `agent-browser snapshot ...`, and related commands appear as the browser-verification activity the step performed.
|
||||
|
||||
Plugin-contributed gate kinds (documentation review, QA, security audit, performance, accessibility, frontend UX design, etc.) can still be supplied as palette templates; see [Plugin-Contributed Steps](#plugin-contributed-steps).
|
||||
|
||||
Built-in gate prompts emit the structured `{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}` envelope (final line JSON only). The legacy `REQUEST REVISION` prose path remains as a backward-compatible fallback. See [Prompt-mode Structured Verdict Contract](#prompt-mode-structured-verdict-contract).
|
||||
|
||||
@@ -7,9 +7,20 @@ import {
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
} from "../index.js";
|
||||
import { BROWSER_VERIFICATION_GROUP_ID, BROWSER_VERIFICATION_STEP_NODE_ID } from "../builtin-browser-verification-group.js";
|
||||
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
const EXECUTE_NODE_MAX_RETRIES = 2;
|
||||
|
||||
function browserVerificationInnerConfig(ir: WorkflowIrV2): Record<string, unknown> {
|
||||
const group = ir.nodes.find((node) => node.id === BROWSER_VERIFICATION_GROUP_ID);
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
const template = group?.config?.template as { nodes?: Array<{ id: string; config?: Record<string, unknown> }> } | undefined;
|
||||
const inner = template?.nodes?.find((node) => node.id === BROWSER_VERIFICATION_STEP_NODE_ID);
|
||||
expect(inner).toBeDefined();
|
||||
return inner?.config ?? {};
|
||||
}
|
||||
|
||||
function executeNodeConfig(ir = BUILTIN_CODING_WORKFLOW_IR): Record<string, unknown> {
|
||||
const executeNodes = ir.nodes.filter((node) => node.id === "execute" && node.config?.seam === "execute");
|
||||
expect(executeNodes).toHaveLength(1);
|
||||
@@ -53,6 +64,11 @@ describe("builtin coding workflow ir", () => {
|
||||
expect(group?.kind).toBe("optional-group");
|
||||
expect(group?.config?.name).toBe("Browser Verification");
|
||||
expect(group?.config?.defaultOn).toBe(false);
|
||||
expect(browserVerificationInnerConfig(BUILTIN_CODING_WORKFLOW_IR)).toMatchObject({
|
||||
toolMode: "coding",
|
||||
gateMode: "advisory",
|
||||
requiresBrowser: true,
|
||||
});
|
||||
// execute → browser-verification → code-review → review on the success path; the
|
||||
// pre-merge code-review optional-group sits next to browser-verification. failure → end.
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toEqual(
|
||||
@@ -149,6 +165,13 @@ describe("builtin coding workflow ir", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks browser verification as browser-capable in both coding built-ins", () => {
|
||||
expect(browserVerificationInnerConfig(BUILTIN_CODING_WORKFLOW_IR).requiresBrowser).toBe(true);
|
||||
expect(browserVerificationInnerConfig(BUILTIN_STEPWISE_CODING_WORKFLOW_IR).requiresBrowser).toBe(true);
|
||||
expect(browserVerificationInnerConfig(BUILTIN_CODING_WORKFLOW_IR).toolMode).toBe("coding");
|
||||
expect(browserVerificationInnerConfig(BUILTIN_STEPWISE_CODING_WORKFLOW_IR).toolMode).toBe("coding");
|
||||
});
|
||||
|
||||
it("expresses merge policy regions in stepwise and PR built-ins", () => {
|
||||
expect(BUILTIN_STEPWISE_CODING_WORKFLOW_IR.nodes.map((node) => node.kind)).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isBuiltinWorkflowPluginGated,
|
||||
} from "../builtin-workflows.js";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BROWSER_VERIFICATION_GROUP_ID, BROWSER_VERIFICATION_STEP_NODE_ID } from "../builtin-browser-verification-group.js";
|
||||
import { builtinPromptConfig, BUILTIN_SEAM_PROMPTS } from "../builtin-workflow-prompts.js";
|
||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
|
||||
import { resolveColumnFlags } from "../trait-registry.js";
|
||||
@@ -18,6 +19,12 @@ import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
const EXECUTE_NODE_MAX_RETRIES = 2;
|
||||
|
||||
function browserVerificationInnerConfig(ir: { nodes: Array<{ id: string; kind: string; config?: Record<string, unknown> }> }): Record<string, unknown> {
|
||||
const group = ir.nodes.find((node) => node.id === BROWSER_VERIFICATION_GROUP_ID);
|
||||
const template = group?.config?.template as { nodes?: Array<{ id: string; config?: Record<string, unknown> }> } | undefined;
|
||||
return template?.nodes?.find((node) => node.id === BROWSER_VERIFICATION_STEP_NODE_ID)?.config ?? {};
|
||||
}
|
||||
|
||||
describe("built-in workflows", () => {
|
||||
// Non-compiler built-ins model graph-only node kinds or reusable fragments the
|
||||
// linear compiler cannot lower to a step list. They still must parse as valid IR.
|
||||
@@ -165,6 +172,11 @@ describe("built-in workflows", () => {
|
||||
expect(byId.get("workflow-step")).toBeUndefined();
|
||||
expect(byId.get("browser-verification")?.kind).toBe("optional-group");
|
||||
expect(byId.get("browser-verification")?.column).toBe("in-progress");
|
||||
expect(browserVerificationInnerConfig(ir)).toMatchObject({
|
||||
toolMode: "coding",
|
||||
gateMode: "advisory",
|
||||
requiresBrowser: true,
|
||||
});
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
// Merge is the native primitive region (FN-6035), placed in in-review.
|
||||
expect(byId.get("merge")).toBeUndefined();
|
||||
|
||||
@@ -24,6 +24,9 @@ to the former `browser-verification` catalog entry). These built-ins are the par
|
||||
oracle, so the produced node bytes must NOT change. Plugin-contributed templates still
|
||||
use the `WorkflowStepTemplate` shape via the editor palette, but built-ins no longer
|
||||
read from a shared array.
|
||||
|
||||
FNXC:WorkflowBrowserVerification 2026-06-27-13:20:
|
||||
The Browser Verification step must actually use the `agent-browser` workflow surface, not only mention it in the prompt. `requiresBrowser` tells the engine to load the agent-browser navigation skill when available, preflight CLI availability, and surface start/probe/tool activity in the task agent log while keeping the step's coding tool mode intact.
|
||||
*/
|
||||
|
||||
/** Stable per-task enable key + group node id (preserved from the prior templateId). */
|
||||
@@ -100,6 +103,7 @@ export function browserVerificationOptionalGroupNode(column: string): WorkflowIr
|
||||
prompt: BROWSER_VERIFICATION_PROMPT,
|
||||
toolMode: "coding",
|
||||
gateMode: "advisory",
|
||||
requiresBrowser: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -658,6 +658,13 @@ export interface WorkflowStep {
|
||||
* skill (discovery + selection) and the engine injects the Fusion workflow-step
|
||||
* conventions preamble. Only meaningful for skill-executor graph nodes. */
|
||||
skillName?: string;
|
||||
/**
|
||||
* Browser capability requested by prompt-mode steps. When true, the executor
|
||||
* loads the agent-browser navigation skill when available, preflights the
|
||||
* `agent-browser` CLI, and records browser-verification activity in the agent
|
||||
* log. Ignored for script-mode steps.
|
||||
*/
|
||||
requiresBrowser?: boolean;
|
||||
/** Name of a script from project settings `scripts` map to execute (required when mode is "script") */
|
||||
scriptName?: string;
|
||||
/** Whether this step is available for selection on new tasks */
|
||||
|
||||
@@ -35,7 +35,10 @@ function codingTask(enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
* handler keyed on the inner template node id; everything else succeeds. */
|
||||
function makeExecutor(onInnerStep: () => void) {
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") onInnerStep();
|
||||
if (node.id === "browser-verification-step") {
|
||||
expect(node.config?.requiresBrowser).toBe(true);
|
||||
onInnerStep();
|
||||
}
|
||||
return { outcome: "success" };
|
||||
});
|
||||
return new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
@@ -55,7 +58,8 @@ describe("builtin coding browser-verification optional-group (U6)", () => {
|
||||
disabledRuns++;
|
||||
}).run(codingTask(), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// The browser-verification step ran exactly once when enabled, never when off.
|
||||
// The browser-verification step ran exactly once when enabled, carrying the
|
||||
// browser capability flag; the disabled task never materializes the inner step.
|
||||
expect(enabledRuns).toBe(1);
|
||||
expect(disabledRuns).toBe(0);
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./executor-test-helpers.js";
|
||||
import type { WorkflowIrNode } from "@fusion/core";
|
||||
import {
|
||||
AGENT_BROWSER_NAVIGATION_SKILL_ID,
|
||||
augmentSessionSkillsForBrowserStep,
|
||||
formatAgentBrowserAvailabilityLog,
|
||||
probeAgentBrowserAvailability,
|
||||
TaskExecutor,
|
||||
} from "../executor.js";
|
||||
import { summarizeToolArgs } from "../agent-logger.js";
|
||||
import {
|
||||
createMockStore,
|
||||
mockedCreateFnAgent,
|
||||
mockedExecSync,
|
||||
resetExecutorMocks,
|
||||
} from "./executor-test-helpers.js";
|
||||
|
||||
type CapturedSession = {
|
||||
skillSelection?: { requestedSkillNames?: string[]; projectRootDir?: string; sessionPurpose?: string };
|
||||
};
|
||||
|
||||
function captureSession(output = '{"verdict":"APPROVE","notes":""}') {
|
||||
const holder: { last?: CapturedSession } = {};
|
||||
mockedCreateFnAgent.mockImplementation(async (opts: any) => {
|
||||
holder.last = { skillSelection: opts.skillSelection };
|
||||
const listeners: Array<(event: any) => void> = [];
|
||||
return {
|
||||
session: {
|
||||
state: {},
|
||||
subscribe: (fn: (event: any) => void) => {
|
||||
listeners.push(fn);
|
||||
return () => {};
|
||||
},
|
||||
prompt: vi.fn(async () => {
|
||||
for (const fn of listeners) {
|
||||
fn({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
partial: output,
|
||||
contentIndex: 0,
|
||||
delta: output,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
return holder;
|
||||
}
|
||||
|
||||
function baseTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "FN-7130",
|
||||
title: "Browser verification",
|
||||
description: "exercise browser verification",
|
||||
column: "in-progress" as const,
|
||||
worktree: "/tmp/wt",
|
||||
branch: "fusion/fn-7130",
|
||||
baseCommitSha: "abc123",
|
||||
dependencies: [],
|
||||
steps: [{ name: "s", status: "in-progress" as const }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(store: ReturnType<typeof createMockStore>) {
|
||||
return new TaskExecutor(store as any, "/tmp/test", {
|
||||
agentStore: { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() },
|
||||
} as any);
|
||||
}
|
||||
|
||||
function browserVerificationStep(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "graph:browser-verification-step",
|
||||
name: "Browser Verification",
|
||||
description: "",
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
gateMode: "advisory",
|
||||
prompt: "Verify in browser.",
|
||||
toolMode: "coding",
|
||||
requiresBrowser: true,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser-verification workflow-step browser capability", () => {
|
||||
beforeEach(() => {
|
||||
resetExecutorMocks();
|
||||
});
|
||||
|
||||
it("probes agent-browser availability as a bounded non-fatal helper", async () => {
|
||||
await expect(probeAgentBrowserAvailability(async (command, options) => {
|
||||
expect(command).toBe("agent-browser --version");
|
||||
expect(options.timeout).toBeLessThanOrEqual(10_000);
|
||||
return { stdout: "agent-browser 1.2.3\n", stderr: "" };
|
||||
})).resolves.toEqual({ available: true, version: "agent-browser 1.2.3" });
|
||||
|
||||
await expect(probeAgentBrowserAvailability(async () => {
|
||||
const err = new Error("spawn agent-browser ENOENT") as Error & { code: string };
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
})).resolves.toEqual({ available: false, reason: "not installed" });
|
||||
|
||||
await expect(probeAgentBrowserAvailability(async () => {
|
||||
const err = new Error("Command timed out") as Error & { code: string; killed: boolean };
|
||||
err.code = "ETIMEDOUT";
|
||||
err.killed = true;
|
||||
throw err;
|
||||
})).resolves.toEqual({ available: false, reason: "probe timed out" });
|
||||
});
|
||||
|
||||
it("merges the agent-browser navigation skill idempotently", () => {
|
||||
expect(augmentSessionSkillsForBrowserStep(undefined, "/repo")).toEqual({
|
||||
projectRootDir: "/repo",
|
||||
sessionPurpose: "executor",
|
||||
requestedSkillNames: [AGENT_BROWSER_NAVIGATION_SKILL_ID],
|
||||
});
|
||||
|
||||
expect(augmentSessionSkillsForBrowserStep({
|
||||
projectRootDir: "/repo",
|
||||
sessionPurpose: "executor",
|
||||
requestedSkillNames: ["existing", AGENT_BROWSER_NAVIGATION_SKILL_ID],
|
||||
}, "/fallback").requestedSkillNames).toEqual(["existing", AGENT_BROWSER_NAVIGATION_SKILL_ID]);
|
||||
});
|
||||
|
||||
it("materializes requiresBrowser from graph prompt config and omits it when absent", async () => {
|
||||
const store = createMockStore();
|
||||
store.getTask.mockImplementation(async (id: string) => baseTask({ id }));
|
||||
const executor = makeExecutor(store);
|
||||
const captured: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (_task: unknown, step: Record<string, unknown>) => {
|
||||
captured.push(step);
|
||||
return { success: true, output: "ok" };
|
||||
});
|
||||
|
||||
const browserNode: WorkflowIrNode = {
|
||||
id: "custom-browser-step",
|
||||
kind: "prompt",
|
||||
config: { prompt: "Verify", toolMode: "coding", requiresBrowser: true },
|
||||
};
|
||||
const plainNode: WorkflowIrNode = { id: "plain-step", kind: "prompt", config: { prompt: "Review" } };
|
||||
|
||||
await (executor as any).runGraphCustomNode(browserNode, baseTask(), {}, undefined);
|
||||
await (executor as any).runGraphCustomNode(plainNode, baseTask(), {}, undefined);
|
||||
|
||||
expect(captured[0]).toMatchObject({ id: "graph:custom-browser-step", requiresBrowser: true, toolMode: "coding" });
|
||||
expect(captured[1]).not.toHaveProperty("requiresBrowser");
|
||||
});
|
||||
|
||||
it("summarizes bash agent-browser commands for agent-log tool entries", () => {
|
||||
expect(summarizeToolArgs("bash", { command: "agent-browser open http://localhost:5173" })).toBe(
|
||||
"agent-browser open http://localhost:5173",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs browser verification start, availability, and finish while augmenting session skills", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = makeExecutor(store);
|
||||
const cap = captureSession();
|
||||
mockedExecSync.mockImplementation((command: string) => {
|
||||
if (command === "agent-browser --version") return Buffer.from("agent-browser 9.9.9\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await (executor as any).executeWorkflowStep(
|
||||
baseTask(),
|
||||
browserVerificationStep(),
|
||||
"/tmp/wt",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(cap.last?.skillSelection?.requestedSkillNames).toContain(AGENT_BROWSER_NAVIGATION_SKILL_ID);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-7130",
|
||||
"[browser-verification] agent-browser available — version agent-browser 9.9.9",
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-7130",
|
||||
expect.stringContaining("[browser-verification] starting browser verification"),
|
||||
"text",
|
||||
undefined,
|
||||
"reviewer",
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-7130",
|
||||
"[browser-verification] agent-browser available — version agent-browser 9.9.9",
|
||||
"text",
|
||||
undefined,
|
||||
"reviewer",
|
||||
);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-7130",
|
||||
"[browser-verification] finished browser verification for task FN-7130: verdict APPROVE",
|
||||
"text",
|
||||
undefined,
|
||||
"reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs an actionable warning and continues when agent-browser is missing", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = makeExecutor(store);
|
||||
captureSession();
|
||||
mockedExecSync.mockImplementation((command: string) => {
|
||||
if (command === "agent-browser --version") {
|
||||
const err = new Error("spawn agent-browser ENOENT") as Error & { code: string };
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await (executor as any).executeWorkflowStep(
|
||||
baseTask(),
|
||||
browserVerificationStep(),
|
||||
"/tmp/wt",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const warning = "[browser-verification] agent-browser not found on PATH — the step relies on the agent-browser CLI; install the agent-browser plugin/binary. Continuing; the step may fast-bail or fail.";
|
||||
expect(result.success).toBe(true);
|
||||
expect(formatAgentBrowserAvailabilityLog({ available: false, reason: "not installed" })).toBe(warning);
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-7130", warning);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-7130", warning, "text", undefined, "reviewer");
|
||||
});
|
||||
|
||||
it("keeps flag-absent prompt steps byte-inert for browser logging and skills", async () => {
|
||||
const store = createMockStore();
|
||||
const executor = makeExecutor(store);
|
||||
const cap = captureSession();
|
||||
mockedExecSync.mockImplementation((command: string) => {
|
||||
if (command === "agent-browser --version") throw new Error("should not probe agent-browser");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await (executor as any).executeWorkflowStep(
|
||||
baseTask(),
|
||||
browserVerificationStep({ id: "graph:plain", name: "Plain", toolMode: "readonly", requiresBrowser: undefined }),
|
||||
"/tmp/wt",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(cap.last?.skillSelection?.requestedSkillNames ?? []).not.toContain(AGENT_BROWSER_NAVIGATION_SKILL_ID);
|
||||
expect(store.logEntry.mock.calls.some(([, message]: [string, string]) => message.includes("[browser-verification]"))).toBe(false);
|
||||
expect(store.appendAgentLog.mock.calls.some(([, message]: [string, string]) => message.includes("[browser-verification]"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
|
||||
|
||||
// Internal git plumbing intentionally bypasses sandbox backends.
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
resolveExecutorSessionModel,
|
||||
} from "./agent-session-helpers.js";
|
||||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
import { resolveMcpServersForStore } from "./mcp-resolution.js";
|
||||
import { reviewStep, type ReviewVerdict, type ReviewResult } from "./reviewer.js";
|
||||
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
|
||||
@@ -245,6 +247,89 @@ export {
|
||||
taskLogParams,
|
||||
} from "./agent-tools.js";
|
||||
|
||||
export const AGENT_BROWSER_NAVIGATION_SKILL_ID = "agent-browser-navigation";
|
||||
|
||||
export interface AgentBrowserAvailabilityProbeResult {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
type AgentBrowserExec = (
|
||||
command: string,
|
||||
options: { encoding: BufferEncoding; timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv; cwd?: string },
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
function isAgentBrowserNotFoundError(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; stderr?: unknown; message?: unknown } | null;
|
||||
const code = typeof err?.code === "string" || typeof err?.code === "number" ? String(err.code) : undefined;
|
||||
if (code === "ENOENT" || code === "127") return true;
|
||||
const combined = `${typeof err?.stderr === "string" ? err.stderr : ""}\n${typeof err?.message === "string" ? err.message : ""}`.toLowerCase();
|
||||
return combined.includes("agent-browser") && (combined.includes("not found") || combined.includes("command not found"));
|
||||
}
|
||||
|
||||
function isAgentBrowserProbeTimeout(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; killed?: unknown; signal?: unknown; message?: unknown } | null;
|
||||
return err?.code === "ETIMEDOUT"
|
||||
|| err?.killed === true
|
||||
|| err?.signal === "SIGTERM"
|
||||
|| (typeof err?.message === "string" && err.message.toLowerCase().includes("timed out"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the agent-browser CLI without making browser verification fatal.
|
||||
*
|
||||
* FNXC:WorkflowBrowserVerification 2026-06-27-13:20:
|
||||
* Browser Verification needs an actionable signal when `agent-browser` is absent or hung. Keep this async, bounded, and injectable so the executor logs availability without blocking or requiring the plugin at import time.
|
||||
*/
|
||||
export async function probeAgentBrowserAvailability(
|
||||
execImpl: AgentBrowserExec = execAsync as AgentBrowserExec,
|
||||
opts?: { timeoutMs?: number; maxBuffer?: number; env?: NodeJS.ProcessEnv; cwd?: string },
|
||||
): Promise<AgentBrowserAvailabilityProbeResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execImpl("agent-browser --version", {
|
||||
encoding: "utf-8",
|
||||
timeout: Math.min(Math.max(opts?.timeoutMs ?? 5_000, 1_000), 10_000),
|
||||
maxBuffer: opts?.maxBuffer ?? 64 * 1024,
|
||||
...(opts?.env ? { env: opts.env } : {}),
|
||||
...(opts?.cwd ? { cwd: opts.cwd } : {}),
|
||||
});
|
||||
const version = (stdout.trim() || stderr.trim() || "unknown").split("\n")[0]?.trim() || "unknown";
|
||||
return { available: true, version };
|
||||
} catch (error) {
|
||||
if (isAgentBrowserNotFoundError(error)) {
|
||||
return { available: false, reason: "not installed" };
|
||||
}
|
||||
if (isAgentBrowserProbeTimeout(error)) {
|
||||
return { available: false, reason: "probe timed out" };
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return { available: false, reason };
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge the agent-browser navigation skill into a workflow-step session. */
|
||||
export function augmentSessionSkillsForBrowserStep(
|
||||
skillSelection: SkillSelectionContext | undefined,
|
||||
projectRootDir: string,
|
||||
): SkillSelectionContext {
|
||||
const existing = skillSelection?.requestedSkillNames ?? [];
|
||||
return {
|
||||
projectRootDir: skillSelection?.projectRootDir ?? projectRootDir,
|
||||
sessionPurpose: skillSelection?.sessionPurpose ?? "executor",
|
||||
requestedSkillNames: [...new Set([...existing, AGENT_BROWSER_NAVIGATION_SKILL_ID])],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAgentBrowserAvailabilityLog(result: AgentBrowserAvailabilityProbeResult): string {
|
||||
if (result.available) {
|
||||
return `[browser-verification] agent-browser available — version ${result.version ?? "unknown"}`;
|
||||
}
|
||||
if (result.reason === "probe timed out") {
|
||||
return "[browser-verification] agent-browser availability probe timed out — the step relies on the agent-browser CLI; continuing so the step can fast-bail or report its own failure.";
|
||||
}
|
||||
return "[browser-verification] agent-browser not found on PATH — the step relies on the agent-browser CLI; install the agent-browser plugin/binary. Continuing; the step may fast-bail or fail.";
|
||||
}
|
||||
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
|
||||
|
||||
function getPromptSection(prompt: string, heading: string): string {
|
||||
@@ -6433,6 +6518,7 @@ export class TaskExecutor {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(stepSkillName ? { skillName: stepSkillName } : {}),
|
||||
...(cfg.requiresBrowser === true ? { requiresBrowser: true } : {}),
|
||||
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
|
||||
};
|
||||
|
||||
@@ -12869,6 +12955,20 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
);
|
||||
}
|
||||
const additionalSkillPaths = ceSkillsDir ? [ceSkillsDir] : undefined;
|
||||
const logBrowserVerificationActivity = async (message: string) => {
|
||||
await this.store.logEntry(task.id, message);
|
||||
await this.store.appendAgentLog(task.id, message, "text", undefined, "reviewer");
|
||||
};
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
effectiveSkillSelection = augmentSessionSkillsForBrowserStep(effectiveSkillSelection, this.rootDir);
|
||||
await logBrowserVerificationActivity(`[browser-verification] starting browser verification for task ${task.id} using step '${workflowStep.name}'`);
|
||||
const browserProbe = await probeAgentBrowserAvailability(execAsync as AgentBrowserExec, {
|
||||
cwd: worktreePath,
|
||||
env: stepEnv,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await logBrowserVerificationActivity(formatAgentBrowserAvailabilityLog(browserProbe));
|
||||
}
|
||||
|
||||
// (U8b) Coding-mode skill steps fan out to ce-<persona> subagents via
|
||||
// fn_spawn_agent (read the persona def, pass its body as systemPromptOverride).
|
||||
@@ -12987,6 +13087,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
task.id,
|
||||
`Workflow step '${workflowStep.name}' ${attemptLabel === "primary" ? "primary" : "fallback"} model timed out after ${Math.round(timeoutMs / 1000)}s — aborting session`,
|
||||
);
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: timed out`);
|
||||
}
|
||||
try { session.dispose(); } catch { /* best-effort */ }
|
||||
await agentLogger.flush();
|
||||
return { success: false, error: `workflow step timed out after ${timeoutMs}ms`, timedOut: true };
|
||||
@@ -13004,6 +13107,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
const parsed = this.parseWorkflowStepOutput(output);
|
||||
if (parsed.verdict) {
|
||||
const revisionRequested = parsed.verdict === "REVISE";
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: verdict ${parsed.verdict}`);
|
||||
}
|
||||
return {
|
||||
success: !revisionRequested,
|
||||
revisionRequested,
|
||||
@@ -13018,6 +13124,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
task.id,
|
||||
`[pre-merge] Workflow step '${workflowStep.name}' produced malformed output — blocking gate success`,
|
||||
);
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: malformed output`);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: parsed.output,
|
||||
@@ -13027,6 +13136,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: completed`);
|
||||
}
|
||||
return { success: true, output: parsed.output };
|
||||
} catch (err: unknown) {
|
||||
await agentLogger.flush();
|
||||
@@ -13038,9 +13150,15 @@ You have access to the file system to review changes.${verdictBlock}`;
|
||||
task.id,
|
||||
`[readonly-violation] Workflow step '${workflowStep.name}' attempted denied tool '${deniedTool}'`,
|
||||
);
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: readonly violation`);
|
||||
}
|
||||
return { success: false, error: `[readonly-violation] ${violation.message}` };
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (workflowStep.requiresBrowser === true) {
|
||||
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: failed — ${errorMessage}`);
|
||||
}
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
|
||||
Reference in New Issue
Block a user