diff --git a/apps/agent-runner/bridges/macos/Sources/AgentMarkBridgeMacos/Dispatcher.swift b/apps/agent-runner/bridges/macos/Sources/AgentMarkBridgeMacos/Dispatcher.swift index 72a0b6c..fe7a6e5 100644 --- a/apps/agent-runner/bridges/macos/Sources/AgentMarkBridgeMacos/Dispatcher.swift +++ b/apps/agent-runner/bridges/macos/Sources/AgentMarkBridgeMacos/Dispatcher.swift @@ -29,10 +29,12 @@ final class Dispatcher { return try handleCapture(params: request.params) case "execute": return try handleExecute(params: request.params) + case "execute_batch": + return try handleExecuteBatch(params: request.params) default: throw RpcError( code: .methodNotFound, - message: "Unknown method: \(request.method). Supported: ping, capabilities, list_windows, capture, execute.", + message: "Unknown method: \(request.method). Supported: ping, capabilities, list_windows, capture, execute, execute_batch.", requestId: request.id ) } @@ -55,7 +57,7 @@ final class Dispatcher { return [ "bridge": "agentmark-bridge-macos", "version": bridgeVersion, - "methods": ["ping", "capabilities", "list_windows", "capture", "execute"], + "methods": ["ping", "capabilities", "list_windows", "capture", "execute", "execute_batch"], "axapiProvider": "Accessibility (AXAPI)", "platform": "macos", "accessibilityGranted": AccessibilityPermission.isGranted, @@ -83,9 +85,60 @@ final class Dispatcher { } private func handleExecute(params: JsonValue?) throws -> Any { + guard let p = params else { + throw RpcError(code: .invalidParams, message: "execute requires params.") + } + let req = try buildExecuteRequest(p) + return try capturer.execute(req) + } + + /// Run N actions in one in-process loop so the entire batch costs one + /// stdio round-trip. `onError: stop` (default) aborts on the first + /// failure; `onError: continue` runs the full array regardless. + private func handleExecuteBatch(params: JsonValue?) throws -> Any { guard let p = params, - let elementId = p.string("elementId"), !elementId.isEmpty else { - throw RpcError(code: .invalidParams, message: "execute requires `elementId`.") + let actionsAny = p.dict?["actions"], + case .array(let actionArray) = JsonValue.fromAny(actionsAny) else { + throw RpcError(code: .invalidParams, message: "execute_batch requires an `actions` array.") + } + + let stopOnError = (p.string("onError") ?? "stop") != "continue" + + var results: [[String: Any]] = [] + results.reserveCapacity(actionArray.count) + var allOk = true + + for actionAny in actionArray { + let actionParam = JsonValue.fromAny(actionAny) + let req: AxapiCapturer.ExecuteRequest + do { + req = try buildExecuteRequest(actionParam) + } catch { + results.append(["ok": false, "message": "\(error)"]) + allOk = false + if stopOnError { break } + continue + } + let r = try capturer.execute(req) + // `capturer.execute` returns `[String: Any]` with `ok` / `message` / `newValue`. + let normalized = r as? [String: Any] ?? [:] + results.append(normalized) + if let ok = normalized["ok"] as? Bool, !ok { + allOk = false + if stopOnError { break } + } + } + + return [ + "results": results, + "allOk": allOk, + "executedCount": results.count, + ] as [String: Any] + } + + private func buildExecuteRequest(_ p: JsonValue) throws -> AxapiCapturer.ExecuteRequest { + guard let elementId = p.string("elementId"), !elementId.isEmpty else { + throw RpcError(code: .invalidParams, message: "execute action requires `elementId`.") } var req = AxapiCapturer.ExecuteRequest() req.elementId = elementId @@ -100,6 +153,6 @@ final class Dispatcher { } req.clearFirst = p.bool("clearFirst") ?? false if let i = p.int("timeoutMs") { req.timeoutMs = i } - return try capturer.execute(req) + return req } } diff --git a/apps/agent-runner/bridges/windows/Program.cs b/apps/agent-runner/bridges/windows/Program.cs index f0af753..72a4981 100644 --- a/apps/agent-runner/bridges/windows/Program.cs +++ b/apps/agent-runner/bridges/windows/Program.cs @@ -205,14 +205,15 @@ public RpcDispatcher() { return method switch { - "ping" => HandlePing(), - "capabilities" => HandleCapabilities(), - "list_windows" => HandleListWindows(), - "capture" => HandleCapture(@params), - "execute" => HandleExecute(@params), + "ping" => HandlePing(), + "capabilities" => HandleCapabilities(), + "list_windows" => HandleListWindows(), + "capture" => HandleCapture(@params), + "execute" => HandleExecute(@params), + "execute_batch" => HandleExecuteBatch(@params), _ => throw new RpcException( RpcError.MethodNotFound, - $"Unknown method: {method}. Supported: ping, capabilities, list_windows, capture, execute."), + $"Unknown method: {method}. Supported: ping, capabilities, list_windows, capture, execute, execute_batch."), }; } @@ -247,6 +248,7 @@ public void Dispose() "list_windows", "capture", "execute", + "execute_batch", }, uiaProvider = "FlaUI.UIA3", platform = "windows", @@ -288,22 +290,7 @@ private object HandleCapture(JsonElement @params) private object HandleExecute(JsonElement @params) { - var modifiers = ReadStringArray(@params, "modifiers"); - - var req = new UiaCapturer.ExecuteRequest - { - ElementId = ReadString(@params, "elementId") - ?? throw new RpcException(RpcError.InvalidParams, "execute requires `elementId`."), - ActionType = ReadString(@params, "actionType") ?? "click", - Text = ReadString(@params, "text"), - Value = ReadString(@params, "value"), - Checked = ReadBool(@params, "checked"), - Expanded = ReadBool(@params, "expanded"), - Key = ReadString(@params, "key"), - Modifiers = modifiers, - ClearFirst = ReadBool(@params, "clearFirst") ?? false, - TimeoutMs = ReadInt(@params, "timeoutMs") ?? 5000, - }; + var req = BuildExecuteRequest(@params); try { @@ -323,6 +310,82 @@ private object HandleExecute(JsonElement @params) } } + /// + /// Run a sequence of actions inside the same STA invocation so the entire + /// batch costs one stdio round-trip. Honours `onError: stop | continue` + /// to abort or keep going past failures. + /// + private object HandleExecuteBatch(JsonElement @params) + { + if (!@params.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array) + { + throw new RpcException(RpcError.InvalidParams, "execute_batch requires an `actions` array."); + } + + var stopOnError = (ReadString(@params, "onError") ?? "stop") != "continue"; + + var requests = new List(actionsEl.GetArrayLength()); + foreach (var actionEl in actionsEl.EnumerateArray()) + { + requests.Add(BuildExecuteRequest(actionEl)); + } + + var results = new List(requests.Count); + var allOk = true; + + try + { + var capturer = _capturer.Value; + var sta = _staWorker.Value; + sta.Invoke(() => + { + foreach (var req in requests) + { + var r = capturer.Execute(req); + results.Add(new { ok = r.Ok, message = r.Message, newValue = r.NewValue }); + if (!r.Ok) + { + allOk = false; + if (stopOnError) break; + } + } + }); + } + catch (InvalidOperationException ex) + { + throw new RpcException(RpcError.InternalError, ex.Message); + } + + return new + { + results = results.ToArray(), + allOk, + executedCount = results.Count, + }; + } + + /// + /// Pull an ExecuteRequest out of a JSON params blob. Shared between the + /// single-execute and batch-execute paths. + /// + private static UiaCapturer.ExecuteRequest BuildExecuteRequest(JsonElement @params) + { + return new UiaCapturer.ExecuteRequest + { + ElementId = ReadString(@params, "elementId") + ?? throw new RpcException(RpcError.InvalidParams, "execute action requires `elementId`."), + ActionType = ReadString(@params, "actionType") ?? "click", + Text = ReadString(@params, "text"), + Value = ReadString(@params, "value"), + Checked = ReadBool(@params, "checked"), + Expanded = ReadBool(@params, "expanded"), + Key = ReadString(@params, "key"), + Modifiers = ReadStringArray(@params, "modifiers"), + ClearFirst = ReadBool(@params, "clearFirst") ?? false, + TimeoutMs = ReadInt(@params, "timeoutMs") ?? 5000, + }; + } + private static string[]? ReadStringArray(JsonElement parent, string name) { if (parent.ValueKind != JsonValueKind.Object) return null; diff --git a/src/desktop/fixture-backend.ts b/src/desktop/fixture-backend.ts index 9b6c585..0c29237 100644 --- a/src/desktop/fixture-backend.ts +++ b/src/desktop/fixture-backend.ts @@ -18,7 +18,11 @@ import type { DesktopCaptureBackend, CaptureDesktopOptions, DesktopElement, + DesktopTarget, DesktopTargetSummary, + ExecuteDesktopAction, + ExecuteDesktopBatchOptions, + ExecuteDesktopBatchResult, ExecuteDesktopOptions, ExecuteDesktopResult, } from './types' @@ -104,6 +108,47 @@ export class FixtureBackend implements DesktopCaptureBackend { return { ok: true } } } + + async executeBatch(opts: ExecuteDesktopBatchOptions): Promise { + // The fixture pays its `latencyMs` once for the whole batch — modelling + // a bridge that processes the whole array inside its sidecar. The + // looped-execute fallback in WindowsUiaBackend / MacosAxapiBackend + // pays it per call. + if (this.latencyMs) await delay(this.latencyMs) + + const results: ExecuteDesktopResult[] = [] + const onError = opts.on_error ?? 'stop' + for (const action of opts.actions) { + const result = await this.executeAction(opts.target, action) + results.push(result) + if (!result.ok && onError === 'stop') break + } + return { + results, + all_ok: results.every((r) => r.ok), + executed_count: results.length, + } + } + + private async executeAction( + target: DesktopTarget | undefined, + action: ExecuteDesktopAction, + ): Promise { + this.executed.push({ target, action }) + switch (action.type) { + case 'type': + this.elementValues.set(action.element_id, action.text) + return { ok: true, new_value: action.text } + case 'check': + this.elementValues.set(action.element_id, action.checked ? 'true' : 'false') + return { ok: true, new_value: String(action.checked) } + case 'select': + this.elementValues.set(action.element_id, action.value) + return { ok: true, new_value: action.value } + default: + return { ok: true } + } + } } function delay(ms: number): Promise { diff --git a/src/desktop/macos-axapi-backend.ts b/src/desktop/macos-axapi-backend.ts index 80ddf19..5ec61f9 100644 --- a/src/desktop/macos-axapi-backend.ts +++ b/src/desktop/macos-axapi-backend.ts @@ -35,6 +35,8 @@ import type { DesktopCaptureBackend, DesktopTargetSummary, ExecuteDesktopAction, + ExecuteDesktopBatchOptions, + ExecuteDesktopBatchResult, ExecuteDesktopOptions, ExecuteDesktopResult, KeyModifier, @@ -149,6 +151,32 @@ export class MacosAxapiBackend implements DesktopCaptureBackend { } } + async executeBatch(opts: ExecuteDesktopBatchOptions): Promise { + await this.ensureStarted() + const params: Record = { + actions: opts.actions.map((a) => buildExecuteParams(a)), + onError: opts.on_error ?? 'stop', + } + if (opts.timeoutMs !== undefined) params.timeoutMs = opts.timeoutMs + + const raw = (await this.callWithTimeout( + 'execute_batch', + params, + opts.timeoutMs ?? Math.max(5000, opts.actions.length * 50), + )) as { results?: RawExecuteResult[]; allOk?: boolean; executedCount?: number } + + const results = (raw.results ?? []).map((r) => ({ + ok: !!r.ok, + message: r.message ?? undefined, + new_value: r.newValue ?? undefined, + })) + return { + results, + all_ok: typeof raw.allOk === 'boolean' ? raw.allOk : results.every((r) => r.ok), + executed_count: typeof raw.executedCount === 'number' ? raw.executedCount : results.length, + } + } + async close(): Promise { this.closed = true const proc = this.proc diff --git a/src/desktop/types.ts b/src/desktop/types.ts index 7692a07..0ba5d75 100644 --- a/src/desktop/types.ts +++ b/src/desktop/types.ts @@ -35,6 +35,13 @@ export interface DesktopCaptureBackend { * is identified by `element_id` which comes from the capture tree. */ execute(opts: ExecuteDesktopOptions): Promise + /** Execute a sequence of actions in one round-trip. Bridges that + * implement this natively run the entire batch inside the sidecar + * process; the default fallback below calls `execute()` in a loop + * (still saves the MCP dispatch overhead but pays N stdio round-trips + * to the bridge). */ + executeBatch?(opts: ExecuteDesktopBatchOptions): Promise + /** Optional teardown — release native handles, close sidecar process. */ close?(): Promise } @@ -231,3 +238,40 @@ export interface ExecuteDesktopResult { * re-capture. */ new_value?: string } + +/** + * Batched execution — run N actions in one MCP / bridge round-trip. + * + * Designed for bulk-input workflows ("fill 1000 form fields", "write 10k + * Excel cells") where the per-call dispatch overhead dominates the + * actual SetValue/Click work. Bridges that implement this natively run + * the whole array inside their sidecar; the dispatcher's default loop + * fallback still wins by avoiding the MCP round-trips. + */ +export interface ExecuteDesktopBatchOptions { + /** Which window the actions target. Same semantics as `execute()`. */ + target?: DesktopTarget + /** Sequence of actions to run. Executed in array order. */ + actions: ReadonlyArray + /** + * What to do on failure of any single action: + * - 'stop' (default): abort the remainder, return results so far + * plus an error result for the failure. + * - 'continue': keep running, return one result per action. + */ + on_error?: 'stop' | 'continue' + /** Per-batch timeout (ms). Default: max(5000, 50 * actions.length). */ + timeoutMs?: number +} + +export interface ExecuteDesktopBatchResult { + /** One result per attempted action, in input order. Length may be + * less than `actions.length` when `on_error: 'stop'` and a failure + * occurred before the end. */ + results: ReadonlyArray + /** Aggregate flag: true when every result has `ok: true`. */ + all_ok: boolean + /** Number of actions that ran (including the failing one when + * on_error: 'stop'). */ + executed_count: number +} diff --git a/src/desktop/windows-uia-backend.ts b/src/desktop/windows-uia-backend.ts index 28874b3..beeb888 100644 --- a/src/desktop/windows-uia-backend.ts +++ b/src/desktop/windows-uia-backend.ts @@ -47,6 +47,8 @@ import type { DesktopCaptureBackend, DesktopTargetSummary, ExecuteDesktopAction, + ExecuteDesktopBatchOptions, + ExecuteDesktopBatchResult, ExecuteDesktopOptions, ExecuteDesktopResult, KeyModifier, @@ -161,6 +163,32 @@ export class WindowsUiaBackend implements DesktopCaptureBackend { } } + async executeBatch(opts: ExecuteDesktopBatchOptions): Promise { + await this.ensureStarted() + const params: Record = { + actions: opts.actions.map((a) => buildExecuteParams(a)), + onError: opts.on_error ?? 'stop', + } + if (opts.timeoutMs !== undefined) params.timeoutMs = opts.timeoutMs + + const raw = (await this.callWithTimeout( + 'execute_batch', + params, + opts.timeoutMs ?? Math.max(5000, opts.actions.length * 50), + )) as { results?: RawExecuteResult[]; allOk?: boolean; executedCount?: number } + + const results = (raw.results ?? []).map((r) => ({ + ok: !!r.ok, + message: r.message ?? undefined, + new_value: r.newValue ?? undefined, + })) + return { + results, + all_ok: typeof raw.allOk === 'boolean' ? raw.allOk : results.every((r) => r.ok), + executed_count: typeof raw.executedCount === 'number' ? raw.executedCount : results.length, + } + } + async close(): Promise { this.closed = true const proc = this.proc diff --git a/src/mcp/plugins/desktop.ts b/src/mcp/plugins/desktop.ts index 323293a..8bf582e 100644 --- a/src/mcp/plugins/desktop.ts +++ b/src/mcp/plugins/desktop.ts @@ -146,6 +146,59 @@ const DESKTOP_TOOLS: McpToolDef[] = [ required: ['desktop_id', 'action_id'], }, }, + { + name: 'agentmark_desktop_execute_batch', + description: + 'Run a sequence of actions in ONE round-trip. Use this for any ' + + 'workflow that drives more than a handful of elements — form ' + + 'fills, bulk Excel cell writes, table populations. The actions ' + + 'array is processed in order. Each entry is an `action_id` ' + + 'from the most recent snapshot plus optional input data ' + + '(same shape as agentmark_desktop_execute, minus desktop_id).\n' + + '\nBackends that support a native batched path (windows_uia, ' + + 'macos_axapi) run the whole array inside the sidecar without ' + + 'crossing back to Node between actions; the fixture backend ' + + 'simulates this. Net effect: a 1000-action batch costs ~1 ' + + 'MCP dispatch + ~1 stdio round-trip instead of 1000 each.\n' + + '\nReturns one result per executed action, an `all_ok` flag, ' + + 'and `executed_count` (which may be less than actions.length ' + + 'when on_error="stop" hits a failure).', + inputSchema: { + type: 'object', + properties: { + desktop_id: { type: 'string' }, + actions: { + type: 'array', + description: 'Sequence of actions to run.', + items: { + type: 'object', + properties: { + action_id: { type: 'string' }, + value: { description: 'Value for input actions (same semantics as agentmark_desktop_execute).' }, + modifiers: { + type: 'array', + items: { type: 'string', enum: ['ctrl', 'alt', 'shift', 'meta', 'win'] }, + }, + clear_first: { type: 'boolean' }, + }, + required: ['action_id'], + }, + }, + on_error: { + type: 'string', + enum: ['stop', 'continue'], + description: + '"stop" (default): abort the rest of the batch on ' + + 'the first failure. "continue": run every action ' + + 'regardless. Pick "stop" for ordered workflows ' + + 'where a missing prerequisite invalidates the ' + + 'rest; "continue" for independent bulk fills.', + }, + timeout_ms: { type: 'number', description: 'Per-batch timeout. Default scales with action count.' }, + }, + required: ['desktop_id', 'actions'], + }, + }, ] export interface DesktopPlugin extends AgentMarkPlugin { @@ -292,6 +345,103 @@ export function createDesktopPlugin(): DesktopPlugin { isError: !result.ok, } }, + + agentmark_desktop_execute_batch: async (args): Promise => { + const id = requireString(args, 'desktop_id') + const session = requireDesktop(id) + + if (!session.lastBinding || !session.lastActionTypes) { + return { + text: + `No cached snapshot for desktop_id ${id}. Call ` + + `agentmark_desktop_snapshot first so action_ids can be resolved.`, + isError: true, + } + } + + const rawActions = args.actions + if (!Array.isArray(rawActions) || rawActions.length === 0) { + return { text: '`actions` must be a non-empty array.', isError: true } + } + + // Resolve every action_id to its (action_type, element_id) up + // front. If any are unknown we fail fast — saves the round-trip + // for an invariably-doomed batch. + const resolved: Array<{ + action_id: string + action_type: string + element_id: string + action: ReturnType + }> = [] + for (let i = 0; i < rawActions.length; i++) { + const item = rawActions[i] as Record + const actionId = typeof item?.action_id === 'string' ? item.action_id : '' + if (!actionId) { + return { text: `actions[${i}].action_id is required.`, isError: true } + } + const elementId = session.lastBinding.get(actionId) + if (!elementId) { + return { text: `Unknown action_id at index ${i}: ${actionId}`, isError: true } + } + const actionType = session.lastActionTypes.get(actionId) ?? 'click' + const modifiers = parseModifiers(item.modifiers) + const clearFirst = item.clear_first === true + const action = buildExecuteAction(actionType, elementId, item.value, modifiers, clearFirst) + resolved.push({ action_id: actionId, action_type: actionType, element_id: elementId, action }) + } + + const onError = + args.on_error === 'continue' || args.on_error === 'stop' + ? (args.on_error as 'stop' | 'continue') + : 'stop' + const timeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined + + // Use the backend's native batched path when available; otherwise + // loop execute() so the tool still has the same surface (only + // savings is per-MCP-call overhead). + let batchResult: { results: Array<{ ok: boolean; message?: string; new_value?: string }>; all_ok: boolean; executed_count: number } + if (typeof session.backend.executeBatch === 'function') { + const raw = await session.backend.executeBatch({ + target: session.lastTarget, + actions: resolved.map((r) => r.action), + on_error: onError, + timeoutMs, + }) + batchResult = { + results: raw.results.map((r) => ({ ...r })), + all_ok: raw.all_ok, + executed_count: raw.executed_count, + } + } else { + const results: Array<{ ok: boolean; message?: string; new_value?: string }> = [] + for (const r of resolved) { + const res = await session.backend.execute({ target: session.lastTarget, action: r.action }) + results.push({ ok: res.ok, message: res.message, new_value: res.new_value }) + if (!res.ok && onError === 'stop') break + } + batchResult = { + results, + all_ok: results.every((r) => r.ok), + executed_count: results.length, + } + } + + return { + text: JSON.stringify({ + all_ok: batchResult.all_ok, + executed_count: batchResult.executed_count, + requested_count: resolved.length, + results: batchResult.results.map((r, i) => ({ + action_id: resolved[i]?.action_id, + action_type: resolved[i]?.action_type, + ok: r.ok, + ...(r.message !== undefined ? { message: r.message } : {}), + ...(r.new_value !== undefined ? { new_value: r.new_value } : {}), + })), + }, null, 2), + isError: !batchResult.all_ok, + } + }, } return { diff --git a/test/mcp/desktop-dispatcher.test.ts b/test/mcp/desktop-dispatcher.test.ts index 44129f1..c5d89b5 100644 --- a/test/mcp/desktop-dispatcher.test.ts +++ b/test/mcp/desktop-dispatcher.test.ts @@ -193,6 +193,92 @@ describe('MCP — desktop tools', () => { expect(exec.text).toContain('No cached snapshot') }) + it('agentmark_desktop_execute_batch runs a sequence of actions in one dispatch', async () => { + const open = await dispatch(state, 'agentmark_desktop_open', {}) + const { desktop_id } = JSON.parse(open.text) + + await dispatch(state, 'agentmark_desktop_snapshot', { + desktop_id, + target: { window_id: 'nowcerts_customer' }, + }) + + const batch = await dispatch(state, 'agentmark_desktop_execute_batch', { + desktop_id, + actions: [ + { action_id: 'act_in_company', value: 'Beta Industries' }, + { action_id: 'act_in_phone', value: '555-0100' }, + ], + }) + expect(batch.isError).toBeFalsy() + const body = JSON.parse(batch.text) + expect(body.all_ok).toBe(true) + expect(body.executed_count).toBe(2) + expect(body.requested_count).toBe(2) + expect(body.results[0]).toMatchObject({ action_id: 'act_in_company', ok: true, new_value: 'Beta Industries' }) + expect(body.results[1]).toMatchObject({ action_id: 'act_in_phone', ok: true, new_value: '555-0100' }) + + // Re-snapshot to confirm both values landed. + const after = await dispatch(state, 'agentmark_desktop_snapshot', { + desktop_id, + target: { window_id: 'nowcerts_customer' }, + }) + const snap = parseSnapshot(after.text) + expect(snap.actions?.act_in_company?.value).toBe('Beta Industries') + expect(snap.actions?.act_in_phone?.value).toBe('555-0100') + }) + + it('agentmark_desktop_execute_batch fails fast on unknown action_id before dispatching anything', async () => { + const open = await dispatch(state, 'agentmark_desktop_open', {}) + const { desktop_id } = JSON.parse(open.text) + + await dispatch(state, 'agentmark_desktop_snapshot', { + desktop_id, + target: { window_id: 'nowcerts_customer' }, + }) + + const batch = await dispatch(state, 'agentmark_desktop_execute_batch', { + desktop_id, + actions: [ + { action_id: 'act_in_company', value: 'OK' }, + { action_id: 'act_bogus_id', value: 'fails' }, + ], + }) + expect(batch.isError).toBe(true) + expect(batch.text).toContain('Unknown action_id at index 1') + + // Crucially: the fixture backend should NOT have received any execute + // call — fail-fast happens *before* the backend is touched. That's + // what makes the batch tool safe for ordered workflows. + const desktop = state.desktops.get(desktop_id)! + const backend = desktop.backend as unknown as { executed: unknown[] } + expect(backend.executed.length).toBe(0) + }) + + it('agentmark_desktop_execute_batch rejects an empty actions array', async () => { + const open = await dispatch(state, 'agentmark_desktop_open', {}) + const { desktop_id } = JSON.parse(open.text) + await dispatch(state, 'agentmark_desktop_snapshot', { desktop_id }) + + const batch = await dispatch(state, 'agentmark_desktop_execute_batch', { + desktop_id, + actions: [], + }) + expect(batch.isError).toBe(true) + expect(batch.text).toMatch(/non-empty array/i) + }) + + it('agentmark_desktop_execute_batch errors when no snapshot has been captured yet', async () => { + const open = await dispatch(state, 'agentmark_desktop_open', {}) + const { desktop_id } = JSON.parse(open.text) + + const batch = await dispatch(state, 'agentmark_desktop_execute_batch', { + desktop_id, + actions: [{ action_id: 'act_anything' }], + }) + expect(batch.isError).toBe(true) + expect(batch.text).toContain('No cached snapshot') + }) + it('agentmark_desktop_close removes the session', async () => { const open = await dispatch(state, 'agentmark_desktop_open', {}) const { desktop_id } = JSON.parse(open.text)