diff --git a/packages/core/lib/v3/agent/tools/goto.ts b/packages/core/lib/v3/agent/tools/goto.ts index ac8900b35..cb981f41a 100644 --- a/packages/core/lib/v3/agent/tools/goto.ts +++ b/packages/core/lib/v3/agent/tools/goto.ts @@ -1,12 +1,19 @@ import { tool } from "ai"; import { z } from "zod"; import type { V3 } from "../../v3.js"; +import type { Variables } from "../../types/public/agent.js"; +import { substituteVariables } from "../utils/variables.js"; -export const gotoTool = (v3: V3) => - tool({ +export const gotoTool = (v3: V3, variables?: Variables) => { + const hasVariables = variables && Object.keys(variables).length > 0; + const urlDescription = hasVariables + ? `The URL to navigate to. Use %variableName% to substitute a variable value. Available: ${Object.keys(variables).join(", ")}` + : "The URL to navigate to"; + + return tool({ description: "Navigate to a specific URL", inputSchema: z.object({ - url: z.string().describe("The URL to navigate to"), + url: z.string().describe(urlDescription), }), execute: async ({ url }) => { try { @@ -21,8 +28,9 @@ export const gotoTool = (v3: V3) => }, }, }); + const resolvedUrl = substituteVariables(url, variables); const page = await v3.context.awaitActivePage(); - await page.goto(url, { waitUntil: "load" }); + await page.goto(resolvedUrl, { waitUntil: "load" }); v3.recordAgentReplayStep({ type: "goto", url, waitUntil: "load" }); return { success: true, url }; } catch (error) { @@ -30,3 +38,4 @@ export const gotoTool = (v3: V3) => } }, }); +}; diff --git a/packages/core/lib/v3/agent/tools/index.ts b/packages/core/lib/v3/agent/tools/index.ts index 2c644e06c..cf5de9553 100644 --- a/packages/core/lib/v3/agent/tools/index.ts +++ b/packages/core/lib/v3/agent/tools/index.ts @@ -167,7 +167,7 @@ export function createAgentTools(v3: V3, options?: V3AgentToolOptions) { extract: extractTool(v3, executionModel, toolTimeout), fillForm: fillFormTool(v3, executionModel, variables, toolTimeout), fillFormVision: fillFormVisionTool(v3, provider, variables), - goto: gotoTool(v3), + goto: gotoTool(v3, variables), keys: keysTool(v3, variables), navback: navBackTool(v3), screenshot: screenshotTool(v3), diff --git a/packages/core/lib/v3/cache/AgentCache.ts b/packages/core/lib/v3/cache/AgentCache.ts index 4d78b8dfc..99e8a7296 100644 --- a/packages/core/lib/v3/cache/AgentCache.ts +++ b/packages/core/lib/v3/cache/AgentCache.ts @@ -649,7 +649,11 @@ export class AgentCache { variables, ); case "goto": - await this.replayAgentGotoStep(step as AgentReplayGotoStep, ctx); + await this.replayAgentGotoStep( + step as AgentReplayGotoStep, + ctx, + variables, + ); return step; case "scroll": await this.replayAgentScrollStep(step as AgentReplayScrollStep, ctx); @@ -771,9 +775,11 @@ export class AgentCache { private async replayAgentGotoStep( step: AgentReplayGotoStep, ctx: V3Context, + variables?: Record, ): Promise { const page = await ctx.awaitActivePage(); - await page.goto(step.url, { waitUntil: step.waitUntil ?? "load" }); + const resolvedUrl = substituteVariables(step.url, variables); + await page.goto(resolvedUrl, { waitUntil: step.waitUntil ?? "load" }); } private async replayAgentScrollStep( diff --git a/packages/core/tests/unit/cache-llm-resolution.test.ts b/packages/core/tests/unit/cache-llm-resolution.test.ts index a1c58a3f9..687de47d8 100644 --- a/packages/core/tests/unit/cache-llm-resolution.test.ts +++ b/packages/core/tests/unit/cache-llm-resolution.test.ts @@ -229,4 +229,72 @@ describe("Cache LLM client selection", () => { waitUntil: "load", }); }); + + it("AgentCache substitutes variables for goto steps during replay", async () => { + const gotoEntry: CachedAgentEntry = { + version: 1, + instruction: "navigate with vars", + startUrl: "https://example.com/source", + options: {}, + configSignature: "sig", + steps: [ + { + type: "goto", + url: "https://example.com/%path%?token=%token%", + waitUntil: "load", + }, + ], + result: { success: true, actions: [] } as AgentResult, + timestamp: new Date().toISOString(), + }; + + const storage = { + enabled: true, + readJson: vi.fn().mockResolvedValue({ value: gotoEntry }), + writeJson: vi.fn().mockResolvedValue({}), + directory: "/tmp/cache", + } as unknown as CacheStorage; + + const handler = { + takeDeterministicAction: vi.fn(), + } as unknown as ActHandler; + + const fakePage = { goto: vi.fn() } as unknown as Page; + const ctx = { + awaitActivePage: vi.fn().mockResolvedValue(fakePage), + } as unknown as V3Context; + + const cache = new AgentCache({ + storage, + logger: vi.fn(), + getActHandler: () => handler, + getContext: () => ctx, + getDefaultLlmClient: () => ({ id: "default" }) as unknown as LLMClient, + getBaseModelName: () => "openai/gpt-4.1-mini" as AvailableModel, + getSystemPrompt: () => undefined, + domSettleTimeoutMs: undefined, + act: vi.fn(), + }); + + const context: AgentCacheContext = { + instruction: "navigate with vars", + startUrl: "https://example.com/source", + options: {}, + configSignature: "sig", + cacheKey: "agent-goto-vars", + variableKeys: ["path", "token"], + variables: { path: "target", token: "abc123" }, + }; + + const result = await cache.tryReplay(context); + + expect(result?.success).toBe(true); + expect(handler.takeDeterministicAction).not.toHaveBeenCalled(); + expect(fakePage.goto).toHaveBeenCalledWith( + "https://example.com/target?token=abc123", + { + waitUntil: "load", + }, + ); + }); }); diff --git a/packages/core/tests/unit/goto-tool-variables.test.ts b/packages/core/tests/unit/goto-tool-variables.test.ts new file mode 100644 index 000000000..7661a3acf --- /dev/null +++ b/packages/core/tests/unit/goto-tool-variables.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Variables } from "../../lib/v3/types/public/agent.js"; +import type { Page } from "../../lib/v3/understudy/page.js"; +import type { V3 } from "../../lib/v3/v3.js"; +import { gotoTool } from "../../lib/v3/agent/tools/goto.js"; + +describe("gotoTool variables", () => { + it("substitutes variables for navigation and preserves template URL in replay", async () => { + const goto = vi.fn().mockResolvedValue(undefined); + const page = { goto } as unknown as Page; + const recordAgentReplayStep = vi.fn(); + + const v3 = { + logger: vi.fn(), + recordAgentReplayStep, + context: { + awaitActivePage: vi.fn().mockResolvedValue(page), + }, + } as unknown as V3; + + const variables: Variables = { + host: "example.com", + path: "login", + }; + + const tool = gotoTool(v3, variables); + const result = await tool.execute({ + url: "https://%host%/%path%", + }); + + expect(goto).toHaveBeenCalledWith("https://example.com/login", { + waitUntil: "load", + }); + expect(recordAgentReplayStep).toHaveBeenCalledWith({ + type: "goto", + url: "https://%host%/%path%", + waitUntil: "load", + }); + expect(result).toEqual({ + success: true, + url: "https://%host%/%path%", + }); + }); +}); diff --git a/packages/server-v4/openapi.v4.yaml b/packages/server-v4/openapi.v4.yaml index 2ecfaa6c0..f1ec1cc70 100644 --- a/packages/server-v4/openapi.v4.yaml +++ b/packages/server-v4/openapi.v4.yaml @@ -2715,6 +2715,12 @@ components: enum: - jsevent - xy + expectDownload: + type: boolean + expectNavigation: + type: boolean + expectPopup: + type: boolean required: - selector additionalProperties: false @@ -6585,6 +6591,12 @@ components: enum: - jsevent - xy + expectDownload: + type: boolean + expectNavigation: + type: boolean + expectPopup: + type: boolean required: - selector - method diff --git a/packages/server-v4/src/schemas/v4/page.ts b/packages/server-v4/src/schemas/v4/page.ts index f281c0c98..57b6231b4 100644 --- a/packages/server-v4/src/schemas/v4/page.ts +++ b/packages/server-v4/src/schemas/v4/page.ts @@ -277,7 +277,9 @@ export const PageClickParamsSchema = PageWithPageIdSchema.extend({ clickCount: z.number().int().lte(3).gte(1).optional(), returnSelector: z.boolean().default(false).optional(), method: z.enum(["jsevent", "xy"]).default("xy"), - // TODO: add expectDownload, expectNavigation, expectPopup OR expect: z.enum(...) + expectDownload: z.boolean().optional(), + expectNavigation: z.boolean().optional(), + expectPopup: z.boolean().optional(), }) .strict() .meta({ id: "PageClickParams" }); diff --git a/packages/server-v4/test/integration/v4/page.test.ts b/packages/server-v4/test/integration/v4/page.test.ts index d61b841d0..d6b70c083 100644 --- a/packages/server-v4/test/integration/v4/page.test.ts +++ b/packages/server-v4/test/integration/v4/page.test.ts @@ -932,6 +932,22 @@ describe("v4 page routes", { concurrency: false }, () => { assertSuccessAction(jseventCtx, "click"); }); + it("POST /v4/page/click accepts expect* options", async () => { + const gotoCtx = await postPageRoute("goto", sessionId, { + url: CLICK_TEST_URL, + waitUntil: "load", + }); + assertSuccessAction(gotoCtx, "goto"); + + const clickCtx = await postPageRoute("click", sessionId, { + selector: { css: "#click-target" }, + expectNavigation: true, + expectPopup: false, + expectDownload: false, + }); + assertSuccessAction(clickCtx, "click"); + }); + it("POST /v4/page/dragAndDrop accepts mixed selector types (xpath from, coordinates to)", async () => { const gotoCtx = await postPageRoute("goto", sessionId, { url: METHODS_TEST_URL,