Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **CUA mode** (`configure({ ai: { mode: "cua" } })`): execute `runSteps` and `runUserFlow` through OpenAI's Responses API with the built-in `computer` tool. Screenshot-driven, coordinate-based actions via Playwright's `page.mouse` / `page.keyboard`. Requires `OPENAI_API_KEY` and `gateway: "none"`; Redis step caching is skipped in this mode because coordinate actions aren't portable across viewport sizes.
- `cua` model slot in `ModelConfig` (default: `gpt-5.5`). For now, you cannot override the CUA model.
- `getMode()` helper and `AIMode` type exported from `src/config.ts`.
- **File upload caching**: `browser_upload_file` now writes a step cache entry, so repeat runs replay the cached upload-button locator instead of re-resolving it through the model. The flow is unchanged — the file chooser is still opened by clicking the button and the files are still set with `fileChooser.setFiles`.

## [1.0.0] - 2026-03-27

Expand Down
34 changes: 33 additions & 1 deletion src/__tests__/upload-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveUploadPath } from "../tools";
import { resolveCachedUploadPaths, resolveUploadPath } from "../tools";

describe("resolveUploadPath", () => {
it("returns absolute Unix paths as-is", () => {
Expand Down Expand Up @@ -33,3 +33,35 @@ describe("resolveUploadPath", () => {
expect(resolveUploadPath("uploads/test/document.pdf", "./uploads")).toBe("./uploads/uploads/test/document.pdf");
});
});

describe("cached upload path resolution", () => {
it("resolves a raw filename coming from step.data", () => {
expect(resolveCachedUploadPaths("report.pdf", "", "./uploads")).toEqual([
"./uploads/report.pdf",
]);
});

it("does not re-prefix the cached value when step.data has no value", () => {
expect(resolveCachedUploadPaths(undefined, "./uploads/a.pdf,/tmp/b.png", "./uploads")).toEqual([
"./uploads/a.pdf",
"/tmp/b.png",
]);
});

it("prefers step.data over the cached value", () => {
expect(resolveCachedUploadPaths("fresh.pdf", "./uploads/stale.pdf", "./uploads")).toEqual([
"./uploads/fresh.pdf",
]);
});

it("splits multiple raw filenames and skips empty entries", () => {
expect(resolveCachedUploadPaths("a.pdf, b.png,", "", "./uploads")).toEqual([
"./uploads/a.pdf",
"./uploads/b.png",
]);
});

it("returns an empty list when neither source has a value", () => {
expect(resolveCachedUploadPaths(undefined, undefined, "./uploads")).toEqual([]);
});
});
20 changes: 18 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async function maybeWithSpan<T>(
import { z } from "zod";
import { buildRunStepsPrompt, buildRunUserFlowPrompt } from "./prompts";
import { getRedis } from "./redis";
import { getAItools } from "./tools";
import { getAItools, resolveCachedUploadPaths } from "./tools";
import { RunStepsOptions, UserFlowOptions } from "./types";
import {
runLocatorCode,
Expand All @@ -40,7 +40,7 @@ import {
replacePlaceholders,
resolveEmailPlaceholders,
} from "./data-cache";
import { resolveAI } from "./config";
import { getConfig, resolveAI } from "./config";
import { runCUALoop, buildRunStepsPromptCUA, buildRunUserFlowPromptCUA } from "./cua";
import { applyExtraction } from "./extract";
import { logger } from "./logger";
Expand Down Expand Up @@ -385,6 +385,22 @@ export const runSteps = async ({
case "select-option":
code = `await page.${locator}.describe('${description}').selectOption("${input}", { timeout: ${CACHED_ACTION_TIMEOUT} })`;
break;
case "uploadFile": {
// `input` is deliberately not used here: see resolveCachedUploadPaths.
const uploadPaths = resolveCachedUploadPaths(
step.data?.value,
value,
getConfig().uploadBasePath || "./uploads",
);

code = `
const fileChooserPromise = page.waitForEvent('filechooser');
await page.${locator}.describe('${description}').click({ timeout: ${CACHED_ACTION_TIMEOUT} });
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(${JSON.stringify(uploadPaths)});
`;
break;
}
case "waitForText":
code = `await page.getByText("${value}", { exact: true }).first().waitFor({ state: "visible" })`;
break;
Expand Down
38 changes: 36 additions & 2 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ export function resolveUploadPath(filePath: string, uploadBasePath: string): str
: `${uploadBasePath}/${filePath}`;
}

/**
* Resolves the file paths a cached "uploadFile" step should attach. The two sources hold
* different things: `rawValue` (from the step's data) is an unresolved filename, while
* `cachedValue` was already resolved against the base path when the step was recorded and
* must be used as-is — resolving it again would re-prefix it whenever `uploadBasePath` is
* relative, which is the default.
*/
export function resolveCachedUploadPaths(
rawValue: string | undefined,
cachedValue: string | undefined,
uploadBasePath: string,
): string[] {
return (rawValue ?? cachedValue ?? "")
.split(",")
.map((filePath) => filePath.trim())
.filter(Boolean)
.map((filePath) =>
rawValue === undefined ? filePath : resolveUploadPath(filePath, uploadBasePath),
);
}

type ToolSettings = {
abortController?: AbortController;
currentStep?: { description: string; data?: Record<string, string> };
Expand Down Expand Up @@ -588,15 +609,28 @@ class PlaywrightTools {
const uploadBasePath = getConfig().uploadBasePath || "./uploads";
const prefixedFilePaths = filePaths.map((filePath) => resolveUploadPath(filePath, uploadBasePath));

// File uploads are not cached for now as it needs a two step process
// We can solve this later by introducing multi-action caching if needed
let cachedLocator = "";

if (this.currentStep) {
cachedLocator = await this.resolveLocator(locator);
}

const fileChooserPromise = this.page.waitForEvent("filechooser");
await locator.click({ timeout: LOCATOR_ACTION_TIMEOUT });
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(prefixedFilePaths, {
timeout: LOCATOR_ACTION_TIMEOUT,
});

// Cached as a single "uploadFile" action: the locator stored is the button that opens
// the file chooser, and the replay in index.ts performs both steps against it.
this.prepareCacheData(
cachedLocator,
"uploadFile",
elementDescription,
prefixedFilePaths.join(","),
);

return {
success: true,
prefixedFilePaths,
Expand Down