Skip to content
Draft
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
24 changes: 24 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ class CuaRuntimeController {
this.translator = this.createTranslator();
}

setPlaywright(enabled: boolean): void {
this.options.playwright = enabled;
}

tools(): AgentTool[] {
return [
...buildCuaComputerTools(
Expand Down Expand Up @@ -312,6 +316,16 @@ export class CuaAgent extends Agent {
state.systemPrompt = this.runtime.systemPrompt;
}
}

/**
* Toggle the `playwright_execute` tool mid-session. Refreshes
* `state.tools` so the next turn sees the updated set.
*/
setPlaywright(enabled: boolean): void {
this.runtime.setPlaywright(enabled);
this.runtimeDirty = true;
super.state.tools = this.runtime.tools();
}
}

/**
Expand Down Expand Up @@ -390,6 +404,16 @@ export class CuaAgentHarness<
await super.setActiveTools(toolNames);
this.requestedActiveToolNames = [...toolNames];
}

/**
* Toggle the `playwright_execute` tool mid-session. Re-resolves the CUA
* tool set and pushes it through `setTools` so the next turn sees it.
*/
async setPlaywright(enabled: boolean): Promise<void> {
this.runtime.setPlaywright(enabled);
const tools = this.runtime.tools();
await super.setTools(tools, this.requestedActiveToolNames ?? tools.map((tool) => tool.name));
}
}

function composeOnPayload(first: AgentOptions["onPayload"], second: AgentOptions["onPayload"]): AgentOptions["onPayload"] {
Expand Down
39 changes: 39 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ describe("CuaAgent", () => {
]);
});

it("toggles playwright_execute on and off through setPlaywright", () => {
const runtime = resolveCuaRuntimeSpec("openai:gpt-5.5");
const baseNames = runtime.toolExecutors.map((tool) => tool.definition.name);
const agent = new CuaAgent({
browser,
client,
initialState: {
model: "openai:gpt-5.5",
},
});

expect(agent.state.tools.map((tool) => tool.name)).toEqual(baseNames);

agent.setPlaywright(true);
expect(agent.state.tools.map((tool) => tool.name)).toEqual([...baseNames, "playwright_execute"]);

agent.setPlaywright(false);
expect(agent.state.tools.map((tool) => tool.name)).toEqual(baseNames);
});

