diff --git a/.changeset/fn-7130-browser-verification-agent-browser.md b/.changeset/fn-7130-browser-verification-agent-browser.md new file mode 100644 index 0000000000..6b4f96fb75 --- /dev/null +++ b/.changeset/fn-7130-browser-verification-agent-browser.md @@ -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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 308688598e..dc9877bd30 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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). diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index db3815a4d4..88689b9344 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -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 { + 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 }> } | 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 { 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([ diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index c7c0399c8d..203b4c540b 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -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 }> }): Record { + const group = ir.nodes.find((node) => node.id === BROWSER_VERIFICATION_GROUP_ID); + const template = group?.config?.template as { nodes?: Array<{ id: string; config?: Record }> } | 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(); diff --git a/packages/core/src/builtin-browser-verification-group.ts b/packages/core/src/builtin-browser-verification-group.ts index 76287a6169..a4979c1e36 100644 --- a/packages/core/src/builtin-browser-verification-group.ts +++ b/packages/core/src/builtin-browser-verification-group.ts @@ -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, }, }, ], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 36cf7db6cd..9c38ed82fd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 */ diff --git a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts index b3500f4128..93498e5436 100644 --- a/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts +++ b/packages/engine/src/__tests__/builtin-coding-browser-verification-group.test.ts @@ -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(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); diff --git a/packages/engine/src/__tests__/executor-browser-verification.test.ts b/packages/engine/src/__tests__/executor-browser-verification.test.ts new file mode 100644 index 0000000000..5e716b759f --- /dev/null +++ b/packages/engine/src/__tests__/executor-browser-verification.test.ts @@ -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 = {}) { + 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) { + return new TaskExecutor(store as any, "/tmp/test", { + agentStore: { getAgent: vi.fn().mockResolvedValue(null), createAgent: vi.fn() }, + } as any); +} + +function browserVerificationStep(overrides: Record = {}) { + 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> = []; + vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (_task: unknown, step: Record) => { + 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); + }); +}); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 9dd5587807..b42703ae7d 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 { + 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 => 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- 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);