diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 162b431f1bf..5c1e97d2442 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -20,6 +20,7 @@ import { RuntimeClientError } from './types' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_SHELL_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, @@ -73,6 +74,7 @@ describe('CLI remote WebSocket transport', () => { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AUTOMATION_SHELL_RUNTIME_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, diff --git a/src/main/automations/precheck-runner.ts b/src/main/automations/precheck-runner.ts index ab38fd42355..8ae6343c32a 100644 --- a/src/main/automations/precheck-runner.ts +++ b/src/main/automations/precheck-runner.ts @@ -58,7 +58,7 @@ function createPrecheckResult(args: { } } -function failedPrecheckResult( +export function failedPrecheckResult( precheck: AutomationPrecheck, startedAt: number, error: string diff --git a/src/main/automations/run-completion-watcher.test.ts b/src/main/automations/run-completion-watcher.test.ts index 31c1aea0bb4..09216f0aeb1 100644 --- a/src/main/automations/run-completion-watcher.test.ts +++ b/src/main/automations/run-completion-watcher.test.ts @@ -50,12 +50,12 @@ const makeRepo = (overrides: Partial = {}): Repo => ({ type TestStore = Awaited> -function createAutomation(store: TestStore): Automation { +function createAutomation(store: TestStore, agentId: Automation['agentId'] = 'claude'): Automation { store.addRepo(makeRepo()) return store.createAutomation({ name: 'Nightly check', prompt: 'Check the repo', - agentId: 'claude', + agentId, projectId: 'r1', workspaceMode: 'existing', workspaceId: 'wt1', @@ -119,6 +119,40 @@ describe('authority-owned automation run completion', () => { service.stop() }) + it.each(['renderer', 'headless', 'retained'] as const)( + 'keeps %s shell runs out of the agent-idle completion watcher', + async (mode) => { + const store = await createStore() + const automation = createAutomation(store, null) + const observer = createObserver(async () => ({ status: 'completed' })) + const resolveTerminal = vi.spyOn(observer, 'resolveRunTerminal') + const service = new AutomationService(store, { + terminalObserver: observer, + headlessDispatcher: mode === 'headless' ? async () => ({ ...LAUNCH_TARGET }) : undefined + }) + + try { + let run: AutomationRun + if (mode === 'headless') { + run = await service.runNow(automation.id) + } else { + run = store.createAutomationRun(automation, Date.now(), 'manual') + const result = { runId: run.id, status: 'dispatched' as const, ...LAUNCH_TARGET } + if (mode === 'renderer') { + await service.markDispatchResult(result) + } else { + store.updateAutomationRun(result) + service.start() + } + } + expect(resolveTerminal).not.toHaveBeenCalled() + expect(readRun(store, automation.id, run.id).status).toBe('dispatched') + } finally { + service.stop() + } + } + ) + it('leaves a headless dispatched run alone when the authority cannot observe it', async () => { const store = await createStore() const automation = createAutomation(store) diff --git a/src/main/automations/service-precheck.test.ts b/src/main/automations/service-precheck.test.ts index c13359b546d..81231e11db4 100644 --- a/src/main/automations/service-precheck.test.ts +++ b/src/main/automations/service-precheck.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import type { Repo } from '../../shared/repo-types' import { AutomationService } from './service' +import type * as PrecheckRunnerModule from './precheck-runner' import { runHeadlessAutomationDispatch } from './headless-dispatch-runner' import { createAutomationRunWriter } from './automation-run-writer' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' @@ -22,7 +23,8 @@ vi.mock('electron', () => ({ } })) -vi.mock('./precheck-runner', () => ({ +vi.mock('./precheck-runner', async (importOriginal) => ({ + ...(await importOriginal()), runAutomationPrecheck: runAutomationPrecheckMock })) @@ -155,10 +157,18 @@ describe('AutomationService prechecks', () => { const result = await service.runPrecheck(automation.id, run.id) - expect(result).toMatchObject({ + expect(result).toEqual({ command: 'test -f ready', exitCode: null, - error: 'Project path for the selected automation host has changed.' + timedOut: false, + durationMs: 0, + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + error: 'Project path for the selected automation host has changed.', + startedAt: Date.now(), + completedAt: Date.now() }) expect(runAutomationPrecheckMock).not.toHaveBeenCalled() }) diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index 2080ee11b82..9be78cca752 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -10,7 +10,7 @@ import { } from '../../shared/automations-types' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' -import { runAutomationPrecheck } from './precheck-runner' +import { failedPrecheckResult, runAutomationPrecheck } from './precheck-runner' import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution' import { collectAutomationRunUsage } from './run-usage-collection' import type { HeadlessAutomationDispatcher } from './headless-dispatch' @@ -166,19 +166,7 @@ export class AutomationService { } const target = this.resolveTarget(automation) if (!target.ok) { - return { - command: automation.precheck.command, - exitCode: null, - timedOut: false, - durationMs: 0, - stdout: '', - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - error: target.error, - startedAt: Date.now(), - completedAt: Date.now() - } + return failedPrecheckResult(automation.precheck, Date.now(), target.error) } return await runAutomationPrecheck({ precheck: automation.precheck, @@ -193,8 +181,8 @@ export class AutomationService { const run = this.runs.updateRun(result) clearAutomationDispatchTokens(run.automationId, run.id) if (!isFinalAutomationRunStatus(run.status)) { - if (run.status === 'dispatched' && this.isAgentRun(run)) { - this.completionWatcher?.watch(run) + if (run.status === 'dispatched' && this.completionWatcher && this.isAgentRun(run)) { + this.completionWatcher.watch(run) } return run } @@ -325,8 +313,8 @@ export class AutomationService { runPrecheck: () => this.runPrecheck(automation.id, run.id), markDispatchResult: (result) => this.markDispatchResult(result), watchRun: (dispatched) => { - if (this.isAgentRun(dispatched)) { - this.completionWatcher?.watch(dispatched) + if (this.completionWatcher && this.isAgentRun(dispatched)) { + this.completionWatcher.watch(dispatched) } } }) diff --git a/src/main/ipc/runtime-subscribe-lifecycle.test.ts b/src/main/ipc/runtime-subscribe-lifecycle.test.ts index 47f9e6afa4d..63c8e5a87a5 100644 --- a/src/main/ipc/runtime-subscribe-lifecycle.test.ts +++ b/src/main/ipc/runtime-subscribe-lifecycle.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AUTOMATION_SHELL_RUNTIME_CAPABILITY } from '../../shared/protocol-version' type StreamRecord = { + clientCapabilities: readonly string[] | undefined connectionId: string | undefined emit: (response: string) => void settled: boolean @@ -39,9 +41,14 @@ vi.mock('../runtime/rpc/dispatcher', () => ({ dispatchStreaming( request: { id: string }, emit: (response: string) => void, - options: { connectionId?: string; signal: AbortSignal } + options: { + clientCapabilities?: readonly string[] + connectionId?: string + signal: AbortSignal + } ): Promise { const record: StreamRecord = { + clientCapabilities: options.clientCapabilities, connectionId: options.connectionId, emit, settled: false, @@ -157,6 +164,14 @@ describe('runtime:subscribe renderer lifecycle cleanup', () => { registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never) }) + it('advertises shell support on same-version desktop subscriptions', () => { + const harness = createSender(1) + subscribe(harness.sender, 'sub-shell') + + expect(streamFor('sub-shell').clientCapabilities).toContain(AUTOMATION_SHELL_RUNTIME_CAPABILITY) + harness.destroy() + }) + it('aborts a live stream once its sender commits a navigation', () => { const harness = createSender(1) subscribe(harness.sender, 'sub-reload') diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index 07010087363..be1d3f3b413 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -170,6 +170,41 @@ describe('registerRuntimeHandlers', () => { expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } }) }) + it('lets the same-version desktop create and list blank-terminal automations', async () => { + const input = { + name: 'Shell check', + prompt: 'echo ready', + agentId: null, + repo: 'repo-1', + rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0', + dtstart: 1 + } + const automation = { id: 'automation-shell', ...input } + const listing = { + automations: [automation], + items: [{ automationId: automation.id, selector: { kind: 'self' } }] + } + const runtime = { + getRuntimeId: () => 'runtime-1', + createAutomation: vi.fn().mockResolvedValue(automation), + listAutomationsForScope: vi.fn().mockReturnValue(listing) + } + registerRuntimeHandlers(runtime as never) + const handler = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')![1] + + const event = runtimeCallEvent() + await expect( + handler(event, { method: 'automation.create', params: input }) + ).resolves.toMatchObject({ ok: true, result: { automation } }) + expect(runtime.createAutomation).toHaveBeenCalledWith(input) + await expect( + handler(event, { + method: 'automation.list', + params: { selector: { kind: 'self' } } + }) + ).resolves.toMatchObject({ ok: true, result: listing }) + }) + it('registers project group runtime RPC methods for local desktop callers', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 3901d8b1ffa..d8a95965efd 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -11,6 +11,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { + AUTOMATION_SHELL_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' @@ -81,7 +82,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AUTOMATION_SHELL_RUNTIME_CAPABILITY ] } )) as RuntimeRpcResponse @@ -129,7 +131,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { connectionId, clientCapabilities: [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AUTOMATION_SHELL_RUNTIME_CAPABILITY ] } ) diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index d6bf969d6bc..d2875dc57cf 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -784,22 +784,8 @@ describe('useAutomationDispatchEvents setup launch', () => { it.each(['claude', null] as const)( 'releases ownership on nonzero exit without finalizing the %s tab', async (agentId) => { - let onExit: ((ptyId: string, code: number) => void) | undefined - mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { - onExit = args.onExit - return { - tabId: 'agent-tab', - paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', - ptyId: 'agent-pty', - startupPlan: {}, - terminalOwnership: { - finalize: mockFinalizeTerminalOwnership, - release: mockReleaseTerminalOwnership - } - } - }) - await registerAndDispatch(makeAutomation({ agentId })) + const { onExit } = mockLaunchAgentBackgroundSession.mock.calls[0][0] if (agentId === null) { expect(mockStoreSubscribe).not.toHaveBeenCalled() } diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index d493dec1aef..dddbb1e3cc9 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -56,6 +56,7 @@ const INVENTORY: readonly InventoryGroup[] = [ 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx', 2 ], + ['src/renderer/src/components/automations/AutomationDetail.tsx', 2], ['src/renderer/src/components/automations/AutomationListLocalRow.tsx', 2], 'src/renderer/src/components/automations/automation-draft-model.ts', ['src/renderer/src/components/automations/automation-list-search-rows.ts', 2],