diff --git a/SKILL.md b/SKILL.md index ec2b78e..1f69317 100644 --- a/SKILL.md +++ b/SKILL.md @@ -9,7 +9,7 @@ metadata: {"openclaw": {"requires": {"bins": ["opera-browser-cli"]}}} `opera-browser-cli` controls an Opera browser session. - **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. +- **`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. - **`mcp-servers`** — list MCP servers registered in the browser (requires Opera Neon). diff --git a/src/cli.ts b/src/cli.ts index 7fd7248..ab1c55b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,11 +83,15 @@ const VERSION = getPackageVersion(); const RAW_STDOUT_MARKER = "__OPERA_BROWSER_CLI_RAW__"; +// NOTE: These error keys are part of the CDP contract between the CLI and the +// browser extension. Any change here MUST be mirrored in opera-chat: +// src/sagas/chat/handleCdpActionRequested.ts → CdpResultErrorKey const CdpResultErrorKey = { NOT_SIGNED_IN: "[OPERA_CDP_ERR:NOT_SIGNED_IN]", SUBSCRIPTION_REQUIRED: "[OPERA_CDP_ERR:SUBSCRIPTION_REQUIRED]", CONSENT_REQUIRED: "[OPERA_CDP_ERR:CONSENT_REQUIRED]", NEON_ONLY: "[OPERA_CDP_ERR:NEON_ONLY]", + CONVERSATION_NOT_FOUND: "[OPERA_CDP_ERR:CONVERSATION_NOT_FOUND]", } as const; type CliStdout = Pick; @@ -108,7 +112,7 @@ commands[55]: network-get [id], lighthouse, perf-start, perf-stop, perf-insight , heap , start, stop, restart, status, attach, launch-args, login, - chat [--model ] , invoke-do , make , + chat [--model ] [--conversation-id ] , invoke-do , make , research , models, mcp-servers, mcp-tools --server , mcp-call --server --tool , mcp-add , mcp-auth , mcp-remove , @@ -651,19 +655,20 @@ args: examples: opera-browser-cli heap ./snapshot.heapsnapshot`, - // Opera AI - chat: `usage: opera-browser-cli chat [--model ] + chat: `usage: opera-browser-cli chat [--model ] [--conversation-id ] Send a chat message to the Opera AI. args: Message to send (required) options: - --model AI model to use (run "opera-browser-cli models" to list) + --model AI model to use (run "opera-browser-cli models" to list) + --conversation-id, -c Continue an existing conversation (omit to start a new one) examples: opera-browser-cli chat "Hello, who are you?" - opera-browser-cli chat --model claude-sonnet-4 "Summarize this page"`, + opera-browser-cli chat --model claude-sonnet-4 "Summarize this page" + opera-browser-cli chat --conversation-id conversation-1725000000000 "What else can you tell me?"`, "invoke-do": `usage: opera-browser-cli invoke-do Ask the Opera AI to perform a complex browsing task. @@ -1133,6 +1138,7 @@ export const EXIT_CODES: Record = { PAGE_CLOSED: 6, EXTENSION_NOT_FOUND: 3, NOT_FOUND: 2, + CONVERSATION_NOT_FOUND: 2, SERVER_DISCONNECTED: 3, UNKNOWN: 1, }; @@ -3053,6 +3059,15 @@ const CDP_RESULT_ERRORS: readonly CdpResultErrorDescriptor[] = [ code: "UNSUPPORTED_OPERATION", suggestions: () => NEON_ONLY_HELP, }, + { + match: (r) => r.includes(CdpResultErrorKey.CONVERSATION_NOT_FOUND), + message: "Opera: the specified conversation was not found or has expired", + code: "CONVERSATION_NOT_FOUND", + suggestions: (cmd) => [ + `Run \`opera-browser-cli ${cmd}\` without --conversation-id to start a new conversation`, + "Use `opera-browser-cli models` to see available models", + ], + }, ]; function checkAiResultForCdpError(command: string, result: string): void { @@ -3099,7 +3114,7 @@ async function callAiTool( } async function handleChat(args: string[]): Promise { - const { prompt, model } = parseChatArgs(args); + 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', @@ -3110,9 +3125,41 @@ async function handleChat(args: string[]): Promise { 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 formatMcpResult("result", result, [], true); + 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. + return ( + encode({ "conversation-id": null }) + + "\n" + + formatMcpResult("result", result, [], true) + ); + } + if ( + typeof parsed !== "object" || + parsed === null || + 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"], + ); + } + return ( + encode({ "conversation-id": parsed.conversationId as string }) + + "\n" + + formatMcpResult("result", parsed.text as string, [], true) + ); } async function handleInvokeDo(args: string[]): Promise { @@ -3147,19 +3194,33 @@ type ResearchType = (typeof VALID_RESEARCH_TYPES)[number]; export function parseChatArgs(args: string[]): { prompt: string; model?: string; + conversationId?: string; } { let model: string | undefined; + let conversationId: string | undefined; const promptParts: string[] = []; for (let i = 0; i < args.length; i++) { - if (args[i] === "--model") { + const arg = args[i]; + if (/^--conversation-id=/.test(arg) || /^-c=/.test(arg)) { + throw new CdpError( + `Invalid syntax: ${arg}. Use --conversation-id (space-separated, not =).`, + "VALIDATION_ERROR", + ['Run `opera-browser-cli chat --conversation-id "prompt"`'], + ); + } + if (arg === "--model") { if (i + 1 < args.length) { model = args[++i]; } + } else if (arg === "--conversation-id" || arg === "-c") { + if (i + 1 < args.length) { + conversationId = args[++i]; + } } else { - promptParts.push(args[i]); + promptParts.push(arg); } } - return { prompt: promptParts.join(" "), model }; + return { prompt: promptParts.join(" "), model, conversationId }; } export function parseResearchArgs(args: string[]): { diff --git a/src/client.ts b/src/client.ts index d137e4d..6a0c914 100644 --- a/src/client.ts +++ b/src/client.ts @@ -123,6 +123,7 @@ export type ErrorCode = | "UNSUPPORTED_OPERATION" | "EXTENSION_NOT_FOUND" | "NOT_FOUND" + | "CONVERSATION_NOT_FOUND" | "SERVER_DISCONNECTED" | "UNKNOWN"; diff --git a/test/cli.test.ts b/test/cli.test.ts index 2f9145e..f2ada58 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -164,26 +164,82 @@ describe("formatScreenshotOutput", () => { describe("parseChatArgs", () => { it("parses prompt only", () => { const result = parseChatArgs(["Hello", "world"]); - expect(result).toEqual({ prompt: "Hello world", model: undefined }); + 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?"]); - expect(result).toEqual({ prompt: "What is this?", model: "gpt-4o" }); + 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"]); - expect(result).toEqual({ prompt: "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"]); - expect(result).toEqual({ prompt: "", model: "gpt-4o" }); + expect(result).toEqual({ prompt: "", model: "gpt-4o", conversationId: undefined }); }); it("ignores --model without a value", () => { const result = parseChatArgs(["Hello", "--model"]); - expect(result).toEqual({ prompt: "Hello", model: undefined }); + expect(result).toEqual({ prompt: "Hello", model: undefined, conversationId: undefined }); + }); + + it("parses --conversation-id flag", () => { + const result = parseChatArgs(["--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"]); + 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"]); + expect(result).toEqual({ prompt: "Hello", model: "gpt-4o", conversationId: "conv-1" }); + }); +}); + +describe("handleChat JSON response parsing", () => { + const isValidShape = (parsed: unknown): parsed is { conversationId: string; text: string } => + typeof parsed === "object" && + parsed !== null && + typeof (parsed as Record).conversationId === "string" && + typeof (parsed as Record).text === "string"; + + it("accepts valid JSON response", () => { + const parsed = JSON.parse('{"conversationId":"conv-1","text":"Hello"}'); + expect(isValidShape(parsed)).toBe(true); + if (isValidShape(parsed)) { + expect(parsed.conversationId).toBe("conv-1"); + expect(parsed.text).toBe("Hello"); + } + }); + + it("rejects a number", () => { + expect(isValidShape(JSON.parse("42"))).toBe(false); + }); + + it("rejects null", () => { + expect(isValidShape(JSON.parse("null"))).toBe(false); + }); + + it("rejects a plain string", () => { + expect(isValidShape(JSON.parse('"hello"'))).toBe(false); + }); + + it("rejects an empty object", () => { + expect(isValidShape(JSON.parse("{}"))).toBe(false); + }); + + it("rejects an object with missing text field", () => { + expect(isValidShape(JSON.parse('{"conversationId":"x"}'))).toBe(false); + }); + + it("rejects an object with wrong types", () => { + expect(isValidShape(JSON.parse('{"conversationId":42,"text":true}'))).toBe(false); }); }); diff --git a/test/exit-codes.test.ts b/test/exit-codes.test.ts index 35d39b5..b8ff6eb 100644 --- a/test/exit-codes.test.ts +++ b/test/exit-codes.test.ts @@ -31,6 +31,7 @@ describe("exit codes", () => { PAGE_CLOSED: 6, EXTENSION_NOT_FOUND: 3, NOT_FOUND: 2, + CONVERSATION_NOT_FOUND: 2, SERVER_DISCONNECTED: 3, UNKNOWN: 1, };