From 32a86969f8e5011597ce438de7e892913b56d433 Mon Sep 17 00:00:00 2001 From: Mateusz Kupczyk Date: Mon, 7 Sep 2026 08:04:55 +0200 Subject: [PATCH 1/2] feat: make now accepts --conversation-id to continue existing conversations. Refactored argument parsing into a shared helper and cleaned up research output to avoid duplication. --- SKILL.md | 3 +- src/cli.ts | 89 ++++++++++++++++++++++++++++++++---------------- test/cli.test.ts | 20 +++++------ 3 files changed, 71 insertions(+), 41 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1f69317..aa3cce3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -11,7 +11,8 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}} - **Standard commands** (`open`, `click`, `fill`, `screenshot`, etc.) — work with any Opera browser session. - **`chat`** — available on any Opera browser. Use `--model ` to select an AI model, `--conversation-id ` to continue a conversation. - **`models`** — list available AI models for chat (shows IDs and which is the default). -- **`invoke-do`, `make`, `research`** — require **Opera Neon** with an active sign-in. +- **`invoke-do`, `make`, `research`** — require **Opera Neon** with an active sign-in. `make` accepts `--conversation-id ` to continue an existing conversation. +- **Research conversation IDs**: Each research session creates a fresh, unique conversation. The conversation ID is **not resumable** for research (no `--conversation-id` flag). However, the conversation ID from a research can be used with `chat` or `make` for follow-up questions/actions in the same research context. - **`mcp-servers`** — list MCP servers registered in the browser (requires Opera Neon). - **`mcp-tools --server `** — list tools exposed by a specific MCP server (requires Opera Neon). - **`mcp-call --server --tool [--params '{...}']`** — execute a tool on an MCP server (requires Opera Neon). diff --git a/src/cli.ts b/src/cli.ts index ab1c55b..98ef695 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -112,8 +112,8 @@ commands[55]: network-get [id], lighthouse, perf-start, perf-stop, perf-insight , heap , start, stop, restart, status, attach, launch-args, login, - chat [--model ] [--conversation-id ] , invoke-do , make , - research , models, + chat [--model ] [--conversation-id ] , invoke-do , + make [--conversation-id ] , research , models, mcp-servers, mcp-tools --server , mcp-call --server --tool , mcp-add , mcp-auth , mcp-remove , mcp-enable , mcp-disable , @@ -681,16 +681,20 @@ examples: opera-browser-cli invoke-do "Find the cheapest flight from London to Tokyo next month" opera-browser-cli invoke-do "Log in to my account and check my order history"`, - make: `usage: opera-browser-cli make + make: `usage: opera-browser-cli make [--conversation-id ] Ask the Opera AI to build something, e.g. a webpage or web app. Requires Opera Neon with an active sign-in. Run \`opera-browser-cli setup\` to configure. args: What to build (required) +flags: + --conversation-id, -c Continue an existing make conversation (omit to start a new one) + examples: opera-browser-cli make "A landing page for a coffee shop with a menu and contact form" - opera-browser-cli make "A todo app with local storage and drag-and-drop reordering"`, + opera-browser-cli make "A todo app with local storage and drag-and-drop reordering" + opera-browser-cli make --conversation-id conversation-123 "Change the hero section to full-width"`, research: `usage: opera-browser-cli research [--type ] Ask the Opera AI to research a topic in depth. @@ -3113,30 +3117,20 @@ async function callAiTool( } } -async function handleChat(args: string[]): Promise { - const { prompt, model, conversationId } = parseChatArgs(args); - if (!prompt) { - throw new CdpError("Missing prompt", "VALIDATION_ERROR", [ - 'Run `opera-browser-cli chat "What is on this page?"` to chat with Opera AI', - "Use --model to select a model (run `opera-browser-cli models` to list)", - ]); - } - const toolArgs: Record = { prompt }; - if (model !== undefined) { - toolArgs["model"] = model; - } - if (conversationId !== undefined) { - toolArgs["conversationId"] = conversationId; - } - const result = await callAiTool("chat", "opera_chat", toolArgs); - // CDP errors are raw strings checked first; only success responses are JSON. - checkAiResultForCdpError("chat", result); +/** + * Render an Opera AI response that carries a conversation id. + * + * A current extension returns JSON `{conversationId, text}`; an older extension + * returns plain text (no conversation id yet). CDP errors are raw strings and + * must be checked with {@link checkAiResultForCdpError} before this is called. + */ +function formatConversationResult(result: string): string { let parsed: { conversationId: string; text: string }; try { parsed = JSON.parse(result); } catch { - // Not JSON — the extension may not support the structured response format yet. - // Return the raw text as the chat response, without a conversation ID. + // Not JSON — the extension does not support the structured response yet. + // Return the raw text as the response, without a conversation ID. return ( encode({ "conversation-id": null }) + "\n" + @@ -3162,6 +3156,27 @@ async function handleChat(args: string[]): Promise { ); } +async function handleChat(args: string[]): Promise { + const { prompt, model, conversationId } = parseChatOrMakeArgs(args); + if (!prompt) { + throw new CdpError("Missing prompt", "VALIDATION_ERROR", [ + 'Run `opera-browser-cli chat "What is on this page?"` to chat with Opera AI', + "Use --model to select a model (run `opera-browser-cli models` to list)", + ]); + } + const toolArgs: Record = { prompt }; + if (model !== undefined) { + toolArgs["model"] = model; + } + if (conversationId !== undefined) { + toolArgs["conversationId"] = conversationId; + } + const result = await callAiTool("chat", "opera_chat", toolArgs); + // CDP errors are raw strings checked first; only success responses are JSON. + checkAiResultForCdpError("chat", result); + return formatConversationResult(result); +} + async function handleInvokeDo(args: string[]): Promise { const prompt = args.join(" "); if (!prompt) { @@ -3176,22 +3191,26 @@ async function handleInvokeDo(args: string[]): Promise { } async function handleMake(args: string[]): Promise { - const prompt = args.join(" "); + const { prompt, conversationId } = parseMakeArgs(args); if (!prompt) { throw new CdpError("Missing prompt", "VALIDATION_ERROR", [ 'Run `opera-browser-cli make "A summary of this page"` to create something', ]); } requireNeon("make"); - const result = await callAiTool("make", "opera_make", { prompt }); + const toolArgs: Record = { prompt }; + if (conversationId !== undefined) { + toolArgs["conversationId"] = conversationId; + } + const result = await callAiTool("make", "opera_make", toolArgs); checkAiResultForCdpError("make", result); - return formatMcpResult("result", result, [], true); + return formatConversationResult(result); } const VALID_RESEARCH_TYPES = ["local", "one-minute", "deep"] as const; type ResearchType = (typeof VALID_RESEARCH_TYPES)[number]; -export function parseChatArgs(args: string[]): { +export function parseChatOrMakeArgs(args: string[]): { prompt: string; model?: string; conversationId?: string; @@ -3205,7 +3224,7 @@ export function parseChatArgs(args: string[]): { throw new CdpError( `Invalid syntax: ${arg}. Use --conversation-id (space-separated, not =).`, "VALIDATION_ERROR", - ['Run `opera-browser-cli chat --conversation-id "prompt"`'], + ['Use --conversation-id (space-separated, not =)'], ); } if (arg === "--model") { @@ -3223,6 +3242,14 @@ export function parseChatArgs(args: string[]): { return { prompt: promptParts.join(" "), model, conversationId }; } +export function parseMakeArgs(args: string[]): { + prompt: string; + conversationId?: string; +} { + const { prompt, conversationId } = parseChatOrMakeArgs(args); + return { prompt, conversationId }; +} + export function parseResearchArgs(args: string[]): { prompt: string; researchType?: ResearchType; @@ -3262,7 +3289,9 @@ async function handleResearch(args: string[]): Promise { if (researchType !== undefined) toolArgs.researchType = researchType; const result = await callAiTool("research", "opera_research", toolArgs); checkAiResultForCdpError("research", result); - return formatMcpResult("result", result, [], true); + // The content is streamed via CHUNK (sendLog -> stderr); the FINAL is empty on purpose to + // avoid duplicated output + return ""; } async function handleModels(): Promise { diff --git a/test/cli.test.ts b/test/cli.test.ts index f2ada58..06378c8 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -4,7 +4,7 @@ import { formatStopOutput, formatScreenshotOutput, getCommandHelp, - parseChatArgs, + parseChatOrMakeArgs, parseScreenshotArgs, parseSetupArgs, } from "../src/cli.js"; @@ -161,44 +161,44 @@ describe("formatScreenshotOutput", () => { }); }); -describe("parseChatArgs", () => { +describe("parseChatOrMakeArgs", () => { it("parses prompt only", () => { - const result = parseChatArgs(["Hello", "world"]); + const result = parseChatOrMakeArgs(["Hello", "world"]); expect(result).toEqual({ prompt: "Hello world", model: undefined, conversationId: undefined }); }); it("parses --model flag with prompt", () => { - const result = parseChatArgs(["--model", "gpt-4o", "What", "is", "this?"]); + const result = parseChatOrMakeArgs(["--model", "gpt-4o", "What", "is", "this?"]); expect(result).toEqual({ prompt: "What is this?", model: "gpt-4o", conversationId: undefined }); }); it("parses --model at end of args", () => { - const result = parseChatArgs(["Hello", "--model", "claude-sonnet-4"]); + const result = parseChatOrMakeArgs(["Hello", "--model", "claude-sonnet-4"]); expect(result).toEqual({ prompt: "Hello", model: "claude-sonnet-4", conversationId: undefined }); }); it("returns empty prompt when only --model is given", () => { - const result = parseChatArgs(["--model", "gpt-4o"]); + const result = parseChatOrMakeArgs(["--model", "gpt-4o"]); expect(result).toEqual({ prompt: "", model: "gpt-4o", conversationId: undefined }); }); it("ignores --model without a value", () => { - const result = parseChatArgs(["Hello", "--model"]); + const result = parseChatOrMakeArgs(["Hello", "--model"]); expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: undefined }); }); it("parses --conversation-id flag", () => { - const result = parseChatArgs(["--conversation-id", "conversation-123", "Hello"]); + const result = parseChatOrMakeArgs(["--conversation-id", "conversation-123", "Hello"]); expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: "conversation-123" }); }); it("parses -c shorthand for conversation-id", () => { - const result = parseChatArgs(["-c", "conversation-456", "Hi"]); + const result = parseChatOrMakeArgs(["-c", "conversation-456", "Hi"]); expect(result).toEqual({ prompt: "Hi", model: undefined, conversationId: "conversation-456" }); }); it("parses both --model and --conversation-id", () => { - const result = parseChatArgs(["--model", "gpt-4o", "--conversation-id", "conv-1", "Hello"]); + const result = parseChatOrMakeArgs(["--model", "gpt-4o", "--conversation-id", "conv-1", "Hello"]); expect(result).toEqual({ prompt: "Hello", model: "gpt-4o", conversationId: "conv-1" }); }); }); From 03fa100bfee9a7904808907adfc7cd84f993440a Mon Sep 17 00:00:00 2001 From: Mateusz Kupczyk Date: Mon, 7 Sep 2026 12:17:13 +0200 Subject: [PATCH 2/2] fixup! feat: make now accepts --conversation-id to continue existing conversations. --- src/cli.ts | 33 ++++++++++++++++++++++----------- test/cli.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 98ef695..fcaa977 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3121,16 +3121,20 @@ async function callAiTool( * Render an Opera AI response that carries a conversation id. * * A current extension returns JSON `{conversationId, text}`; an older extension - * returns plain text (no conversation id yet). CDP errors are raw strings and - * must be checked with {@link checkAiResultForCdpError} before this is called. + * returns plain text, and `make` replies without a conversation are possible. + * CDP errors are raw strings and must be checked with + * {@link checkAiResultForCdpError} before this is called. + * + * Anything that is not the `{conversationId, text}` envelope is rendered raw + * (with a null conversation id) rather than failing, so an unknown response + * shape still prints. */ function formatConversationResult(result: string): string { let parsed: { conversationId: string; text: string }; try { parsed = JSON.parse(result); } catch { - // Not JSON — the extension does not support the structured response yet. - // Return the raw text as the response, without a conversation ID. + // Not JSON — render the raw text without a conversation ID. return ( encode({ "conversation-id": null }) + "\n" + @@ -3143,10 +3147,12 @@ function formatConversationResult(result: string): string { typeof (parsed as Record).conversationId !== "string" || typeof (parsed as Record).text !== "string" ) { - throw new CdpError( - "Unexpected response format from Opera AI — the browser extension may be out of date", - "BROWSER_ERROR", - ["Run `opera-browser-cli setup` to ensure the latest extension is installed"], + // JSON, but not the `{conversationId, text}` envelope. Backward-compatible: + // print the raw response instead of throwing on an unknown shape. + return ( + encode({ "conversation-id": null }) + + "\n" + + formatMcpResult("result", result, [], true) ); } return ( @@ -3246,6 +3252,13 @@ export function parseMakeArgs(args: string[]): { prompt: string; conversationId?: string; } { + if (args.includes("--model")) { + throw new CdpError( + "make does not accept --model; select a model with `chat --model ` instead", + "VALIDATION_ERROR", + ["Run `opera-browser-cli models` to list the models available for chat"], + ); + } const { prompt, conversationId } = parseChatOrMakeArgs(args); return { prompt, conversationId }; } @@ -3289,9 +3302,7 @@ async function handleResearch(args: string[]): Promise { if (researchType !== undefined) toolArgs.researchType = researchType; const result = await callAiTool("research", "opera_research", toolArgs); checkAiResultForCdpError("research", result); - // The content is streamed via CHUNK (sendLog -> stderr); the FINAL is empty on purpose to - // avoid duplicated output - return ""; + return formatMcpResult("result", result, [], true); } async function handleModels(): Promise { diff --git a/test/cli.test.ts b/test/cli.test.ts index 06378c8..9b67e6a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -5,6 +5,7 @@ import { formatScreenshotOutput, getCommandHelp, parseChatOrMakeArgs, + parseMakeArgs, parseScreenshotArgs, parseSetupArgs, } from "../src/cli.js"; @@ -243,3 +244,25 @@ describe("handleChat JSON response parsing", () => { expect(isValidShape(JSON.parse('{"conversationId":42,"text":true}'))).toBe(false); }); }); + +describe("parseMakeArgs", () => { + it("parses a bare prompt", () => { + expect(parseMakeArgs(["Build a todo app"])).toEqual({ + prompt: "Build a todo app", + conversationId: undefined, + }); + }); + + it("parses --conversation-id", () => { + expect(parseMakeArgs(["--conversation-id", "conv-1", "Change the hero"])).toEqual({ + prompt: "Change the hero", + conversationId: "conv-1", + }); + }); + + it("rejects --model instead of silently dropping it", () => { + expect(() => parseMakeArgs(["--model", "gpt-4o", "Build a todo app"])).toThrow( + "make does not accept --model", + ); + }); +});