mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
refactor: simplify retired-Gemini cleanup
Collapse the settings migration from eleven shape-specific walkers into one key-matched depth-first pass (219 -> 104 lines), and trim the managed-hook cleanup to its read/strip/write core (120 -> 87 lines). Fixes two bugs the rewrite exposed: commitMessageAi kept its `enabled` flag (only automations disable on a retired agent), and already-disabled automations no longer re-trigger a save. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -184,7 +184,6 @@ module.exports = {
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
|
||||
@@ -26,13 +26,12 @@ const MANAGED_SCRIPTS = [
|
||||
['cursor-hook.sh', 'cursor'],
|
||||
['devin-hook.sh', 'devin'],
|
||||
['droid-hook.sh', 'droid'],
|
||||
['gemini-hook.sh', 'gemini'],
|
||||
['grok-hook.sh', 'grok'],
|
||||
['kimi-hook.sh', 'kimi'],
|
||||
['openclaude-hook.sh', 'claude']
|
||||
]
|
||||
|
||||
const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh', 'gemini-hook.sh'])
|
||||
const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh'])
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { home: process.env.HOME ?? '', minMtime: 0 }
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/agent-hooks/retired-gemini-hook-cleanup.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -65,7 +66,6 @@
|
||||
"../src/main/copilot/hook-service.ts",
|
||||
"../src/main/cursor/hook-service.ts",
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/grok/windows-grok-hook-script.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
|
||||
@@ -55,8 +55,10 @@ describe('resolveMobileTerminalTabAgentId', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('falls back to explicit terminal titles when hook identity is unavailable', () => {
|
||||
expect(resolveMobileTerminalTabAgentId(terminalTab('✦ Gemini CLI'))).toBe('gemini')
|
||||
it('does not map retired Gemini title decorations to an agent id', () => {
|
||||
// Gemini CLI is no longer a TuiAgent; title fallback must not invent an identity.
|
||||
expect(resolveMobileTerminalTabAgentId(terminalTab('✦ Gemini CLI'))).toBeNull()
|
||||
expect(resolveMobileTerminalTabAgentId(terminalTab('Codex ready'))).toBe('codex')
|
||||
})
|
||||
|
||||
it('does not let title fallback override launch identity', () => {
|
||||
@@ -76,8 +78,18 @@ describe('resolveMobileTerminalTabAgentId', () => {
|
||||
})
|
||||
|
||||
describe('getMobileSessionTabTitle', () => {
|
||||
it('keeps retired Gemini titles unstripped when no agent identity is known', () => {
|
||||
// No icon without identity; leave the raw title (including decoration) alone.
|
||||
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('✦ Gemini CLI')
|
||||
})
|
||||
|
||||
it('strips leading agent decorations when an icon is shown', () => {
|
||||
expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI')
|
||||
expect(getMobileSessionTabTitle(terminalTab('Codex ready', { agentType: 'codex' }))).toBe(
|
||||
'Codex ready'
|
||||
)
|
||||
expect(getMobileSessionTabTitle(terminalTab('✳ investigating', { agentType: 'claude' }))).toBe(
|
||||
'investigating'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back for glyph-only agent titles on mobile', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS,
|
||||
type ManagedAgentHookInstaller
|
||||
} from './managed-agent-hook-registry'
|
||||
import { removeRetiredGeminiManagedHooksLocal } from './retired-gemini-hook-cleanup'
|
||||
|
||||
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
|
||||
export { prepareManagedCodexHomeBeforeShellLaunch } from '../codex/managed-home-shell-preflight'
|
||||
@@ -111,6 +112,12 @@ export async function installManagedAgentHooks(
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
// Why: Gemini CLI is gone; strip leftover managed hooks that 404 on /hook/gemini.
|
||||
try {
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
} catch (error) {
|
||||
console.warn('[agent-hooks] Failed to clean retired Gemini managed hooks:', error)
|
||||
}
|
||||
await refreshExistingManagedScripts(options)
|
||||
const installers = selectedInstallers(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
@@ -167,6 +174,15 @@ export async function installManagedAgentHooks(
|
||||
}
|
||||
|
||||
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
|
||||
// Why: full uninstall must also clear retired Gemini entries that no longer
|
||||
// have a remover in MANAGED_AGENT_HOOK_REMOVERS.
|
||||
if (!options.agents) {
|
||||
try {
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
} catch (error) {
|
||||
console.warn('[agent-hooks] Failed to clean retired Gemini managed hooks:', error)
|
||||
}
|
||||
}
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
return MANAGED_AGENT_HOOK_REMOVERS.filter(
|
||||
([agent]) => allowed === null || allowed.has(agent)
|
||||
|
||||
@@ -85,7 +85,12 @@ export async function installManagedHooks(options?: {
|
||||
return { installers: 0, errors: 0 }
|
||||
}
|
||||
const home = homedir()
|
||||
const grokHomeDir = await resolveRelayGrokHome(home, options?.signal)
|
||||
// Why: an empty allowlist installs nothing (the request only carries retired-agent
|
||||
// cleanup), so skip the login-shell GROK_HOME probe that install would need.
|
||||
const grokHomeDir =
|
||||
options?.agents?.length === 0
|
||||
? defaultGrokHome(home)
|
||||
: await resolveRelayGrokHome(home, options?.signal)
|
||||
options?.signal?.throwIfAborted()
|
||||
const hostIdentity = scopeManagedHookHostIdentity(
|
||||
await readManagedHookHostIdentity(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { removeRetiredGeminiManagedHooksRemote } from './retired-gemini-hook-cleanup'
|
||||
|
||||
export type RemoteManagedHookInstallOptions = {
|
||||
/** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime
|
||||
@@ -80,6 +81,12 @@ export async function installRemoteManagedAgentHooks(
|
||||
remoteHome: string,
|
||||
options?: RemoteManagedHookInstallOptions
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
// Why: strip retired Gemini CLI hooks on remote homes the same way as local.
|
||||
try {
|
||||
await removeRetiredGeminiManagedHooksRemote(sftp, remoteHome)
|
||||
} catch (error) {
|
||||
console.warn('[agent-hooks] Failed to clean retired remote Gemini managed hooks:', error)
|
||||
}
|
||||
// Why: omit/empty allowlist must never mean "install every agent" — that
|
||||
// recreates config homes for CLIs the user never installed (issue #11641).
|
||||
const allowedAgents = new Set(options?.agents ?? [])
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = (await vi.importActual('os')) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { removeRetiredGeminiManagedHooksLocal } from './retired-gemini-hook-cleanup'
|
||||
|
||||
describe('removeRetiredGeminiManagedHooksLocal', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-gemini-hook-cleanup-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes managed gemini-hook commands and deletes the managed script', () => {
|
||||
const settingsDir = join(homeDir, '.gemini')
|
||||
const hooksDir = join(homeDir, '.orca', 'agent-hooks')
|
||||
mkdirSync(settingsDir, { recursive: true })
|
||||
mkdirSync(hooksDir, { recursive: true })
|
||||
const scriptPath = join(hooksDir, 'gemini-hook.sh')
|
||||
writeFileSync(scriptPath, '#!/bin/sh\nprintf "{}\\n"\n', 'utf8')
|
||||
writeFileSync(
|
||||
join(settingsDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
BeforeAgent: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `/bin/sh '${scriptPath}'`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }]
|
||||
}
|
||||
],
|
||||
AfterTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `if [ -f '${scriptPath}' ]; then /bin/sh '${scriptPath}'; fi`
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
theme: 'dark'
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(settingsDir, 'settings.json'), 'utf8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
theme: string
|
||||
}
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(settings.hooks.AfterTool).toBeUndefined()
|
||||
expect(settings.hooks.BeforeAgent).toEqual([
|
||||
{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }
|
||||
])
|
||||
expect(existsSync(scriptPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes the Windows script variants the command matcher also strips', () => {
|
||||
const hooksDir = join(homeDir, '.orca', 'agent-hooks')
|
||||
mkdirSync(hooksDir, { recursive: true })
|
||||
for (const fileName of ['gemini-hook.sh', 'gemini-hook.cmd', 'gemini-hook.ps1']) {
|
||||
writeFileSync(join(hooksDir, fileName), 'noop', 'utf8')
|
||||
}
|
||||
writeFileSync(join(hooksDir, 'claude-hook.ps1'), 'keep', 'utf8')
|
||||
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
|
||||
expect(existsSync(join(hooksDir, 'gemini-hook.sh'))).toBe(false)
|
||||
expect(existsSync(join(hooksDir, 'gemini-hook.cmd'))).toBe(false)
|
||||
expect(existsSync(join(hooksDir, 'gemini-hook.ps1'))).toBe(false)
|
||||
expect(existsSync(join(hooksDir, 'claude-hook.ps1'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op when no managed gemini hooks are present', () => {
|
||||
const settingsDir = join(homeDir, '.gemini')
|
||||
mkdirSync(settingsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(settingsDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
BeforeAgent: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(settingsDir, 'settings.json'), 'utf8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
}
|
||||
expect(settings.hooks).toEqual({
|
||||
BeforeAgent: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not touch Antigravity hooks under ~/.gemini/config', () => {
|
||||
const antigravityDir = join(homeDir, '.gemini', 'config')
|
||||
mkdirSync(antigravityDir, { recursive: true })
|
||||
const antigravityHooks = {
|
||||
hooks: {
|
||||
PreToolUse: [{ command: '/bin/sh antigravity-hook.sh' }]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(antigravityDir, 'hooks.json'), JSON.stringify(antigravityHooks), 'utf8')
|
||||
|
||||
removeRetiredGeminiManagedHooksLocal()
|
||||
|
||||
expect(JSON.parse(readFileSync(join(antigravityDir, 'hooks.json'), 'utf8'))).toEqual(
|
||||
antigravityHooks
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
// Why: Gemini CLI was removed, but managed hook entries remain in
|
||||
// ~/.gemini/settings.json and ~/.orca/agent-hooks/gemini-hook.* and still POST
|
||||
// to /hook/gemini (404). Antigravity intentionally lives under ~/.gemini too —
|
||||
// only strip commands that match the managed gemini-hook script name.
|
||||
|
||||
import { unlinkSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
writeHooksJson,
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { readHooksJsonRemote, writeHooksJsonRemote } from './installer-utils-remote'
|
||||
|
||||
// Why: installs emitted .sh (POSIX) or .cmd/.ps1 (Windows); sweep every variant.
|
||||
const SCRIPT_FILES = ['gemini-hook.sh', 'gemini-hook.cmd', 'gemini-hook.ps1'] as const
|
||||
const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh')
|
||||
|
||||
/** Returns the rewritten config, or null when the file holds no managed Gemini hooks. */
|
||||
function stripManagedGeminiHooks(config: HooksConfig | null): HooksConfig | null {
|
||||
const hooks = config?.hooks
|
||||
if (!hooks || typeof hooks !== 'object') {
|
||||
return null
|
||||
}
|
||||
const nextHooks = { ...hooks }
|
||||
let changed = false
|
||||
for (const [eventName, definitions] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === definitions.length) {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
return changed ? { ...config, hooks: nextHooks } : null
|
||||
}
|
||||
|
||||
/** Idempotent local upgrade cleanup for retired Gemini CLI managed hooks. */
|
||||
export function removeRetiredGeminiManagedHooksLocal(): void {
|
||||
const configPath = join(homedir(), '.gemini', 'settings.json')
|
||||
const cleaned = stripManagedGeminiHooks(readHooksJson(configPath))
|
||||
if (cleaned) {
|
||||
writeHooksJson(configPath, cleaned)
|
||||
}
|
||||
for (const fileName of SCRIPT_FILES) {
|
||||
try {
|
||||
unlinkSync(getSharedManagedScriptPath(fileName))
|
||||
} catch {
|
||||
// Missing file or permission error; settings cleanup already stops the POSTs.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent remote (SSH) cleanup mirroring local remove for Gemini CLI hooks. */
|
||||
export async function removeRetiredGeminiManagedHooksRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string
|
||||
): Promise<void> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const configPath = `${home}/.gemini/settings.json`
|
||||
try {
|
||||
const cleaned = stripManagedGeminiHooks(await readHooksJsonRemote(sftp, configPath))
|
||||
if (cleaned) {
|
||||
await writeHooksJsonRemote(sftp, configPath, cleaned)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: do not block remote hook install for other agents.
|
||||
}
|
||||
for (const fileName of SCRIPT_FILES) {
|
||||
// Missing file or permission errors are non-fatal for upgrade cleanup.
|
||||
await new Promise<void>((resolve) => {
|
||||
sftp.unlink(`${home}/.orca/agent-hooks/${fileName}`, () => resolve())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { removeRetiredGeminiManagedHooksRemote } from './retired-gemini-hook-cleanup'
|
||||
import {
|
||||
buildManagedHookDetectionCommands,
|
||||
detectedManagedHookAgents,
|
||||
@@ -27,6 +28,18 @@ export async function installWslGuestHooks(options: {
|
||||
warn: (message: string) => void
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, settings, warn } = options
|
||||
const sftp = createWslHookSftpAdapter(mux)
|
||||
// Why: retired-agent cleanup must not depend on detecting a *supported* agent —
|
||||
// a guest with only the retired CLI would keep posting to a dead hook endpoint.
|
||||
try {
|
||||
await removeRetiredGeminiManagedHooksRemote(sftp, guestHome)
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[agent-hooks] WSL retired Gemini hook cleanup for '${distro}' failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
let agents
|
||||
try {
|
||||
const detected = (await mux.request('preflight.detectAgents', {
|
||||
@@ -44,7 +57,7 @@ export async function installWslGuestHooks(options: {
|
||||
if (agents.length === 0) {
|
||||
return
|
||||
}
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
const results = await installHooks(sftp, guestHome, {
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
agents
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// bridge (including a full run of the unchanged remote hook installers), and
|
||||
// the per-distro relay manager state machine with fault injection.
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayDispatcher } from '../../relay/dispatcher'
|
||||
import { registerWslHookFsHandlers } from '../../relay/wsl-hook-fs-bridge'
|
||||
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter'
|
||||
import { createWslHookSftpAdapter, installWslGuestHooks } from './wsl-hook-fs-adapter'
|
||||
import {
|
||||
installRemoteManagedAgentHooks,
|
||||
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
@@ -123,6 +123,41 @@ describe.skipIf(process.platform === 'win32')(
|
||||
expect(outside).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('strips retired Gemini hooks even when agent detection never succeeds', async () => {
|
||||
// Detection has no handler in this harness, so it fails like a guest with
|
||||
// none of the supported CLIs — cleanup must still have run by then.
|
||||
mkdirSync(join(home, '.gemini'), { recursive: true })
|
||||
mkdirSync(join(home, '.orca', 'agent-hooks'), { recursive: true })
|
||||
const scriptPath = join(home, '.orca', 'agent-hooks', 'gemini-hook.sh')
|
||||
writeFileSync(scriptPath, 'noop', 'utf8')
|
||||
writeFileSync(
|
||||
join(home, '.gemini', 'settings.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
BeforeAgent: [{ hooks: [{ type: 'command', command: `/bin/sh '${scriptPath}'` }] }]
|
||||
},
|
||||
theme: 'dark'
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
const installHooks = vi.fn()
|
||||
|
||||
await installWslGuestHooks({
|
||||
mux: harness.mux,
|
||||
guestHome: home,
|
||||
distro: 'Ubuntu',
|
||||
installHooks: installHooks as never,
|
||||
settings: null,
|
||||
warn: () => {}
|
||||
})
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(home, '.gemini', 'settings.json'), 'utf8'))
|
||||
expect(settings.hooks).toEqual({})
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(existsSync(scriptPath)).toBe(false)
|
||||
expect(installHooks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the unchanged remote managed hook installers against a WSL guest home', async () => {
|
||||
const adapter = createWslHookSftpAdapter(harness.mux)
|
||||
const results = await installRemoteManagedAgentHooks(adapter, home, {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult,
|
||||
AutomationRun,
|
||||
AutomationRunOutputSnapshot
|
||||
} from '../../shared/automations-types'
|
||||
import {
|
||||
didAutomationPrecheckPass,
|
||||
formatAutomationPrecheckFailure
|
||||
} from '../../shared/automation-precheck'
|
||||
import type { Store } from '../persistence'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
|
||||
const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024
|
||||
@@ -71,3 +78,73 @@ export function createHeadlessAutomationOutputSnapshotBuffer(): {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serve-mode run dispatch: precheck, launch through the dispatcher, then persist
|
||||
* the launch target and its (possibly late) completion. */
|
||||
export async function requestHeadlessAutomationDispatch(args: {
|
||||
store: Pick<Store, 'updateAutomationRun'>
|
||||
dispatch: HeadlessAutomationDispatcher
|
||||
automation: Automation
|
||||
run: AutomationRun
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
runPrecheck: () => Promise<AutomationPrecheckResult | null>
|
||||
markDispatchResult: (result: AutomationDispatchResult) => Promise<AutomationRun>
|
||||
}): Promise<AutomationRun> {
|
||||
const { store, dispatch, automation, run, target, markDispatchResult } = args
|
||||
const precheckResult =
|
||||
run.trigger === 'scheduled' && automation.precheck ? await args.runPrecheck() : null
|
||||
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
|
||||
return store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_precheck',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: formatAutomationPrecheckFailure(precheckResult)
|
||||
})
|
||||
}
|
||||
try {
|
||||
const launch = await dispatch({ automation, run, target })
|
||||
const launchRunTarget = {
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
terminalPaneKey: launch.terminalPaneKey ?? null,
|
||||
terminalPtyId: launch.terminalPtyId ?? null
|
||||
}
|
||||
const updated = store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
...launchRunTarget,
|
||||
error: null
|
||||
})
|
||||
if (launch.completion) {
|
||||
void launch.completion
|
||||
.then((completion) =>
|
||||
markDispatchResult({
|
||||
runId: run.id,
|
||||
status: completion.status,
|
||||
...launchRunTarget,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
...launchRunTarget,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
return updated
|
||||
} catch (error) {
|
||||
return store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('headless automation workspace create args', () => {
|
||||
it('stamps automation provenance for serve-mode new-per-run workspaces', () => {
|
||||
const args = buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation,
|
||||
agentId: 'codex',
|
||||
run: {
|
||||
id: 'run-1',
|
||||
title: 'Nightly review run',
|
||||
@@ -93,6 +94,7 @@ describe('headless automation workspace create args', () => {
|
||||
it('falls back to skip for legacy automations without a saved setup decision', () => {
|
||||
const args = buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation: { ...automation, setupDecision: undefined },
|
||||
agentId: 'codex',
|
||||
run: {
|
||||
id: 'run-1',
|
||||
title: 'Nightly review run',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import { buildAutomationWorkspaceProvenance } from '../../shared/automation-workspace-provenance'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
type HeadlessAutomationRunForWorkspace = Pick<AutomationRun, 'id' | 'title' | 'scheduledFor'>
|
||||
@@ -23,11 +24,14 @@ export function buildHeadlessAutomationWorkspaceName(
|
||||
|
||||
export function buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation,
|
||||
agentId,
|
||||
run,
|
||||
repo,
|
||||
createdAt = Date.now()
|
||||
}: {
|
||||
automation: Automation
|
||||
/** Passed separately: `automation.agentId` is null once its agent is retired. */
|
||||
agentId: TuiAgent
|
||||
run: HeadlessAutomationRunForWorkspace
|
||||
repo: Repo
|
||||
createdAt?: number
|
||||
@@ -38,8 +42,8 @@ export function buildHeadlessAutomationWorktreeCreateArgs({
|
||||
baseBranch: automation.baseBranch ?? undefined,
|
||||
setupDecision: automation.setupDecision ?? 'skip',
|
||||
activate: false,
|
||||
createdWithAgent: automation.agentId,
|
||||
startupAgent: automation.agentId,
|
||||
createdWithAgent: agentId,
|
||||
startupAgent: agentId,
|
||||
startupPrompt: automation.prompt,
|
||||
telemetrySource: 'unknown',
|
||||
automationProvenance: buildAutomationWorkspaceProvenance(automation, run, repo, createdAt)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { AutomationService } from './service'
|
||||
import { AUTOMATION_MISSING_AGENT_MESSAGE, type Automation } from '../../shared/automations-types'
|
||||
|
||||
const testState = { dir: '' }
|
||||
|
||||
@@ -26,6 +27,12 @@ async function createStore() {
|
||||
return new Store()
|
||||
}
|
||||
|
||||
/** Mirrors the load-time retired-agent cleanup, which nulls agentId in the profile. */
|
||||
function clearPersistedAgentId(store: Awaited<ReturnType<typeof createStore>>, id: string): void {
|
||||
const state = (store as unknown as { state: { automations: Automation[] } }).state
|
||||
state.automations.find((entry) => entry.id === id)!.agentId = null
|
||||
}
|
||||
|
||||
const makeRepo = (overrides: Partial<Repo> = {}): Repo => ({
|
||||
id: 'r1',
|
||||
path: '/repo',
|
||||
@@ -121,6 +128,57 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips dispatch for an automation whose retired agent was cleared', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Retired agent check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
clearPersistedAgentId(store, automation.id)
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({ isDestroyed: () => false, send } as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toBe(AUTOMATION_MISSING_AGENT_MESSAGE)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an agentless automation paused when a client tries to re-enable it', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Retired agent check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
clearPersistedAgentId(store, automation.id)
|
||||
|
||||
expect(store.updateAutomation(automation.id, { enabled: true }).enabled).toBe(false)
|
||||
expect(
|
||||
store.updateAutomation(automation.id, { agentId: 'codex', enabled: true })
|
||||
).toMatchObject({ agentId: 'codex', enabled: true })
|
||||
})
|
||||
|
||||
it('skips dispatch when the selected project host setup is gone', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
AUTOMATION_MISSING_AGENT_MESSAGE,
|
||||
isFinalAutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationDispatchRequest,
|
||||
@@ -13,12 +14,12 @@ import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import { runAutomationPrecheck } from './precheck-runner'
|
||||
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
|
||||
import { collectAutomationRunUsage } from './run-usage-collection'
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens'
|
||||
import {
|
||||
didAutomationPrecheckPass,
|
||||
formatAutomationPrecheckFailure
|
||||
} from '../../shared/automation-precheck'
|
||||
requestHeadlessAutomationDispatch,
|
||||
type HeadlessAutomationDispatcher
|
||||
} from './headless-dispatch'
|
||||
import { clearAutomationDispatchTokens, createAutomationDispatchToken } from './dispatch-tokens'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -211,6 +212,16 @@ export class AutomationService {
|
||||
automation: Automation,
|
||||
run: AutomationRun
|
||||
): Promise<AutomationRun> {
|
||||
// Why: a retired agent leaves agentId null (see retired-agent-settings-cleanup).
|
||||
// Fail the run here with an actionable reason instead of at agent launch.
|
||||
if (!isTuiAgent(automation.agentId)) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: AUTOMATION_MISSING_AGENT_MESSAGE
|
||||
})
|
||||
}
|
||||
const target = resolveAutomationRunTarget(this.store, automation, {
|
||||
allowRemoteHostScheduling: this.allowRemoteHostScheduling
|
||||
})
|
||||
@@ -254,63 +265,14 @@ export class AutomationService {
|
||||
run: AutomationRun,
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
): Promise<AutomationRun> {
|
||||
const precheckResult =
|
||||
run.trigger === 'scheduled' && automation.precheck
|
||||
? await this.runPrecheck(automation.id, run.id)
|
||||
: null
|
||||
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_precheck',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: formatAutomationPrecheckFailure(precheckResult)
|
||||
})
|
||||
}
|
||||
try {
|
||||
const launch = await this.headlessDispatcher!({ automation, run, target })
|
||||
const launchRunTarget = {
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
terminalPaneKey: launch.terminalPaneKey ?? null,
|
||||
terminalPtyId: launch.terminalPtyId ?? null
|
||||
}
|
||||
const updated = this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
...launchRunTarget,
|
||||
error: null
|
||||
})
|
||||
if (launch.completion) {
|
||||
void launch.completion
|
||||
.then((completion) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: completion.status,
|
||||
...launchRunTarget,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
...launchRunTarget,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
return updated
|
||||
} catch (error) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
return await requestHeadlessAutomationDispatch({
|
||||
store: this.store,
|
||||
dispatch: this.headlessDispatcher!,
|
||||
automation,
|
||||
run,
|
||||
target,
|
||||
runPrecheck: () => this.runPrecheck(automation.id, run.id),
|
||||
markDispatchResult: (result) => this.markDispatchResult(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -279,6 +279,8 @@ import { setUnreadDockBadgeCount } from './dock/unread-badge'
|
||||
import { AutomationService } from './automations/service'
|
||||
import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch'
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
|
||||
import { AUTOMATION_MISSING_AGENT_MESSAGE } from '../shared/automations-types'
|
||||
import { isTuiAgent } from '../shared/tui-agent-config'
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode'
|
||||
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
||||
@@ -2567,6 +2569,12 @@ void app.whenReady().then(async () => {
|
||||
allowRemoteHostScheduling: isServeMode,
|
||||
headlessDispatcher: isServeMode
|
||||
? async ({ automation, run, target }) => {
|
||||
// Why: the service already skips agentless runs; this keeps the launch
|
||||
// paths below narrowed to a real agent instead of guarding each one.
|
||||
const agentId = automation.agentId
|
||||
if (!isTuiAgent(agentId)) {
|
||||
throw new Error(AUTOMATION_MISSING_AGENT_MESSAGE)
|
||||
}
|
||||
const terminalSnapshotLimit = 2_000
|
||||
let terminalHandle: string
|
||||
let terminalSessionId: string | null = null
|
||||
@@ -2579,6 +2587,7 @@ void app.whenReady().then(async () => {
|
||||
const created = await runtimeService.createManagedWorktree({
|
||||
...buildHeadlessAutomationWorktreeCreateArgs({
|
||||
automation,
|
||||
agentId,
|
||||
run,
|
||||
repo: target.repo
|
||||
})
|
||||
@@ -2602,7 +2611,7 @@ void app.whenReady().then(async () => {
|
||||
const terminal = await runtimeService.launchAgentTerminal(
|
||||
`id:${automation.workspaceId}`,
|
||||
{
|
||||
agent: automation.agentId,
|
||||
agent: agentId,
|
||||
prompt: automation.prompt,
|
||||
title: run.title
|
||||
}
|
||||
|
||||
@@ -69,9 +69,6 @@ vi.mock('../openclaude/hook-service', () => ({
|
||||
vi.mock('../codex/hook-service', () => ({
|
||||
codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../gemini/hook-service', () => ({
|
||||
geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../antigravity/hook-service', () => ({
|
||||
antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) }
|
||||
}))
|
||||
|
||||
@@ -5374,9 +5374,14 @@ export class Store {
|
||||
const dtstart = updates.dtstart ?? current.dtstart
|
||||
const scheduleChanged = updates.rrule !== undefined || updates.dtstart !== undefined
|
||||
const workspaceMode = updates.workspaceMode ?? current.workspaceMode
|
||||
const agentId = updates.agentId ?? current.agentId
|
||||
const updated: Automation = {
|
||||
...current,
|
||||
...updates,
|
||||
agentId,
|
||||
// Why: an automation whose agent was retired has no agent to dispatch to,
|
||||
// so it stays paused until the user picks a new one.
|
||||
enabled: agentId !== null && (updates.enabled ?? current.enabled),
|
||||
name:
|
||||
updates.name !== undefined ? updates.name.trim() || 'Untitled automation' : current.name,
|
||||
precheck: Object.hasOwn(updates, 'precheck')
|
||||
|
||||
@@ -27,12 +27,11 @@ describe('cleanRetiredAgentReferences', () => {
|
||||
const state = profile({ settings: { defaultTuiAgent: 'gemini' } })
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(true)
|
||||
expect(state.settings.defaultTuiAgent).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves blank as an explicit shell-only preference', () => {
|
||||
const state = profile({ settings: { defaultTuiAgent: 'blank' } })
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(false)
|
||||
expect(state.settings.defaultTuiAgent).toBe('blank')
|
||||
// 'blank' is an explicit shell-only preference, not a retired agent.
|
||||
const blank = profile({ settings: { defaultTuiAgent: 'blank' } })
|
||||
expect(cleanRetiredAgentReferences(blank)).toBe(false)
|
||||
expect(blank.settings.defaultTuiAgent).toBe('blank')
|
||||
})
|
||||
|
||||
it('drops retired keys from agent-keyed launch settings', () => {
|
||||
@@ -84,12 +83,19 @@ describe('cleanRetiredAgentReferences', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the legacy commitMessageAi agent', () => {
|
||||
it('clears the legacy commitMessageAi agent without switching the feature off', () => {
|
||||
const state = profile({
|
||||
settings: { commitMessageAi: { agentId: 'gemini', selectedModelByAgent: { gemini: 'x' } } }
|
||||
settings: {
|
||||
commitMessageAi: { enabled: true, agentId: 'gemini', selectedModelByAgent: { gemini: 'x' } }
|
||||
}
|
||||
})
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(true)
|
||||
expect(state.settings.commitMessageAi).toEqual({ agentId: null, selectedModelByAgent: {} })
|
||||
// A cleared agent falls back to the default here, unlike automations.
|
||||
expect(state.settings.commitMessageAi).toEqual({
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {}
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the removed Gemini CLI OAuth toggle', () => {
|
||||
@@ -118,6 +124,44 @@ describe('cleanRetiredAgentReferences', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('disables and clears automations that still target a retired agent', () => {
|
||||
const state = profile({
|
||||
automations: [
|
||||
{ id: 'a1', name: 'Gemini nightly', agentId: 'gemini', enabled: true },
|
||||
{ id: 'a2', name: 'Claude hourly', agentId: 'claude', enabled: true }
|
||||
]
|
||||
})
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(true)
|
||||
expect(state.automations).toEqual([
|
||||
{ id: 'a1', name: 'Gemini nightly', agentId: null, enabled: false },
|
||||
{ id: 'a2', name: 'Claude hourly', agentId: 'claude', enabled: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('is sticky for already-disabled retired automations that still hold the agent id', () => {
|
||||
const state = profile({
|
||||
automations: [{ id: 'a1', agentId: 'gemini', enabled: false }]
|
||||
})
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(true)
|
||||
expect(state.automations).toEqual([{ id: 'a1', agentId: null, enabled: false }])
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a retired createdWithAgent so reopening cannot seed a missing agent', () => {
|
||||
const state = profile({
|
||||
worktreeMeta: {
|
||||
wt1: { createdWithAgent: 'gemini', isPinned: true },
|
||||
wt2: { createdWithAgent: 'codex' }
|
||||
},
|
||||
folderWorkspaces: [{ id: 'fw1', createdWithAgent: 'gemini' }]
|
||||
})
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(true)
|
||||
expect(state.worktreeMeta.wt1).toEqual({ isPinned: true })
|
||||
expect(state.worktreeMeta.wt2.createdWithAgent).toBe('codex')
|
||||
expect(state.folderWorkspaces[0]).toEqual({ id: 'fw1' })
|
||||
expect(cleanRetiredAgentReferences(state)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a profile missing every optional collection', () => {
|
||||
expect(cleanRetiredAgentReferences({} as PersistedState)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,158 +1,87 @@
|
||||
// Why: a removed agent id outlives its code inside saved profiles. Most readers
|
||||
// guard with isTuiAgent(), but `defaultTuiAgent` preselects the launch target
|
||||
// verbatim and Source Control AI copies its `agentId` into every action recipe
|
||||
// without validating it — both would point at an agent that no longer exists.
|
||||
// Scrub the profile once at the load boundary so no downstream reader has to.
|
||||
// verbatim, Source Control AI copies its `agentId` into every action recipe,
|
||||
// and automations dispatch with a raw agentId — all would point at an agent
|
||||
// that no longer exists. Scrub the profile once at the load boundary so no
|
||||
// downstream reader has to.
|
||||
|
||||
import type { PersistedState } from '../shared/persisted-state-types'
|
||||
|
||||
/** Agent ids Orca no longer ships. Keep an entry until profiles predating its removal are gone. */
|
||||
const RETIRED_TUI_AGENT_IDS: readonly string[] = ['gemini']
|
||||
const RETIRED_AGENTS: readonly unknown[] = ['gemini']
|
||||
|
||||
/** Status-bar providers Orca no longer publishes usage for. */
|
||||
const RETIRED_STATUS_BAR_ITEM_IDS: readonly string[] = ['gemini', 'antigravity']
|
||||
const RETIRED_STATUS_BAR_ITEMS: readonly unknown[] = ['gemini', 'antigravity']
|
||||
|
||||
/** GlobalSettings keys owned solely by a retired agent. */
|
||||
const RETIRED_SETTINGS_KEYS: readonly string[] = ['geminiCliOAuthEnabled']
|
||||
const RETIRED_KEYS: readonly string[] = ['geminiCliOAuthEnabled']
|
||||
|
||||
type MutableRecord = Record<string, unknown>
|
||||
/** Single-agent fields. null reads as "fall back to the default" everywhere. */
|
||||
const AGENT_ID_KEYS: readonly string[] = ['agentId', 'defaultTuiAgent']
|
||||
|
||||
function asRecord(value: unknown): MutableRecord | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as MutableRecord)
|
||||
: null
|
||||
// Why: named explicitly rather than detected, so a repo, host, or worktree
|
||||
// literally called "gemini" is never mistaken for an agent-keyed map.
|
||||
const AGENT_KEYED_MAPS: readonly string[] = [
|
||||
'agentCmdOverrides',
|
||||
'agentDefaultArgs',
|
||||
'agentDefaultEnv',
|
||||
'selectedModelByAgent',
|
||||
'discoveredModelsByAgent'
|
||||
]
|
||||
const AGENT_KEYED_MAPS_BY_HOST: readonly string[] = [
|
||||
'selectedModelByAgentByHost',
|
||||
'discoveredModelsByAgentByHost'
|
||||
]
|
||||
|
||||
type Rec = Record<string, unknown>
|
||||
|
||||
function asRecord(value: unknown): Rec | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Rec) : null
|
||||
}
|
||||
|
||||
function isRetiredAgent(value: unknown): boolean {
|
||||
return typeof value === 'string' && RETIRED_TUI_AGENT_IDS.includes(value)
|
||||
}
|
||||
|
||||
/** Drops retired agent ids used as keys of an agent-keyed record. */
|
||||
function dropRetiredAgentKeys(value: unknown): boolean {
|
||||
function dropRetiredKeys(value: unknown): void {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return false
|
||||
for (const key of Object.keys(record ?? {})) {
|
||||
if (RETIRED_AGENTS.includes(key)) {
|
||||
delete record![key]
|
||||
}
|
||||
}
|
||||
let changed = false
|
||||
for (const key of Object.keys(record)) {
|
||||
if (isRetiredAgent(key)) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One depth-first pass over the profile. Every shape that stores an agent id
|
||||
* does it under one of the key names above, at some nesting depth that differs
|
||||
* per shape (settings, per-repo overrides, action recipes, automations), so
|
||||
* matching on the key rather than the path covers them all.
|
||||
*/
|
||||
function scrub(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(scrub)
|
||||
return
|
||||
}
|
||||
const record = asRecord(node)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (RETIRED_KEYS.includes(key)) {
|
||||
delete record[key]
|
||||
changed = true
|
||||
} else if (AGENT_ID_KEYS.includes(key) && RETIRED_AGENTS.includes(value)) {
|
||||
record[key] = null
|
||||
} else if (key === 'createdWithAgent' && RETIRED_AGENTS.includes(value)) {
|
||||
delete record[key]
|
||||
} else if (key === 'disabledTuiAgents' && Array.isArray(value)) {
|
||||
record[key] = value.filter((agent) => !RETIRED_AGENTS.includes(agent))
|
||||
} else if (key === 'statusBarItems' && Array.isArray(value)) {
|
||||
record[key] = value.filter((item) => !RETIRED_STATUS_BAR_ITEMS.includes(item))
|
||||
} else if (AGENT_KEYED_MAPS.includes(key)) {
|
||||
dropRetiredKeys(value)
|
||||
} else if (AGENT_KEYED_MAPS_BY_HOST.includes(key)) {
|
||||
Object.values(asRecord(value) ?? {}).forEach(dropRetiredKeys)
|
||||
} else {
|
||||
scrub(value)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Same, one level deeper: host key -> agent-keyed record. */
|
||||
function dropRetiredAgentKeysByHost(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
for (const perHost of Object.values(record)) {
|
||||
changed = dropRetiredAgentKeys(perHost) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Clears a retired `agentId` to null, which every reader treats as "fall back to the default". */
|
||||
function clearRetiredAgentId(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
if (!record || !isRetiredAgent(record.agentId)) {
|
||||
return false
|
||||
}
|
||||
record.agentId = null
|
||||
return true
|
||||
}
|
||||
|
||||
function cleanModelSelections(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
let changed = dropRetiredAgentKeys(record.selectedModelByAgent)
|
||||
changed = dropRetiredAgentKeysByHost(record.selectedModelByAgentByHost) || changed
|
||||
changed = dropRetiredAgentKeys(record.discoveredModelsByAgent) || changed
|
||||
changed = dropRetiredAgentKeysByHost(record.discoveredModelsByAgentByHost) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
function cleanActionRecipes(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
for (const recipe of Object.values(record)) {
|
||||
changed = clearRetiredAgentId(recipe) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Covers both GlobalSettings.sourceControlAi and the per-repo override shape. */
|
||||
function cleanSourceControlAi(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
let changed = clearRetiredAgentId(record)
|
||||
changed = cleanModelSelections(record) || changed
|
||||
changed = cleanActionRecipes(record.actions) || changed
|
||||
changed = cleanActionRecipes(record.launchActionDefaults) || changed
|
||||
changed = cleanActionRecipes(record.actionOverrides) || changed
|
||||
const modelOverrides = asRecord(record.modelOverridesByOperation)
|
||||
for (const override of Object.values(modelOverrides ?? {})) {
|
||||
changed = cleanModelSelections(override) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function cleanSettings(value: unknown): boolean {
|
||||
const settings = asRecord(value)
|
||||
if (!settings) {
|
||||
return false
|
||||
}
|
||||
let changed = false
|
||||
for (const key of RETIRED_SETTINGS_KEYS) {
|
||||
if (key in settings) {
|
||||
delete settings[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (isRetiredAgent(settings.defaultTuiAgent)) {
|
||||
settings.defaultTuiAgent = null
|
||||
changed = true
|
||||
}
|
||||
if (Array.isArray(settings.disabledTuiAgents)) {
|
||||
const kept = settings.disabledTuiAgents.filter((agent) => !isRetiredAgent(agent))
|
||||
if (kept.length !== settings.disabledTuiAgents.length) {
|
||||
settings.disabledTuiAgents = kept
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
changed = dropRetiredAgentKeys(settings.agentCmdOverrides) || changed
|
||||
changed = dropRetiredAgentKeys(settings.agentDefaultArgs) || changed
|
||||
changed = dropRetiredAgentKeys(settings.agentDefaultEnv) || changed
|
||||
changed = clearRetiredAgentId(settings.commitMessageAi) || changed
|
||||
changed = cleanModelSelections(settings.commitMessageAi) || changed
|
||||
changed = cleanSourceControlAi(settings.sourceControlAi) || changed
|
||||
return changed
|
||||
}
|
||||
|
||||
function cleanUi(value: unknown): boolean {
|
||||
const ui = asRecord(value)
|
||||
if (!ui || !Array.isArray(ui.statusBarItems)) {
|
||||
return false
|
||||
}
|
||||
const kept = ui.statusBarItems.filter(
|
||||
(item) => typeof item !== 'string' || !RETIRED_STATUS_BAR_ITEM_IDS.includes(item)
|
||||
)
|
||||
if (kept.length === ui.statusBarItems.length) {
|
||||
return false
|
||||
}
|
||||
ui.statusBarItems = kept
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,13 +89,16 @@ function cleanUi(value: unknown): boolean {
|
||||
* can flag the profile for a re-save.
|
||||
*/
|
||||
export function cleanRetiredAgentReferences(state: PersistedState): boolean {
|
||||
let changed = cleanSettings(state.settings)
|
||||
changed = cleanUi(state.ui) || changed
|
||||
for (const repo of state.repos ?? []) {
|
||||
changed = cleanSourceControlAi(repo?.sourceControlAi) || changed
|
||||
const before = JSON.stringify(state)
|
||||
// Why: automations dispatch without the isTuiAgent() guard most readers apply,
|
||||
// so clearing the id alone would let them run on whatever agent is default.
|
||||
// Scoped to automations on purpose — commitMessageAi also has an `enabled`
|
||||
// flag, but there a cleared agent must not switch the whole feature off.
|
||||
for (const automation of state.automations ?? []) {
|
||||
if (automation && RETIRED_AGENTS.includes(automation.agentId)) {
|
||||
automation.enabled = false
|
||||
}
|
||||
}
|
||||
for (const setup of state.projectHostSetups ?? []) {
|
||||
changed = cleanSourceControlAi(setup?.sourceControlAi) || changed
|
||||
}
|
||||
return changed
|
||||
scrub(state)
|
||||
return JSON.stringify(state) !== before
|
||||
}
|
||||
|
||||
@@ -116,12 +116,6 @@ describe('orchestration worker launch preferences', () => {
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('does not expose deprecated Gemini model selection to worker-start', () => {
|
||||
expect(() =>
|
||||
resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' })
|
||||
).toThrow('does not support launch-time model selection')
|
||||
})
|
||||
|
||||
it('rejects preferences when reusing an existing terminal', () => {
|
||||
expect(() =>
|
||||
assertWorkerLaunchPreferencesCreateTerminal({
|
||||
|
||||
@@ -11,15 +11,31 @@ describe('worktree RPC schemas', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid startup agent values', () => {
|
||||
it('strips a retired startup agent together with its prompt', () => {
|
||||
// A mixed-version client can still send startupAgent:'gemini' plus a prompt.
|
||||
// Stripping the agent alone left the prompt orphaned and failed the create.
|
||||
const parsed = WorktreeCreate.safeParse({
|
||||
repo: 'repo-1',
|
||||
name: 'agent-startup',
|
||||
startupAgent: 'wat',
|
||||
startupAgent: 'gemini',
|
||||
startupPrompt: 'hi'
|
||||
})
|
||||
|
||||
expect(parsed.success).toBe(false)
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.data?.startupAgent).toBeUndefined()
|
||||
expect(parsed.data?.startupPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a supported startup agent and its prompt', () => {
|
||||
const parsed = WorktreeCreate.parse({
|
||||
repo: 'repo-1',
|
||||
name: 'agent-startup',
|
||||
startupAgent: 'claude',
|
||||
startupPrompt: 'hi'
|
||||
})
|
||||
|
||||
expect(parsed.startupAgent).toBe('claude')
|
||||
expect(parsed.startupPrompt).toBe('hi')
|
||||
})
|
||||
|
||||
it('rejects startup prompts without startup agents', () => {
|
||||
|
||||
@@ -15,16 +15,31 @@ import { TaskSourceContextSchema } from '../../../../shared/task-source-context-
|
||||
import { WorkspaceLinkedItemSchema } from '../../../../shared/workspace-linked-item-schema'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from '../../../../shared/workspace-linked-item-source-context'
|
||||
|
||||
// Why: mixed client/host versions can still send retired ids (e.g. gemini).
|
||||
// Strip to undefined like createdWithAgent so worktree-create is not rejected.
|
||||
const OptionalTuiAgent = z
|
||||
.unknown()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value !== undefined && !isTuiAgent(value)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Unknown TUI agent' })
|
||||
}
|
||||
})
|
||||
.transform((value): TuiAgent | undefined => (isTuiAgent(value) ? value : undefined))
|
||||
.optional()
|
||||
|
||||
/**
|
||||
* Why: `startupPrompt` only travels with a `startupAgent`, so a stripped retired
|
||||
* agent must take its prompt with it. Otherwise the leftover prompt trips the
|
||||
* "startupPrompt requires startupAgent" refinement below and the create fails
|
||||
* instead of degrading to a plain shell.
|
||||
*/
|
||||
function dropRetiredStartupAgentPair(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
const params = value as Record<string, unknown>
|
||||
if (params.startupAgent === undefined || isTuiAgent(params.startupAgent)) {
|
||||
return value
|
||||
}
|
||||
const { startupAgent: _startupAgent, startupPrompt: _startupPrompt, ...rest } = params
|
||||
return rest
|
||||
}
|
||||
|
||||
const AutomationWorkspaceProvenanceRequest = z.object({
|
||||
automationId: z.string(),
|
||||
automationRunId: z.string(),
|
||||
@@ -97,7 +112,7 @@ function assertLinkedWorkItemSourceContextMatch(
|
||||
}
|
||||
}
|
||||
|
||||
export const WorktreeCreate = z
|
||||
const WorktreeCreateParams = z
|
||||
.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
@@ -212,6 +227,8 @@ export const WorktreeCreate = z
|
||||
}
|
||||
})
|
||||
|
||||
export const WorktreeCreate = z.preprocess(dropRetiredStartupAgentPair, WorktreeCreateParams)
|
||||
|
||||
export const WorktreePrefetchCreateBase = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
|
||||
@@ -128,4 +128,30 @@ describe('SshRelaySession managed hooks', () => {
|
||||
muxRequestMock.mock.invocationCallOrder[managedIndex]
|
||||
)
|
||||
})
|
||||
|
||||
it('still requests managed hooks with no detected agents so retired hooks get cleaned', async () => {
|
||||
muxRequestMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'preflight.detectAgents') {
|
||||
return { agents: [] }
|
||||
}
|
||||
return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD
|
||||
? { installers: 0, errors: 0 }
|
||||
: { ok: true }
|
||||
})
|
||||
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const connection = {
|
||||
sftp: vi.fn(),
|
||||
getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
|
||||
} as unknown as SshConnection
|
||||
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
|
||||
|
||||
await session.establish(connection)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, {
|
||||
hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||
agents: []
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1292,7 +1292,10 @@ export class SshRelaySession {
|
||||
commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux')
|
||||
})) as { agents?: unknown }
|
||||
const agents = detectedManagedHookAgents(detected?.agents)
|
||||
if (agents.length === 0 || (shouldContinue && !shouldContinue())) {
|
||||
// Why: an empty agent list still goes to the host — the request also runs
|
||||
// retired-agent hook cleanup, which a host with none of the supported CLIs
|
||||
// installed would otherwise never get.
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
return
|
||||
}
|
||||
const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Pencil, Pause, Play, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
|
||||
import { AgentIcon } from '@/lib/agent-catalog'
|
||||
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
|
||||
import { formatAutomationSchedule } from '../../../../shared/automation-schedules'
|
||||
import { formatAutomationPrecheckTimeout } from '../../../../shared/automation-precheck'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './automation-usage-model'
|
||||
import type { AutomationTargetAvailability } from './automation-target-availability'
|
||||
import { getAutomationSourceDisplay } from './automation-source-display'
|
||||
import { getAgentLabel } from './automation-draft-model'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type AutomationDetailProps = {
|
||||
@@ -125,8 +126,7 @@ export function AutomationDetail({
|
||||
: usageSummary.unavailableRuns > 0
|
||||
? 'Unavailable'
|
||||
: 'No runs'
|
||||
const agentLabel =
|
||||
getAgentCatalog().find((agent) => agent.id === automation.agentId)?.label ?? automation.agentId
|
||||
const agentLabel = getAgentLabel(automation.agentId)
|
||||
const runLocationLabel =
|
||||
automation.workspaceMode === 'new_per_run'
|
||||
? (automation.baseBranch ?? projectDefaultBaseRef ?? 'Project default')
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import { AUTOMATION_MISSING_AGENT_MESSAGE } from '../../../../shared/automations-types'
|
||||
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
|
||||
import {
|
||||
getLocalExecutionHostLabel,
|
||||
@@ -1123,7 +1124,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const nextDraft: AutomationDraft = {
|
||||
name: latest.name,
|
||||
prompt: latest.prompt,
|
||||
agentId: latest.agentId,
|
||||
// Preselect the default agent when a retired-agent cleanup cleared this one.
|
||||
agentId: latest.agentId ?? defaultAgent,
|
||||
projectId: getAutomationRunRepoId(latest),
|
||||
workspaceMode: latest.workspaceMode,
|
||||
workspaceId: latest.workspaceId ?? '',
|
||||
@@ -1514,6 +1516,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const toggleAutomation = async (automation: Automation): Promise<void> => {
|
||||
// Why: resuming without an agent would only schedule runs that fail at dispatch.
|
||||
if (!automation.enabled && !automation.agentId) {
|
||||
toast.error(AUTOMATION_MISSING_AGENT_MESSAGE)
|
||||
return
|
||||
}
|
||||
await updateAutomationForTarget(
|
||||
automation,
|
||||
{ enabled: !automation.enabled },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAgentCatalog } from '@/lib/agent-catalog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AutomationPrecheck } from '../../../../shared/automations-types'
|
||||
import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
@@ -47,6 +48,10 @@ export function buildHermesCronSchedule(draft: AutomationDraft): string {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAgentLabel(agentId: string): string {
|
||||
export function getAgentLabel(agentId: string | null): string {
|
||||
if (!agentId) {
|
||||
// A retired-agent cleanup cleared it; the user has to pick a replacement.
|
||||
return translate('auto.components.automations.automation.draft.model.a0f61bd7c9', 'No agent')
|
||||
}
|
||||
return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('automation host client', () => {
|
||||
name: automation.name,
|
||||
prompt: automation.prompt,
|
||||
precheck: null,
|
||||
agentId: automation.agentId,
|
||||
agentId: 'codex',
|
||||
runContext: automation.runContext,
|
||||
projectId: automation.projectId,
|
||||
workspaceMode: automation.workspaceMode,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import type { AutomationTargetAvailability } from './automation-target-availability'
|
||||
|
||||
function unavailable(
|
||||
reason: Exclude<AutomationTargetAvailability['reason'], 'available'>,
|
||||
message: string
|
||||
): AutomationTargetAvailability {
|
||||
return { canRunNow: false, reason, message }
|
||||
}
|
||||
|
||||
/** Blocks a manual run whose saved task-source host cannot serve it right now. */
|
||||
export function getAutomationSourceAvailability(
|
||||
sourceContext: TaskSourceContext | null | undefined,
|
||||
sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined
|
||||
): AutomationTargetAvailability | null {
|
||||
if (!sourceContext) {
|
||||
return null
|
||||
}
|
||||
const availability = sourceHostAvailability?.find(
|
||||
(entry) => entry.hostId === sourceContext.hostId
|
||||
)
|
||||
if (!availability) {
|
||||
return null
|
||||
}
|
||||
const providerLabel = getAutomationSourceProviderLabel(sourceContext.provider)
|
||||
switch (availability.reason) {
|
||||
case undefined:
|
||||
break
|
||||
case 'missing-provider-auth':
|
||||
return unavailable(
|
||||
'source-auth-needed',
|
||||
`Connect the saved ${providerLabel} source account before running manually.`
|
||||
)
|
||||
case 'unavailable-source-tool':
|
||||
return unavailable(
|
||||
'source-tool-unavailable',
|
||||
`Install or configure the ${providerLabel} source tool before running manually.`
|
||||
)
|
||||
case 'unsupported-provider':
|
||||
case 'missing-task-source-capability':
|
||||
return unavailable(
|
||||
'source-provider-unsupported',
|
||||
`The saved ${providerLabel} source is not supported on this automation host.`
|
||||
)
|
||||
case 'checking-task-source-capability':
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`Checking the saved ${providerLabel} source host before running manually.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
availability.health === 'disconnected' ||
|
||||
availability.health === 'blocked' ||
|
||||
availability.health === 'error' ||
|
||||
availability.status === 'disconnected' ||
|
||||
availability.status === 'auth-failed' ||
|
||||
availability.status === 'reconnection-failed' ||
|
||||
availability.status === 'error'
|
||||
) {
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`Reconnect the saved ${providerLabel} source host before running manually.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
availability.health === 'connecting' ||
|
||||
availability.status === 'connecting' ||
|
||||
availability.status === 'deploying-relay' ||
|
||||
availability.status === 'reconnecting'
|
||||
) {
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`The saved ${providerLabel} source host is still connecting.`
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider']): string {
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return 'GitHub'
|
||||
case 'gitlab':
|
||||
return 'GitLab'
|
||||
case 'linear':
|
||||
return 'Linear'
|
||||
case 'jira':
|
||||
return 'Jira'
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
import {
|
||||
AUTOMATION_MISSING_AGENT_MESSAGE,
|
||||
type Automation
|
||||
} from '../../../../shared/automations-types'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
describeRuntimeCompatBlock,
|
||||
@@ -10,12 +13,12 @@ import {
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { AutomationHostTarget } from './automation-host-client'
|
||||
import type { SshConnectionState } from '../../../../shared/ssh-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import type { RuntimeStatus } from '../../../../shared/runtime-types'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
|
||||
import { getAutomationSourceAvailability } from './automation-source-availability'
|
||||
|
||||
export type AutomationTargetAvailability =
|
||||
| {
|
||||
@@ -26,6 +29,7 @@ export type AutomationTargetAvailability =
|
||||
| {
|
||||
canRunNow: false
|
||||
reason:
|
||||
| 'missing-agent'
|
||||
| 'missing-project'
|
||||
| 'missing-project-host-setup'
|
||||
| 'project-host-setup-not-ready'
|
||||
@@ -69,6 +73,9 @@ export function getAutomationTargetAvailability({
|
||||
automationHostTarget,
|
||||
sourceHostAvailability
|
||||
}: AutomationTargetAvailabilityArgs): AutomationTargetAvailability {
|
||||
if (!automation.agentId) {
|
||||
return unavailable('missing-agent', AUTOMATION_MISSING_AGENT_MESSAGE)
|
||||
}
|
||||
if (!repo) {
|
||||
return unavailable('missing-project', 'The target project is no longer available.')
|
||||
}
|
||||
@@ -184,86 +191,6 @@ function repoHostMatchesRunContext(
|
||||
return targetHostId !== null && getRepoExecutionHostId(repo) === targetHostId
|
||||
}
|
||||
|
||||
function getAutomationSourceAvailability(
|
||||
sourceContext: TaskSourceContext | null | undefined,
|
||||
sourceHostAvailability: readonly TaskSourceHostAvailability[] | undefined
|
||||
): AutomationTargetAvailability | null {
|
||||
if (!sourceContext) {
|
||||
return null
|
||||
}
|
||||
const availability = sourceHostAvailability?.find(
|
||||
(entry) => entry.hostId === sourceContext.hostId
|
||||
)
|
||||
if (!availability) {
|
||||
return null
|
||||
}
|
||||
const providerLabel = getAutomationSourceProviderLabel(sourceContext.provider)
|
||||
switch (availability.reason) {
|
||||
case undefined:
|
||||
break
|
||||
case 'missing-provider-auth':
|
||||
return unavailable(
|
||||
'source-auth-needed',
|
||||
`Connect the saved ${providerLabel} source account before running manually.`
|
||||
)
|
||||
case 'unavailable-source-tool':
|
||||
return unavailable(
|
||||
'source-tool-unavailable',
|
||||
`Install or configure the ${providerLabel} source tool before running manually.`
|
||||
)
|
||||
case 'unsupported-provider':
|
||||
case 'missing-task-source-capability':
|
||||
return unavailable(
|
||||
'source-provider-unsupported',
|
||||
`The saved ${providerLabel} source is not supported on this automation host.`
|
||||
)
|
||||
case 'checking-task-source-capability':
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`Checking the saved ${providerLabel} source host before running manually.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
availability.health === 'disconnected' ||
|
||||
availability.health === 'blocked' ||
|
||||
availability.health === 'error' ||
|
||||
availability.status === 'disconnected' ||
|
||||
availability.status === 'auth-failed' ||
|
||||
availability.status === 'reconnection-failed' ||
|
||||
availability.status === 'error'
|
||||
) {
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`Reconnect the saved ${providerLabel} source host before running manually.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
availability.health === 'connecting' ||
|
||||
availability.status === 'connecting' ||
|
||||
availability.status === 'deploying-relay' ||
|
||||
availability.status === 'reconnecting'
|
||||
) {
|
||||
return unavailable(
|
||||
'source-host-unavailable',
|
||||
`The saved ${providerLabel} source host is still connecting.`
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider']): string {
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return 'GitHub'
|
||||
case 'gitlab':
|
||||
return 'GitLab'
|
||||
case 'linear':
|
||||
return 'Linear'
|
||||
case 'jira':
|
||||
return 'Jira'
|
||||
}
|
||||
}
|
||||
|
||||
function getRuntimeAutomationAvailability(
|
||||
environmentId: string,
|
||||
runtimeStatusByEnvironmentId:
|
||||
|
||||
@@ -67,10 +67,11 @@ describe('buildDashboardWorktreeLaunchOptions', () => {
|
||||
disabledTuiAgents: ['claude']
|
||||
} as LaunchState['settings']
|
||||
}),
|
||||
[card(), card({ paneKey: 'pane-2', agentType: 'gemini' })]
|
||||
[card(), card({ paneKey: 'pane-2', agentType: 'droid' })]
|
||||
)
|
||||
|
||||
expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] })
|
||||
// droid is undetected locally but proven by a running pane, so it stays offerable.
|
||||
expect(options).toEqual({ 'worktree-1': ['codex', 'droid'] })
|
||||
})
|
||||
|
||||
it('publishes detected launch choices for workspaces without cards', () => {
|
||||
|
||||
-2
@@ -90,10 +90,8 @@ vi.mock('@/store', () => ({
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean
|
||||
return {
|
||||
...actual,
|
||||
isGeminiTerminalTitle: vi.fn((title: string) => isGeminiTerminalTitle(title)),
|
||||
isClaudeAgent: vi.fn(() => false),
|
||||
detectAgentStatusFromTitle: vi.fn(() => null)
|
||||
}
|
||||
|
||||
-2
@@ -111,10 +111,8 @@ vi.mock('@/store', () => ({
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean
|
||||
return {
|
||||
...actual,
|
||||
isGeminiTerminalTitle: vi.fn((title: string) => isGeminiTerminalTitle(title)),
|
||||
isClaudeAgent: vi.fn(() => false),
|
||||
detectAgentStatusFromTitle: vi.fn(() => null)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
|
||||
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
|
||||
import { launchWorktreeBackgroundTerminals } from '@/lib/launch-worktree-background-terminals'
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult
|
||||
import {
|
||||
AUTOMATION_MISSING_AGENT_MESSAGE,
|
||||
type AutomationDispatchResult,
|
||||
type AutomationPrecheckResult
|
||||
} from '../../../shared/automations-types'
|
||||
import { getAutomationRunRepoId } from '../../../shared/automation-run-identity'
|
||||
import {
|
||||
@@ -125,6 +126,20 @@ export function useAutomationDispatchEvents(): void {
|
||||
return ownership?.finalize() ?? false
|
||||
}
|
||||
|
||||
const agentId = automation.agentId
|
||||
if (!agentId) {
|
||||
// Why: the host already skips agentless runs; this covers an older host
|
||||
// dispatching an automation a retired-agent cleanup has since cleared.
|
||||
await markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
workspaceId: run.workspaceId,
|
||||
workspaceDisplayName: run.workspaceDisplayName ?? null,
|
||||
error: AUTOMATION_MISSING_AGENT_MESSAGE
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!repo) {
|
||||
await markDispatchResult({
|
||||
runId: run.id,
|
||||
@@ -499,7 +514,7 @@ export function useAutomationDispatchEvents(): void {
|
||||
if (automation.reuseSession) {
|
||||
const reusableSession = findReusableAutomationSession({
|
||||
automationId: automation.id,
|
||||
agentId: automation.agentId,
|
||||
agentId,
|
||||
worktreeId: worktree.id,
|
||||
currentRunId: run.id,
|
||||
runs: await window.api.automations.listRuns({ automationId: automation.id }),
|
||||
@@ -582,7 +597,7 @@ export function useAutomationDispatchEvents(): void {
|
||||
}
|
||||
}
|
||||
const result = await launchAgentBackgroundSession({
|
||||
agent: automation.agentId,
|
||||
agent: agentId,
|
||||
worktreeId: worktree.id,
|
||||
prompt: automation.prompt,
|
||||
launchSource: 'unknown',
|
||||
|
||||
@@ -14587,6 +14587,11 @@
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"draft": {
|
||||
"model": {
|
||||
"a0f61bd7c9": "No agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"external": {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { assertTuiAgent, TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import {
|
||||
@@ -45,6 +45,7 @@ export async function launchAgentBackgroundSession(
|
||||
args: LaunchAgentBackgroundSessionArgs
|
||||
): Promise<LaunchAgentBackgroundSessionResult | null> {
|
||||
const { agent, worktreeId, prompt, launchSource, title, onData, onExit, onAgentStatus } = args
|
||||
assertTuiAgent(agent)
|
||||
const store = useAppStore.getState()
|
||||
// Folder workspaces exist only in getKnownWorktreeById (#2989).
|
||||
const worktree = store.getKnownWorktreeById(worktreeId)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -18,6 +18,10 @@ export type AutomationRunStatus =
|
||||
| 'dispatch_failed'
|
||||
export type AutomationRunTrigger = 'scheduled' | 'manual'
|
||||
|
||||
/** Shown wherever an automation lost its agent to a retired-agent cleanup. */
|
||||
export const AUTOMATION_MISSING_AGENT_MESSAGE =
|
||||
'This automation used an agent Orca no longer ships. Edit it and pick a new agent.'
|
||||
|
||||
/** Statuses a run can never leave; only these are safe to evict from history. */
|
||||
export function isFinalAutomationRunStatus(status: AutomationRunStatus): boolean {
|
||||
return (
|
||||
@@ -93,7 +97,9 @@ export type Automation = {
|
||||
name: string
|
||||
prompt: string
|
||||
precheck: AutomationPrecheck | null
|
||||
agentId: TuiAgent
|
||||
/** null once the saved agent is retired from Orca; such an automation stays
|
||||
* paused and cannot dispatch until the user picks a new agent. */
|
||||
agentId: TuiAgent | null
|
||||
/** Why: runContext carries the logical project + host setup identity for
|
||||
* multi-host projects; projectId remains only as the legacy repo-id storage
|
||||
* field for pre-host-context automations.
|
||||
@@ -186,7 +192,6 @@ export type AutomationUpdateInput = Partial<
|
||||
| 'name'
|
||||
| 'prompt'
|
||||
| 'precheck'
|
||||
| 'agentId'
|
||||
| 'runContext'
|
||||
| 'sourceContext'
|
||||
| 'projectId'
|
||||
@@ -201,7 +206,10 @@ export type AutomationUpdateInput = Partial<
|
||||
| 'enabled'
|
||||
| 'missedRunGraceMinutes'
|
||||
>
|
||||
>
|
||||
> & {
|
||||
/** Non-null: an update may replace a retired agent but never clear one. */
|
||||
agentId?: TuiAgent
|
||||
}
|
||||
|
||||
export type AutomationDispatchRequest = {
|
||||
automation: Automation
|
||||
|
||||
@@ -328,6 +328,15 @@ export function isTuiAgent(value: unknown): value is TuiAgent {
|
||||
return typeof value === 'string' && Object.hasOwn(TUI_AGENT_CONFIG, value)
|
||||
}
|
||||
|
||||
/** Guards launch paths against ids that outlive a retired agent in saved state. */
|
||||
export function assertTuiAgent(value: unknown): asserts value is TuiAgent {
|
||||
if (!isTuiAgent(value)) {
|
||||
throw new Error(
|
||||
`Unknown or retired TUI agent: ${typeof value === 'string' ? value : String(value)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTuiAgentDetectCommands(config: TuiAgentConfig): string[] {
|
||||
return [config.detectCmd, ...(config.detectCmdAliases ?? [])]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Draft launches: seed an agent with unsent composer text instead of a prompt.
|
||||
import {
|
||||
clearEnvCommand,
|
||||
commandSeparator,
|
||||
quoteStartupArg,
|
||||
resolveStartupShell,
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
import { assertTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { SleepingAgentLaunchConfig } from './agent-session-resume'
|
||||
import type { StartupCommandDelivery } from './codex-startup-delivery'
|
||||
import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config'
|
||||
import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
|
||||
import { appliedSessionOptionProps } from './tui-agent-session-option-props'
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
launchConfig: SleepingAgentLaunchConfig
|
||||
env?: Record<string, string>
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
}
|
||||
|
||||
export function buildAgentDraftLaunchPlan(args: {
|
||||
agent: TuiAgent
|
||||
draft: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string> | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
/** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */
|
||||
isRemote?: boolean
|
||||
}): AgentDraftLaunchPlan | null {
|
||||
const { agent, draft, cmdOverrides, platform } = args
|
||||
assertTuiAgent(agent)
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const baseCommand = resolveAgentLaunchCommand({
|
||||
agent,
|
||||
cmdOverrides,
|
||||
platform,
|
||||
shell,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
if (!baseCommand.ok) {
|
||||
return null
|
||||
}
|
||||
const launchConfig = buildSleepingAgentLaunchConfig({
|
||||
...args,
|
||||
// Why: see the new-session path above — resume must not replay picker flags.
|
||||
agentCommand: baseCommand.commandWithoutSessionOptions
|
||||
})
|
||||
let plan: AgentDraftLaunchPlan | null = null
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, shell)
|
||||
plan = {
|
||||
agent,
|
||||
launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
launchConfig,
|
||||
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
|
||||
// Why: native draft flags carry user text on argv and must survive rc-file startup.
|
||||
...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}),
|
||||
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
|
||||
}
|
||||
} else if (config.draftPromptEnvVar) {
|
||||
const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell)
|
||||
plan = {
|
||||
agent,
|
||||
launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
launchConfig,
|
||||
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
|
||||
env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed }
|
||||
}
|
||||
}
|
||||
if (
|
||||
!plan ||
|
||||
!inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform })
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return plan
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
haveSameDisabledTuiAgents,
|
||||
isTuiAgentEnabled,
|
||||
normalizeDisabledTuiAgents,
|
||||
pickTuiAgent
|
||||
} from './tui-agent-selection'
|
||||
@@ -42,3 +43,16 @@ describe('haveSameDisabledTuiAgents', () => {
|
||||
expect(haveSameDisabledTuiAgents(['codex'], ['claude'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTuiAgentEnabled', () => {
|
||||
it('returns false for retired or unknown agent ids', () => {
|
||||
expect(isTuiAgentEnabled('gemini')).toBe(false)
|
||||
expect(isTuiAgentEnabled('not-an-agent')).toBe(false)
|
||||
expect(isTuiAgentEnabled(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when a supported agent is disabled', () => {
|
||||
expect(isTuiAgentEnabled('claude', ['claude'])).toBe(false)
|
||||
expect(isTuiAgentEnabled('claude', ['codex'])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,8 +85,10 @@ export function haveSameDisabledTuiAgents(left: unknown, right: unknown): boolea
|
||||
return leftSet.size === rightSet.size && [...leftSet].every((agent) => rightSet.has(agent))
|
||||
}
|
||||
|
||||
export function isTuiAgentEnabled(agent: TuiAgent, disabled?: Iterable<unknown> | null): boolean {
|
||||
return !normalizeDisabledTuiAgents(disabled).includes(agent)
|
||||
export function isTuiAgentEnabled(agent: unknown, disabled?: Iterable<unknown> | null): boolean {
|
||||
// Why: retired/unknown ids must never count as "enabled" — callers pass
|
||||
// persisted strings that outlive the TuiAgent union.
|
||||
return isTuiAgent(agent) && !normalizeDisabledTuiAgents(disabled).includes(agent)
|
||||
}
|
||||
|
||||
export function filterEnabledTuiAgents<T extends TuiAgent>(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
|
||||
/** Omits the key entirely when no picker options applied, keeping plans byte-stable. */
|
||||
export function appliedSessionOptionProps(values: Record<string, SessionOptionValue>): {
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
} {
|
||||
return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {}
|
||||
}
|
||||
@@ -39,6 +39,17 @@ describe('tui agent startup plans', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects retired agent ids before indexing TUI_AGENT_CONFIG', () => {
|
||||
expect(() =>
|
||||
buildAgentStartupPlan({
|
||||
agent: 'gemini' as never,
|
||||
prompt: 'hello',
|
||||
cmdOverrides: {},
|
||||
platform: 'linux'
|
||||
})
|
||||
).toThrow(/Unknown or retired TUI agent: gemini/)
|
||||
})
|
||||
|
||||
it('uses POSIX quoting when the target shell is Linux', () => {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'claude',
|
||||
|
||||
@@ -6,21 +6,19 @@ import {
|
||||
type SleepingAgentLaunchConfig
|
||||
} from './agent-session-resume'
|
||||
import {
|
||||
clearEnvCommand,
|
||||
commandSeparator,
|
||||
quoteStartupArg,
|
||||
resolveStartupShell,
|
||||
type AgentStartupShell
|
||||
} from './tui-agent-startup-shell'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import { assertTuiAgent, TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import type { StartupCommandDelivery } from './codex-startup-delivery'
|
||||
import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config'
|
||||
import { planHermesStartupQuery } from './hermes-startup-query'
|
||||
import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import type { SessionOptionValue } from './native-chat-session-options'
|
||||
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
|
||||
import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command'
|
||||
import { appliedSessionOptionProps } from './tui-agent-session-option-props'
|
||||
|
||||
export type AgentStartupPlan = {
|
||||
agent: TuiAgent
|
||||
@@ -37,10 +35,6 @@ export type AgentStartupPlan = {
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
}
|
||||
|
||||
function appliedSessionOptionProps(values: Record<string, SessionOptionValue>) {
|
||||
return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {}
|
||||
}
|
||||
|
||||
export function buildAgentStartupPlan(args: {
|
||||
agent: TuiAgent
|
||||
prompt: string
|
||||
@@ -57,6 +51,7 @@ export function buildAgentStartupPlan(args: {
|
||||
isRemote?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args
|
||||
assertTuiAgent(agent)
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const trimmedPrompt = prompt.trim()
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
@@ -199,6 +194,7 @@ export function buildAgentResumeStartupPlan(args: {
|
||||
/** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */
|
||||
isRemote?: boolean
|
||||
}): AgentStartupPlan | null {
|
||||
assertTuiAgent(args.agent)
|
||||
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
|
||||
if (!argv) {
|
||||
return null
|
||||
@@ -233,84 +229,7 @@ export function buildAgentResumeStartupPlan(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentDraftLaunchPlan = {
|
||||
agent: TuiAgent
|
||||
launchCommand: string
|
||||
expectedProcess: string
|
||||
launchConfig: SleepingAgentLaunchConfig
|
||||
env?: Record<string, string>
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
}
|
||||
|
||||
export function buildAgentDraftLaunchPlan(args: {
|
||||
agent: TuiAgent
|
||||
draft: string
|
||||
cmdOverrides: Partial<Record<TuiAgent, string>>
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
agentArgs?: string | null
|
||||
agentEnv?: Record<string, string> | null
|
||||
sessionOptions?: Record<string, SessionOptionValue>
|
||||
/** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */
|
||||
isRemote?: boolean
|
||||
}): AgentDraftLaunchPlan | null {
|
||||
const { agent, draft, cmdOverrides, platform } = args
|
||||
const shell = resolveStartupShell(platform, args.shell)
|
||||
const config = TUI_AGENT_CONFIG[agent]
|
||||
const trimmed = draft.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
const baseCommand = resolveAgentLaunchCommand({
|
||||
agent,
|
||||
cmdOverrides,
|
||||
platform,
|
||||
shell,
|
||||
agentArgs: args.agentArgs,
|
||||
sessionOptions: args.sessionOptions,
|
||||
isRemote: args.isRemote
|
||||
})
|
||||
if (!baseCommand.ok) {
|
||||
return null
|
||||
}
|
||||
const launchConfig = buildSleepingAgentLaunchConfig({
|
||||
...args,
|
||||
// Why: see the new-session path above — resume must not replay picker flags.
|
||||
agentCommand: baseCommand.commandWithoutSessionOptions
|
||||
})
|
||||
let plan: AgentDraftLaunchPlan | null = null
|
||||
if (config.draftPromptFlag) {
|
||||
const quoted = quoteStartupArg(trimmed, shell)
|
||||
plan = {
|
||||
agent,
|
||||
launchCommand: `${baseCommand.command} ${config.draftPromptFlag} ${quoted}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
launchConfig,
|
||||
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
|
||||
// Why: native draft flags carry user text on argv and must survive rc-file startup.
|
||||
...(agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}),
|
||||
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
|
||||
}
|
||||
} else if (config.draftPromptEnvVar) {
|
||||
const clearVar = clearEnvCommand(config.draftPromptEnvVar, shell)
|
||||
plan = {
|
||||
agent,
|
||||
launchCommand: `${baseCommand.command}${commandSeparator(shell)}${clearVar}`,
|
||||
expectedProcess: config.expectedProcess,
|
||||
launchConfig,
|
||||
...appliedSessionOptionProps(baseCommand.appliedSessionOptions),
|
||||
env: { ...args.agentEnv, [config.draftPromptEnvVar]: trimmed }
|
||||
}
|
||||
}
|
||||
if (
|
||||
!plan ||
|
||||
!inlineAgentDraftFitsPlatform({ command: plan.launchCommand, env: plan.env, platform })
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return plan
|
||||
}
|
||||
export { buildAgentDraftLaunchPlan, type AgentDraftLaunchPlan } from './tui-agent-draft-launch'
|
||||
|
||||
export { isShellProcess }
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user