diff --git a/docs/issues/yobrowser-cdp-graceful-degradation/plan.md b/docs/issues/yobrowser-cdp-graceful-degradation/plan.md new file mode 100644 index 000000000..c7afca525 --- /dev/null +++ b/docs/issues/yobrowser-cdp-graceful-degradation/plan.md @@ -0,0 +1,95 @@ +# Plan + +## Source Review + +- `YoBrowserPresenter.updateSessionBrowserBounds()` marks a session invisible + when the renderer reports `visible=false` or zero-size bounds. +- `YoBrowserPresenter.getBrowserStatus()` already returns enough state for an + agent-facing recovery hint: initialized, page, navigation flags, visible, and + loading. +- `YoBrowserToolHandler.callTool()` currently checks `getBrowserPage()` before + `cdp_send` and throws a generic initialization error when no page is available. +- `AgentToolManager` currently wraps YoBrowser handler success as `{ content }`; + thrown errors are caught later in the agent runtime and become errored tool + results with text like `Error: ...`. +- `ToolPresenter` can preserve agent tool failures through `rawData.isError` and + `createAgentToolErrorResult`, which is a better fit for recoverable, + structured YoBrowser failures than an untyped exception string. + +## Design + +- Add a small YoBrowser recoverable error contract for browser availability + failures. The contract should include: + - `code: "yobrowser_unavailable"` + - `recoverable: true` + - `sessionId` + - attempted `method` + - sanitized `browserStatus` from `getBrowserStatus(sessionId)` when available + - concise `suggestedNextActions` +- Detect unavailable-browser states before CDP execution in + `YoBrowserToolHandler.callTool("cdp_send", ...)`: + - missing conversation/session id remains a validation error + - missing or destroyed page maps to the recoverable YoBrowser error + - a known not-ready browser/CDP error that means the browser cannot accept CDP + commands maps to the same recoverable YoBrowser error + - ordinary CDP protocol errors remain ordinary tool errors +- Propagate the recoverable YoBrowser error as an errored agent tool result with + structured content instead of only throwing a generic exception. Prefer the + existing `AgentToolCallResult`/`rawData.isError` path so the runtime marks the + block as an error while preserving the model-readable JSON content. +- Keep the agent-visible payload compact. Do not include stack traces, Electron + internals, full DOM content, screenshots, or local paths. +- Update the YoBrowser tool system prompt only if needed to make the recovery + path explicit. If changed, keep it brief and tool-oriented: + `If cdp_send reports yobrowser_unavailable, inspect get_browser_status and use + load_url to reopen the browser when you have a URL.` + +## Event Flow + +1. User closes or hides the YoBrowser panel while an agent task is running. +2. Renderer bounds update reaches `YoBrowserPresenter.updateSessionBrowserBounds` + with `visible=false` or an unusable size. +3. YoBrowser session state becomes not visible or no longer CDP-ready. +4. The agent later calls `cdp_send`. +5. `YoBrowserToolHandler` detects the unavailable browser state and builds the + recoverable YoBrowser error payload. +6. Agent tool routing returns that payload as an errored tool result. +7. The agent runtime records the tool call as failed but injects the structured + error content into the next model context. +8. The model can call `get_browser_status`, call `load_url` with an available + URL, ask the user to reopen the panel, or continue without browser + verification. + +## Compatibility + +- No storage migration is required. +- No tool name, IPC route, or renderer event contract changes are required for + the first increment. +- Existing successful YoBrowser automation remains source-compatible. +- Existing generic failure logs can stay, but the agent-visible error should no + longer depend on raw exception text for the browser-unavailable case. + +## Test Strategy + +- Update `test/main/presenter/browser/YoBrowserToolHandler.test.ts` to verify + that `cdp_send` on a missing browser returns or raises the recoverable + YoBrowser error contract expected by the chosen propagation path. +- Add or update agent tool manager / tool presenter coverage to verify + recoverable YoBrowser errors become `rawData.isError === true` with structured + model-visible content. +- Add or update agent runtime dispatch coverage to verify the tool block remains + errored and the response text contains the stable `yobrowser_unavailable` + signal. +- Keep existing tests for successful `cdp_send` and `load_url` behavior passing. + +## Risks + +- If the recoverable error is returned as normal content without `isError`, the + UI and runtime may mark the tool as successful. The implementation should use + the existing errored tool-result path. +- If the error payload is too verbose, it may waste context or obscure the + recovery instruction. Keep only state needed for model recovery. +- If all CDP exceptions are treated as browser unavailable, real page/script/CDP + protocol mistakes could become misleading recovery prompts. Limit mapping to + missing page, destroyed page, detached/closed state, and known not-ready + failures. diff --git a/docs/issues/yobrowser-cdp-graceful-degradation/spec.md b/docs/issues/yobrowser-cdp-graceful-degradation/spec.md new file mode 100644 index 000000000..2aefa3f1c --- /dev/null +++ b/docs/issues/yobrowser-cdp-graceful-degradation/spec.md @@ -0,0 +1,114 @@ +# YoBrowser CDP Graceful Degradation + +## Problem + +GitHub issue #1734 reports that a running agent task can lose browser control when +the user closes the right-side YoBrowser panel mid-session. The browser view is +detached or hidden, but the agent still attempts later `cdp_send` calls for DOM +inspection, scripted interaction, or screenshot verification. Today those calls +surface as generic initialization failures or blocked CDP failures, which gives +the model too little context to decide whether it should reopen the browser, +inspect status, skip browser-dependent verification, or ask the user for help. + +## User Story + +As a user running a browser-assisted agent task, I need CDP failures caused by an +unavailable YoBrowser session to be reported as meaningful, recoverable tool +errors so the agent can adapt its next step instead of stalling the task. + +As an agent, when `cdp_send` cannot execute because the session browser is +closed, detached, hidden, destroyed, or otherwise not ready, I need a compact +error payload that explains the browser state and names the safe recovery tools +available in the same context. + +## Acceptance Criteria + +- `cdp_send` failures caused by an unavailable session browser are delivered to + the agent as tool errors, not as silent hangs or terminal application crashes. +- The tool error is meaningful to both the model and logs. It includes a stable + error code, the attempted CDP method, the conversation/session id, the current + YoBrowser status when available, whether the failure is recoverable, and a + short recovery hint. +- The tool error explicitly tells the agent that it may call + `get_browser_status` to inspect state and `load_url` to recreate or reopen the + session browser when it still has a target URL. If there is no target URL, the + hint allows the agent to ask the user to reopen the panel or continue without + browser verification. +- The agent runtime preserves the failure as an errored tool result so follow-up + model context can see that `cdp_send` failed, while still allowing the model to + choose a recovery strategy. +- Existing successful `cdp_send`, `load_url`, and `get_browser_status` behavior + remains unchanged. +- Non-browser-availability CDP errors, malformed arguments, missing + conversation ids, permission denials, and user cancellation keep their existing + error semantics unless they can be safely wrapped with the same recoverable + browser-unavailable code. +- The implementation avoids leaking Electron stack traces, internal object + dumps, filesystem paths, or private page content in the agent-visible error. +- Unit coverage verifies the unavailable-browser case, the still-successful CDP + case, and runtime propagation of the structured recoverable error into the + tool result. + +## Non-goals + +- Do not automatically reattach or reopen the YoBrowser panel in this first + increment. +- Do not add a new renderer-main browser state synchronization channel unless + implementation proves the existing status APIs are insufficient. +- Do not change the public names of `cdp_send`, `load_url`, or + `get_browser_status`. +- Do not retry CDP commands automatically. The model should decide whether to + retry, reopen, skip, or ask the user based on the tool error and conversation + context. +- Do not introduce UI copy or renderer layout changes for this issue. + +## Constraints + +- The fix should follow the existing Presenter and agent tool routing patterns: + YoBrowser-specific readiness detection belongs near + `YoBrowserPresenter`/`YoBrowserToolHandler`, while tool-result propagation + belongs in the agent tool path. +- Tool outputs are part of the model context, so the error payload must be small, + deterministic, and easy to parse even when prefixed by the runtime's standard + error formatting. +- `get_browser_status` already exposes the primary session state + (`initialized`, `visible`, `loading`, and page information), so the first + implementation should prefer reusing that state over adding broader event + synchronization. + +## Proposed Agent-Visible Error Shape + +The exact TypeScript representation can be refined during implementation, but +the agent-visible content should be equivalent to: + +```json +{ + "ok": false, + "error": { + "code": "yobrowser_unavailable", + "message": "YoBrowser is not available for this session, so the CDP command was not run.", + "recoverable": true, + "sessionId": "", + "method": "Page.captureScreenshot", + "browserStatus": { + "initialized": false, + "visible": false, + "loading": false, + "page": null + }, + "suggestedNextActions": [ + "Call get_browser_status to inspect the current browser state.", + "Call load_url with the target URL to recreate or reopen the session browser.", + "If no URL is available, ask the user to reopen the browser panel or continue without browser verification." + ] + } +} +``` + +## Business Value + +This turns a brittle browser-control failure into an agent-readable recovery +signal. The immediate user impact is fewer stalled browser-assisted tasks after +the panel is closed, while the implementation stays smaller and safer than +automatic recovery because it does not mutate browser visibility on behalf of +the model. diff --git a/docs/issues/yobrowser-cdp-graceful-degradation/tasks.md b/docs/issues/yobrowser-cdp-graceful-degradation/tasks.md new file mode 100644 index 000000000..bc4268e98 --- /dev/null +++ b/docs/issues/yobrowser-cdp-graceful-degradation/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +- [x] Review GitHub issue #1734 and confirm the requested graceful-degradation + direction. +- [x] Inspect the current YoBrowser CDP call path and agent tool error + propagation. +- [x] Write SDD spec, plan, and task breakdown before code changes. +- [x] Define the YoBrowser recoverable error contract in the smallest suitable + module. +- [x] Map unavailable-browser `cdp_send` failures to the recoverable + `yobrowser_unavailable` error. +- [x] Propagate the recoverable error as an errored agent tool result with + structured model-visible content. +- [x] Add focused unit tests for YoBrowser handler behavior and agent runtime + propagation. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. diff --git a/src/main/presenter/browser/YoBrowserErrors.ts b/src/main/presenter/browser/YoBrowserErrors.ts new file mode 100644 index 000000000..841812dbd --- /dev/null +++ b/src/main/presenter/browser/YoBrowserErrors.ts @@ -0,0 +1,57 @@ +import type { YoBrowserStatus } from '@shared/types/browser' + +export const YO_BROWSER_UNAVAILABLE_ERROR_CODE = 'yobrowser_unavailable' + +export interface YoBrowserUnavailableErrorPayload { + ok: false + error: { + code: typeof YO_BROWSER_UNAVAILABLE_ERROR_CODE + message: string + recoverable: true + sessionId: string + method: string + browserStatus: YoBrowserStatus | null + suggestedNextActions: string[] + } +} + +export class YoBrowserUnavailableError extends Error { + readonly payload: YoBrowserUnavailableErrorPayload + readonly originalError?: unknown + + constructor(payload: YoBrowserUnavailableErrorPayload, originalError?: unknown) { + super(payload.error.message) + this.name = 'YoBrowserUnavailableError' + this.payload = payload + this.originalError = originalError + } +} + +export const isYoBrowserUnavailableError = (error: unknown): error is YoBrowserUnavailableError => + error instanceof YoBrowserUnavailableError || + (error instanceof Error && + error.name === 'YoBrowserUnavailableError' && + typeof (error as { payload?: unknown }).payload === 'object' && + (error as { payload?: YoBrowserUnavailableErrorPayload }).payload?.error?.code === + YO_BROWSER_UNAVAILABLE_ERROR_CODE) + +export const buildYoBrowserUnavailablePayload = ( + sessionId: string, + method: string, + browserStatus: YoBrowserStatus | null +): YoBrowserUnavailableErrorPayload => ({ + ok: false, + error: { + code: YO_BROWSER_UNAVAILABLE_ERROR_CODE, + message: 'YoBrowser is not available for this session, so the CDP command was not run.', + recoverable: true, + sessionId, + method, + browserStatus, + suggestedNextActions: [ + 'Call get_browser_status to inspect the current browser state.', + 'Call load_url with the target URL to recreate or reopen the session browser.', + 'If no URL is available, ask the user to reopen the browser panel or continue without browser verification.' + ] + } +}) diff --git a/src/main/presenter/browser/YoBrowserToolHandler.ts b/src/main/presenter/browser/YoBrowserToolHandler.ts index 964f6933b..6aeeb1ad3 100644 --- a/src/main/presenter/browser/YoBrowserToolHandler.ts +++ b/src/main/presenter/browser/YoBrowserToolHandler.ts @@ -1,6 +1,12 @@ import logger from '@shared/logger' import { getYoBrowserToolDefinitions } from './YoBrowserToolDefinitions' import type { YoBrowserPresenter } from './YoBrowserPresenter' +import { BrowserPageStatus, type YoBrowserStatus } from '@shared/types/browser' +import { + YoBrowserUnavailableError, + buildYoBrowserUnavailablePayload, + isYoBrowserUnavailableError +} from './YoBrowserErrors' export class YoBrowserToolHandler { private readonly presenter: YoBrowserPresenter @@ -42,9 +48,15 @@ export class YoBrowserToolHandler { throw new Error('CDP method is required') } - const page = await this.presenter.getBrowserPage(sessionId) - if (!page) { - throw new Error(`Session browser for ${sessionId} is not initialized`) + const status = await this.presenter.getBrowserStatus(sessionId) + const page = status.page + if ( + !status.initialized || + !status.visible || + !page || + page.status === BrowserPageStatus.Closed + ) { + throw await this.createUnavailableError(sessionId, method, status) } try { @@ -61,6 +73,7 @@ export class YoBrowserToolHandler { url: page.url, status: page.status }) + throw await this.createUnavailableError(sessionId, method, status, error) } throw error } @@ -69,11 +82,43 @@ export class YoBrowserToolHandler { throw new Error(`Unknown YoBrowser tool: ${toolName}`) } } catch (error) { - logger.error('[YoBrowserToolHandler] Tool execution failed', { toolName, error }) + if (isYoBrowserUnavailableError(error)) { + logger.warn('[YoBrowserToolHandler] Tool execution failed:browser-unavailable', { + toolName, + error: error.payload.error + }) + } else { + logger.error('[YoBrowserToolHandler] Tool execution failed', { toolName, error }) + } throw error } } + private async createUnavailableError( + sessionId: string, + method: string, + knownStatus?: YoBrowserStatus, + originalError?: unknown + ): Promise { + if (knownStatus) { + return new YoBrowserUnavailableError( + buildYoBrowserUnavailablePayload(sessionId, method, knownStatus), + originalError + ) + } + + return this.presenter + .getBrowserStatus(sessionId) + .catch(() => null) + .then( + (status) => + new YoBrowserUnavailableError( + buildYoBrowserUnavailablePayload(sessionId, method, status), + originalError + ) + ) + } + private normalizeCdpParams(value: unknown): Record { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return value as Record diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 7c35dea81..0f3335aa7 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -31,6 +31,8 @@ import { import { AgentImageGenerationTool, IMAGE_GENERATE_TOOL_NAME } from './agentImageGenerationTool' import { AgentPlanTool, UPDATE_PLAN_TOOL_NAME } from './agentPlanTool' import { AgentTapeToolHandler } from './agentTapeTools' +import { createAgentToolErrorResult } from '@shared/lib/agentToolResultEnvelope' +import { isYoBrowserUnavailableError } from '../../browser/YoBrowserErrors' // Consider moving to a shared handlers location in future refactoring import { @@ -530,9 +532,34 @@ export class AgentToolManager { // Route to YoBrowser CDP tools if (AgentToolManager.YO_BROWSER_TOOL_NAME_SET.has(toolName)) { - const response = await this.getYoBrowserToolHandler().callTool(toolName, args, conversationId) - return { - content: response + try { + const response = await this.getYoBrowserToolHandler().callTool( + toolName, + args, + conversationId + ) + return { + content: response + } + } catch (error) { + if (!isYoBrowserUnavailableError(error)) { + throw error + } + + const payload = error.payload + const content = JSON.stringify(payload) + return { + content, + rawData: { + content, + isError: true, + toolResult: createAgentToolErrorResult(toolName, payload.error.message, { + code: payload.error.code, + recoverable: payload.error.recoverable, + data: payload + }) + } + } } } diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index ab6e8f2cb..33076fe2c 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -699,6 +699,9 @@ export class ToolPresenter implements IToolPresenter { '- Use `cdp_send` for DOM inspection, scripted interaction, screenshots, and low-level CDP commands.' ) lines.push('- Avoid using `cdp_send` `Page.navigate` for normal navigation unless needed.') + lines.push( + '- If `cdp_send` reports `yobrowser_unavailable`, call `get_browser_status`, then use `load_url` with the target URL when available.' + ) } return lines.join('\n') diff --git a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts index 7dba6f192..d4fdd8b36 100644 --- a/test/main/presenter/agentRuntimePresenter/dispatch.test.ts +++ b/test/main/presenter/agentRuntimePresenter/dispatch.test.ts @@ -1397,6 +1397,80 @@ describe('dispatch', () => { expect(block!.status).toBe('error') }) + it('preserves recoverable YoBrowser unavailable errors as failed tool context', async () => { + const tools = [makeTool('cdp_send')] + const toolPresenter = createMockToolPresenter() + const payload = { + ok: false, + error: { + code: 'yobrowser_unavailable', + message: 'YoBrowser is not available for this session, so the CDP command was not run.', + recoverable: true, + sessionId: 's1', + method: 'Page.captureScreenshot', + browserStatus: { + initialized: false, + page: null, + canGoBack: false, + canGoForward: false, + visible: false, + loading: false + }, + suggestedNextActions: [ + 'Call get_browser_status to inspect the current browser state.', + 'Call load_url with the target URL to recreate or reopen the session browser.' + ] + } + } + const content = JSON.stringify(payload) + ;(toolPresenter.callTool as ReturnType).mockResolvedValue({ + content, + rawData: { + toolCallId: 'tc1', + content, + isError: true + } + }) + const conversation: any[] = [] + + state.blocks.push({ + type: 'tool_call', + content: '', + status: 'pending', + timestamp: Date.now(), + tool_call: { + id: 'tc1', + name: 'cdp_send', + params: '{"method":"Page.captureScreenshot"}', + response: '' + } + }) + state.completedToolCalls = [ + { id: 'tc1', name: 'cdp_send', arguments: '{"method":"Page.captureScreenshot"}' } + ] + + await executeTools( + state, + conversation, + 0, + tools, + toolPresenter, + 'gpt-4', + io, + 'full_access', + new ToolOutputGuard(), + 32000, + 1024 + ) + + const toolMsg = conversation.find((message: any) => message.role === 'tool') + expect(toolMsg.content).toContain('yobrowser_unavailable') + + const block = state.blocks.find((b) => b.type === 'tool_call') + expect(block!.tool_call!.response).toContain('yobrowser_unavailable') + expect(block!.status).toBe('error') + }) + it('stops on abort', async () => { const abortController = new AbortController() const abortIo = createIo({ abortSignal: abortController.signal }) diff --git a/test/main/presenter/browser/YoBrowserToolHandler.test.ts b/test/main/presenter/browser/YoBrowserToolHandler.test.ts index f71adf17e..5adf71fc9 100644 --- a/test/main/presenter/browser/YoBrowserToolHandler.test.ts +++ b/test/main/presenter/browser/YoBrowserToolHandler.test.ts @@ -9,9 +9,24 @@ vi.mock('@shared/logger', () => ({ })) describe('YoBrowserToolHandler', () => { + const readyStatus = { + initialized: true, + page: { + id: 'page-1', + url: 'https://example.com', + status: 'ready', + createdAt: 1, + updatedAt: 2 + }, + canGoBack: false, + canGoForward: false, + visible: true, + loading: false + } + const createPresenter = () => ({ - getBrowserStatus: vi.fn().mockResolvedValue({ initialized: false }), + getBrowserStatus: vi.fn().mockResolvedValue(readyStatus), loadUrl: vi.fn().mockResolvedValue({ initialized: true }), getBrowserPage: vi.fn().mockResolvedValue({ id: 'page-1', @@ -74,13 +89,54 @@ describe('YoBrowserToolHandler', () => { ) }) - it('requires an initialized session browser before cdp_send', async () => { + it('returns a recoverable browser-unavailable error before cdp_send', async () => { const presenter = createPresenter() - presenter.getBrowserPage.mockResolvedValue(null) + presenter.getBrowserStatus.mockResolvedValue({ + initialized: false, + page: null, + canGoBack: false, + canGoForward: false, + visible: false, + loading: false + }) const handler = new YoBrowserToolHandler(presenter) await expect( handler.callTool('cdp_send', { method: 'Page.reload' }, 'session-a') - ).rejects.toThrow('Session browser for session-a is not initialized') + ).rejects.toMatchObject({ + name: 'YoBrowserUnavailableError', + payload: { + ok: false, + error: expect.objectContaining({ + code: 'yobrowser_unavailable', + recoverable: true, + sessionId: 'session-a', + method: 'Page.reload' + }) + } + }) + expect(presenter.sendCdpCommand).not.toHaveBeenCalled() + }) + + it('maps YoBrowserNotReadyError to the recoverable unavailable error', async () => { + const presenter = createPresenter() + const notReadyError = new Error('Browser page is not ready') + notReadyError.name = 'YoBrowserNotReadyError' + presenter.sendCdpCommand.mockRejectedValue(notReadyError) + const handler = new YoBrowserToolHandler(presenter) + + await expect( + handler.callTool('cdp_send', { method: 'Page.captureScreenshot' }, 'session-a') + ).rejects.toMatchObject({ + name: 'YoBrowserUnavailableError', + payload: { + error: expect.objectContaining({ + code: 'yobrowser_unavailable', + method: 'Page.captureScreenshot', + browserStatus: readyStatus + }) + }, + originalError: notReadyError + }) }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/agentToolManagerYoBrowser.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerYoBrowser.test.ts new file mode 100644 index 000000000..043944cb8 --- /dev/null +++ b/test/main/presenter/toolPresenter/agentTools/agentToolManagerYoBrowser.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'os' +import { AgentToolManager } from '@/presenter/toolPresenter/agentTools/agentToolManager' +import { + YoBrowserUnavailableError, + buildYoBrowserUnavailablePayload +} from '@/presenter/browser/YoBrowserErrors' + +vi.mock('electron', () => ({ + app: { + getPath: () => os.tmpdir() + }, + nativeImage: { + createFromPath: () => ({ + getSize: () => ({ width: 128, height: 96 }) + }) + } +})) + +describe('AgentToolManager YoBrowser routing', () => { + let manager: AgentToolManager + let yoBrowserCallTool: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + yoBrowserCallTool = vi.fn() + manager = new AgentToolManager({ + agentWorkspacePath: null, + configPresenter: { + getSkillsEnabled: () => false, + getSkillsPath: () => os.tmpdir(), + getModelConfig: vi.fn(), + resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({}) + } as any, + runtimePort: { + resolveConversationWorkdir: vi.fn().mockResolvedValue(null), + resolveConversationSessionInfo: vi.fn().mockResolvedValue(null), + getSkillPresenter: () => + ({ + getActiveSkills: vi.fn().mockResolvedValue([]), + getActiveSkillsAllowedTools: vi.fn().mockResolvedValue([]), + listSkillScripts: vi.fn().mockResolvedValue([]), + getSkillExtension: vi.fn() + }) as any, + getYoBrowserToolHandler: () => ({ + getToolDefinitions: vi.fn().mockReturnValue([]), + callTool: yoBrowserCallTool + }), + getFilePresenter: () => ({ + getMimeType: vi.fn(), + prepareFileCompletely: vi.fn() + }), + getLlmProviderPresenter: () => ({ + executeWithRateLimit: vi.fn().mockResolvedValue(undefined), + generateCompletionStandalone: vi.fn(), + generateImageStandalone: vi.fn() + }), + createSettingsWindow: vi.fn(), + sendToWindow: vi.fn().mockReturnValue(true), + getApprovedFilePaths: vi.fn().mockReturnValue([]), + consumeSettingsApproval: vi.fn().mockReturnValue(false) + } as any + }) + }) + + it('returns recoverable YoBrowser CDP failures as errored structured tool results', async () => { + const browserStatus = { + initialized: false, + page: null, + canGoBack: false, + canGoForward: false, + visible: false, + loading: false + } + yoBrowserCallTool.mockRejectedValue( + new YoBrowserUnavailableError( + buildYoBrowserUnavailablePayload('session-a', 'Page.reload', browserStatus) + ) + ) + + const result = (await manager.callTool( + 'cdp_send', + { method: 'Page.reload' }, + 'session-a' + )) as any + const payload = JSON.parse(result.content) + + expect(result.rawData.isError).toBe(true) + expect(payload).toMatchObject({ + ok: false, + error: { + code: 'yobrowser_unavailable', + recoverable: true, + sessionId: 'session-a', + method: 'Page.reload', + browserStatus + } + }) + expect(result.rawData.toolResult).toMatchObject({ + ok: false, + data: payload, + error: { + code: 'yobrowser_unavailable', + recoverable: true + } + }) + }) +}) diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index fcbd9a610..07d5a8cdb 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -323,6 +323,9 @@ describe('ToolPresenter', () => { expect(withYoBrowser).toContain( 'Avoid using `cdp_send` `Page.navigate` for normal navigation unless needed.' ) + expect(withYoBrowser).toContain( + 'If `cdp_send` reports `yobrowser_unavailable`, call `get_browser_status`, then use `load_url` with the target URL when available.' + ) }) it('includes question guidance only when deepchat_question is enabled', () => {