it("refreshes CUA runtime state when state.model changes", () => {
const runtime = resolveCuaRuntimeSpec("google:gemini-3-flash-preview");
const agent = new CuaAgent({
Expand Down Expand Up @@ -376,6 +396,25 @@ describe("CuaAgentHarness", () => {
expect(harness.getTools().length).toBeGreaterThan(0);
});

it("toggles playwright_execute on and off through harness.setPlaywright", async () => {
const runtime = resolveCuaRuntimeSpec("openai:gpt-5.5");
const baseNames = runtime.toolExecutors.map((tool) => tool.definition.name);
const harness = new CuaAgentHarness({
...(await createHarnessServices()),
browser,
client,
model: "openai:gpt-5.5",
});

expect(harness.getTools().map((tool) => tool.name)).toEqual(baseNames);

await harness.setPlaywright(true);
expect(harness.getTools().map((tool) => tool.name)).toEqual([...baseNames, "playwright_execute"]);

await harness.setPlaywright(false);
expect(harness.getTools().map((tool) => tool.name)).toEqual(baseNames);
});

it("refreshes CUA runtime state through setModel", async () => {
const runtime = resolveCuaRuntimeSpec("google:gemini-3-flash-preview");
const harness = new CuaAgentHarness({
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ model run Playwright/TypeScript directly against the live browser session
for steps that are awkward as raw pointer/keyboard actions (precise DOM
reads, form fills, data extraction, waiting on selectors). `page`,
`context`, and `browser` are in scope; the code may `return` a
JSON-serializable value. Off by default. Verified e2e with Anthropic,
JSON-serializable value. Off by default. Toggle mid-session with
`/playwright on` or `/playwright off`. Verified e2e with Anthropic,
Tzafon, and Yutori CUA models.

## Output formats
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/tui/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ export async function runInteractive(opts: InteractiveOptions): Promise<number>
await applyCompactCommand(opts, messages);
return;
}
if (parsed?.command === "playwright") {
await applyPlaywrightCommand(opts, messages, parsed.argument);
return;
}
if (parsed?.command === "skill") {
const skill = (opts.skills ?? []).find((s) => s.name === parsed.name);
if (!skill) {
Expand Down Expand Up @@ -511,6 +515,24 @@ function isThinkingLevel(value: string): value is ThinkingLevel {
return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(value);
}

async function applyPlaywrightCommand(
opts: InteractiveOptions,
messages: MessageList,
argument: string,
): Promise<void> {
const value = argument.trim().toLowerCase();
if (value !== "on" && value !== "off") {
messages.addError("usage: /playwright <on|off>");
return;
}
try {
await opts.harness.setPlaywright(value === "on");
messages.addNotice(`playwright → ${value}`);
} catch (err) {
messages.addError((err as Error).message);
}
}

async function applyCompactCommand(opts: InteractiveOptions, messages: MessageList): Promise<void> {
messages.addNotice("compacting…");
try {
Expand Down
23 changes: 21 additions & 2 deletions packages/cli/src/tui/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export function buildAutocompleteProvider(
description: "Summarize older turns to free context budget",
});

commands.push({
name: "playwright",
description: "Toggle the playwright_execute tool",
argumentHint: "<on|off>",
getArgumentCompletions: (prefix: string) => playwrightCompletions(prefix),
});

for (const skill of skills) {
commands.push({
name: `skill:${skill.name}`,
Expand Down Expand Up @@ -73,10 +80,22 @@ function thinkingCompletions(prefix: string): AutocompleteItem[] {
return filtered.map((t) => ({ value: t.value, label: t.value, description: t.description }));
}

const PLAYWRIGHT_TOGGLES: ReadonlyArray<{ value: string; description: string }> = [
{ value: "on", description: "Enable the playwright_execute tool" },
{ value: "off", description: "Disable the playwright_execute tool" },
];

function playwrightCompletions(prefix: string): AutocompleteItem[] {
const trimmed = prefix.trim().toLowerCase();
const filtered = trimmed ? PLAYWRIGHT_TOGGLES.filter((t) => t.value.startsWith(trimmed)) : PLAYWRIGHT_TOGGLES;
return filtered.map((t) => ({ value: t.value, label: t.value, description: t.description }));
}

export type ParsedSlashCommand =
| { command: "model"; argument: string }
| { command: "thinking"; argument: string }
| { command: "compact"; argument: string }
| { command: "playwright"; argument: string }
| { command: "skill"; name: string; remainder: string };

/**
Expand All @@ -91,11 +110,11 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | undefined
const [, name, rest] = skillMatch;
return { command: "skill", name: name ?? "", remainder: (rest ?? "").trim() };
}
const builtinMatch = trimmed.match(/^\/(model|thinking|compact)\s*(.*)$/);
const builtinMatch = trimmed.match(/^\/(model|thinking|compact|playwright)\s*(.*)$/);
if (builtinMatch) {
const [, name, rest] = builtinMatch;
return {
command: name as "model" | "thinking" | "compact",
command: name as "model" | "thinking" | "compact" | "playwright",
argument: (rest ?? "").trim(),
};
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/test/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ describe("parseSlashCommand", () => {
expect(parseSlashCommand("/compact")).toEqual({ command: "compact", argument: "" });
});

it("parses /playwright with on|off arguments", () => {
expect(parseSlashCommand("/playwright on")).toEqual({ command: "playwright", argument: "on" });
expect(parseSlashCommand("/playwright off")).toEqual({ command: "playwright", argument: "off" });
expect(parseSlashCommand("/playwright")).toEqual({ command: "playwright", argument: "" });
});

it("parses /skill:<name> with optional remainder", () => {
expect(parseSlashCommand("/skill:hello")).toEqual({
command: "skill",
Expand Down
Loading