diff --git a/CHANGELOG.md b/CHANGELOG.md index fc97691..464ebcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## 1.0.31 (2026-07-27) + +### New Features + +- **First-class Shell agents** — create and edit command-backed agents from the CLI, editor, and TUI. Shell agents run their configured command from the task workspace and map exit code `0` to success and non-zero exits to failure. +- **Simplified Shell setup** — selecting Shell asks only for the agent name and command, hides model, reasoning, role, skills, and team fields, and applies the automatic approval policy. +- **Command-aware TUI** — creation confirmations and agent details show the configured command, while Shell agent details prioritize the active task and command over AI-only model information. + +### Bug Fixes + +- **Reliable targeted run completion** — `orch run ` keeps its event listener until the requested run completes, preserving final output and returning a non-zero process exit code when the Shell command fails. +- **Immediate wizard confirmation** — text and textarea validation is checked against the current value when the user confirms, eliminating stale debounce errors without allowing invalid values through. +- **Safe adapter transitions** — switching an existing agent to Shell clears incompatible model and effort settings, requires a command, and defaults approval to automatic. + +### Refactoring + +- Centralized repeated Shell conditions and agent-creation status formatting without changing behavior. + +### Tests + +- Added service, CLI, adapter, wizard, command-bar, and TUI regression coverage for Shell creation, editing, validation, execution, success, and failure. +- Verified the built CLI in a real pseudo-terminal TUI session. +- Full suite: 2069 passed, 2 skipped. + ## 1.0.30 (2026-07-25) ### Bug Fixes diff --git a/landing/index.html b/landing/index.html index 610b909..5923fb7 100644 --- a/landing/index.html +++ b/landing/index.html @@ -2280,7 +2280,7 @@
- Open-source orchestration for zero-human companies · v1.0.28 + Open-source orchestration for zero-human companies · v1.0.31

Run multiple AI agents. Without babysitting any of them.

@@ -2337,7 +2337,7 @@

Run multiple AI agents. Without babysitting any of them.

Scroll
-
v1.0.28 — MIT
+
v1.0.31 — MIT
@@ -2995,7 +2995,7 @@

Community

diff --git a/package-lock.json b/package-lock.json index d518586..79e2de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@oxgeneral/orch", - "version": "1.0.30", + "version": "1.0.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@oxgeneral/orch", - "version": "1.0.30", + "version": "1.0.31", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2bed938..6737c19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@oxgeneral/orch", - "version": "1.0.30", + "version": "1.0.31", "description": "AI agent runtime — orchestrate Claude, Cursor, Codex & OpenCode as one team. Multi-agent task automation with state machine, auto-retry, inter-agent messaging, goals and teams. Zero-config CLI + programmatic API.", "type": "module", "engines": { diff --git a/readme.md b/readme.md index 67a4b64..6d149e9 100644 --- a/readme.md +++ b/readme.md @@ -333,6 +333,10 @@ Every department gets the same superpowers: state machine governance, retry, mes The `shell` adapter is the key: **if it runs in a terminal, it's an agent** — `npm test`, `python bot.py`, Semgrep, `curl`, CRM scripts, data pipelines. Any CLI tool gets state tracking, retry, and coordination for free. +```bash +orch agent add "Test Runner" --adapter shell --command "npm test" +``` +
diff --git a/src/application/agent-factory.ts b/src/application/agent-factory.ts index 5380c1f..e00c73a 100644 --- a/src/application/agent-factory.ts +++ b/src/application/agent-factory.ts @@ -7,6 +7,7 @@ import type { AgentShopTemplate } from '../domain/agent-shop.js'; import type { CreateAgentInput } from '../domain/agent.js'; +import { InvalidArgumentsError } from '../domain/errors.js'; import { resolveModel } from '../domain/model-tiers.js'; /** MCP skills use colon-separated names (e.g. `package:skill-name`). */ @@ -24,6 +25,12 @@ export function templateToAgentInput( template: AgentShopTemplate, adapter: string, ): CreateAgentInput { + if (adapter === 'shell') { + throw new InvalidArgumentsError( + 'Agent Shop templates require an AI adapter; create shell agents with --command', + ); + } + const model = resolveModel(adapter, template.tier); const skills = adapter === 'claude' ? template.skills diff --git a/src/application/agent-service.ts b/src/application/agent-service.ts index f3565ab..c1d8fc4 100644 --- a/src/application/agent-service.ts +++ b/src/application/agent-service.ts @@ -21,26 +21,35 @@ export class AgentService { ) {} async create(input: CreateAgentInput): Promise { - if (!input.name.trim()) { + const name = input.name.trim(); + if (!name) { throw new InvalidArgumentsError('Agent name is required'); } + const adapter = input.adapter?.trim() || this.config.defaults.agent.adapter; + const isShell = adapter === 'shell'; + const command = input.command?.trim() || undefined; + if (isShell && !command) { + throw new InvalidArgumentsError('Shell agents require a command'); + } + // Check for duplicate name - const existing = await this.agentStore.getByName(input.name); + const existing = await this.agentStore.getByName(name); if (existing) { - throw new InvalidArgumentsError(`Agent "${input.name}" already exists`); + throw new InvalidArgumentsError(`Agent "${name}" already exists`); } const agent: Agent = { id: `agt_${nanoid(7)}`, - name: input.name.trim(), - adapter: input.adapter || this.config.defaults.agent.adapter, + name, + adapter, role: input.role, config: { - command: input.command, - model: input.model, - effort: input.effort, - approval_policy: input.approval_policy ?? this.config.defaults.agent.approval_policy, + command, + model: isShell ? undefined : input.model, + effort: isShell ? undefined : input.effort, + approval_policy: input.approval_policy ?? + (isShell ? 'auto' : this.config.defaults.agent.approval_policy), max_turns: input.max_turns ?? this.config.defaults.agent.max_turns, timeout_ms: input.timeout_ms ?? this.config.defaults.agent.timeout_ms, stall_timeout_ms: input.stall_timeout_ms ?? this.config.defaults.agent.stall_timeout_ms, @@ -88,8 +97,20 @@ export class AgentService { await this.agentStore.delete(id); } - async update(id: string, fields: { name?: string; adapter?: string; role?: string; model?: string; effort?: Agent['config']['effort'] | ''; approval_policy?: Agent['config']['approval_policy'] }): Promise { + async update(id: string, fields: { name?: string; adapter?: string; role?: string; command?: string; model?: string; effort?: Agent['config']['effort'] | ''; approval_policy?: Agent['config']['approval_policy'] }): Promise { const agent = await this.get(id); + const wasShell = agent.adapter === 'shell'; + + const adapter = fields.adapter !== undefined ? fields.adapter.trim() : agent.adapter; + if (!adapter) throw new InvalidArgumentsError('Agent adapter cannot be empty'); + const enteringShell = adapter === 'shell' && !wasShell; + + const command = fields.command !== undefined + ? fields.command.trim() || undefined + : agent.config.command; + if (adapter === 'shell' && !command) { + throw new InvalidArgumentsError('Shell agents require a command'); + } if (fields.name !== undefined) { if (!fields.name.trim()) throw new InvalidArgumentsError('Agent name cannot be empty'); @@ -101,14 +122,21 @@ export class AgentService { agent.name = fields.name.trim(); } if (fields.adapter !== undefined) { - const adapter = fields.adapter.trim(); - if (!adapter) throw new InvalidArgumentsError('Agent adapter cannot be empty'); agent.adapter = adapter; } if (fields.role !== undefined) agent.role = fields.role || undefined; + if (fields.command !== undefined) agent.config.command = command; if (fields.model !== undefined) agent.config.model = fields.model || undefined; if (fields.effort !== undefined) agent.config.effort = fields.effort || undefined; - if (fields.approval_policy !== undefined) agent.config.approval_policy = fields.approval_policy; + if (enteringShell) { + agent.config.model = undefined; + agent.config.effort = undefined; + } + if (fields.approval_policy !== undefined) { + agent.config.approval_policy = fields.approval_policy; + } else if (enteringShell) { + agent.config.approval_policy = 'auto'; + } await this.agentStore.save(agent); return agent; diff --git a/src/bin/cli.ts b/src/bin/cli.ts index e63d9a4..f88afc8 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -42,7 +42,7 @@ const program = new Command(); program .name('orchestry') .description('Agents Organizations — CLI orchestrator for AI agents') - .version('1.0.30') + .version('1.0.31') .option('--json', 'Output as JSON') .option('--quiet', 'Minimal output (IDs only)') .option('--no-color', 'Disable colors') diff --git a/src/cli/commands/agent.ts b/src/cli/commands/agent.ts index 789f53f..2de4f19 100644 --- a/src/cli/commands/agent.ts +++ b/src/cli/commands/agent.ts @@ -42,10 +42,11 @@ export function registerAgentCommand(program: Command, container: LightContainer if (opts.edit) { const { openInEditor, agentToEditorContent, agentFromEditorContent } = await import('../editor.js'); - const initial = agentToEditorContent({ name, model: opts.model, role }); + const initial = agentToEditorContent({ name, command: opts.command, model: opts.model, role }); const edited = await openInEditor(initial); const parsed = agentFromEditorContent(edited); if (parsed.name) name = parsed.name; + if (parsed.command !== undefined) opts.command = parsed.command; if (parsed.model) opts.model = parsed.model; if (parsed.role) role = parsed.role; } @@ -161,11 +162,15 @@ export function registerAgentCommand(program: Command, container: LightContainer console.log(); const pairs: Array<[string, string]> = [ - ['Adapter', `${a.adapter}${a.config.model ? ` (${a.config.model})` : ''}`], + ['Adapter', `${a.adapter}${a.adapter !== 'shell' && a.config.model ? ` (${a.config.model})` : ''}`], ['Status', `${statusIcon(a.status)} ${a.status}`], - ['Effort', a.config.effort ?? 'default'], ['Policy', a.config.approval_policy ?? 'auto'], ]; + if (a.adapter === 'shell') { + pairs.splice(1, 0, ['Command', a.config.command ?? '—']); + } else { + pairs.splice(2, 0, ['Effort', a.config.effort ?? 'default']); + } if (a.current_task) pairs.push(['Task', a.current_task]); if (a.role) pairs.push(['Role', a.role]); if (a.config.skills?.length) pairs.push(['Skills', a.config.skills.join(', ')]); @@ -192,6 +197,7 @@ export function registerAgentCommand(program: Command, container: LightContainer const { openInEditor, agentToEditorContent, agentFromEditorContent } = await import('../editor.js'); const initial = agentToEditorContent({ name: a.name, + command: a.config.command, model: a.config.model, role: a.role, }); @@ -202,6 +208,7 @@ export function registerAgentCommand(program: Command, container: LightContainer const updated = await container.agentService.update(id, { name: parsed.name, role: parsed.role, + command: parsed.command, model: parsed.model, }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 01bb0ce..cafbf24 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -261,7 +261,10 @@ export function registerInitCommand(program: Command): void { return; } await runInit(opts); - console.log(` Next: ${dim('orch task add "Create backend agent" --assignee agt_creator')}`); + const next = opts.adapter === 'shell' + ? 'orch agent add "Test Runner" --adapter shell --command "npm test"' + : 'orch task add "Create backend agent" --assignee agt_creator'; + console.log(` Next: ${dim(next)}`); console.log(); }); } diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 07af149..6c81a6d 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -35,11 +35,19 @@ async function runSingle(container: Container, taskId: string): Promise { console.log(); console.log(` ${amber('orch')} · running ${taskId} "${task.title}"`); + let targetRunId: string | undefined; + let unsub = () => {}; + // Subscribe to events for live output - const unsub = container.eventBus.onAny((event) => { + unsub = container.eventBus.onAny((event) => { const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); switch (event.type) { + case 'agent:started': + if (event.taskId === taskId) { + targetRunId = event.runId; + } + break; case 'agent:output': console.log(` ${dim(time)} ${getIcon('agentAction')} ${typeof event.data === 'string' ? event.data.slice(0, 80) : ''}`); break; @@ -50,18 +58,29 @@ async function runSingle(container: Container, taskId: string): Promise { console.log(` ${dim(time)} ${getIcon('failed')} ${event.error}`); break; case 'agent:completed': + if (event.runId !== targetRunId) break; if (event.success) { printSuccess('Done'); } else { printError('Failed'); + process.exitCode = 1; } + unsub(); break; } }); try { await container.orchestrator.runTask(taskId); - } finally { + } catch (error) { + unsub(); + throw error; + } + + // A terminal/non-dispatchable task may not emit a run lifecycle. Normal + // dispatched runs keep the listener until their asynchronous collector emits + // agent:completed, so output and the final exit code are not lost. + if (!targetRunId) { unsub(); } diff --git a/src/cli/commands/tui.ts b/src/cli/commands/tui.ts index c84587e..6b3f83a 100644 --- a/src/cli/commands/tui.ts +++ b/src/cli/commands/tui.ts @@ -81,10 +81,11 @@ export function registerTuiCommand(program: Command, container: Container): void return container.stateStore.read(); }; - const onAddAgent = async (name: string, adapter?: string, opts?: { model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => { + const onAddAgent = async (name: string, adapter?: string, opts?: { command?: string; model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => { return container.agentService.create({ name, adapter: adapter ?? container.config.defaults.agent.adapter, + command: opts?.command || undefined, model: opts?.model || undefined, effort: (opts?.effort as import('../../domain/agent.js').ReasoningEffort) || undefined, role: opts?.role || undefined, @@ -113,7 +114,7 @@ export function registerTuiCommand(program: Command, container: Container): void return container.taskService.update(taskId, fields); }; - const onUpdateAgent = async (agentId: string, fields: { name?: string; adapter?: string; role?: string; model?: string; effort?: string; approval_policy?: string }) => { + const onUpdateAgent = async (agentId: string, fields: { name?: string; adapter?: string; role?: string; command?: string; model?: string; effort?: string; approval_policy?: string }) => { return container.agentService.update(agentId, { ...fields, effort: fields.effort as import('../../domain/agent.js').ReasoningEffort | '' | undefined, diff --git a/src/cli/editor.ts b/src/cli/editor.ts index 8b61bf7..a532be5 100644 --- a/src/cli/editor.ts +++ b/src/cli/editor.ts @@ -124,6 +124,7 @@ export function fromEditorContent(content: string): { export interface AgentEditorFields { name?: string; role?: string; + command?: string; model?: string; } @@ -137,6 +138,7 @@ export interface AgentEditorFields { * # Role description goes below the --- separator. * --- * name: My Agent + * command: * model: claude-sonnet-4-6 * --- * @@ -145,6 +147,7 @@ export interface AgentEditorFields { */ export function agentToEditorContent(fields: { name: string; + command?: string; model?: string; role?: string; }): string { @@ -154,6 +157,7 @@ export function agentToEditorContent(fields: { '# Role description goes below the second --- separator.', '---', `name: ${fields.name}`, + `command: ${fields.command ?? ''}`, `model: ${fields.model ?? ''}`, '---', '', @@ -194,6 +198,8 @@ export function agentFromEditorContent(content: string): AgentEditorFields { const value = kv[2] ?? ''; if (key === 'name') { result.name = value.trim(); + } else if (key === 'command') { + result.command = value.trim(); } else if (key === 'model') { result.model = value.trim(); } diff --git a/src/domain/default-agents.ts b/src/domain/default-agents.ts index 3aee9bb..51b408c 100644 --- a/src/domain/default-agents.ts +++ b/src/domain/default-agents.ts @@ -30,11 +30,14 @@ const AGENT_CREATOR_ROLE = `Agent architect — designs and creates AI agents fo 4) CREATE: \`orch agent add "" --adapter --model --skills "" --role "" --approval-policy auto\` + For shell agents, a command is mandatory: + \`orch agent add "" --adapter shell --command "npm test" --approval-policy auto\` + ## SKILL TYPES There are two types of skills: -**Library skills** — ORCH loads Markdown content and injects it into the agent's system prompt. Works with ALL adapters (claude, opencode, codex, pi, cursor, grok, antigravity, shell). Use plain names without colons: +**Library skills** — ORCH loads Markdown content and injects it into an AI agent's system prompt. Works with claude, opencode, codex, pi, cursor, grok, and antigravity. Shell agents are command-driven. Use plain names without colons: | Category | Skills | |----------|--------| @@ -60,7 +63,8 @@ You can mix both types: \`--skills "review,feature-dev:code-explorer,investigate ## ANTI-PATTERNS -- Never create agents without skills — they cannot be auto-matched to tasks. +- Never create AI agents without skills — they cannot be auto-matched to tasks. Shell agents are command-driven. +- Never create a shell agent without --command. - Never write generic roles like "helper" — be specific about actions and tools. - Never use opus for simple tasks — it is expensive; use sonnet or haiku. - Never assign more than 3-4 skills per agent — create specialized agents instead. @@ -74,6 +78,9 @@ After creation — \`orch context set agent- ""\`.`; * Adapter and model are resolved from the user's chosen default adapter. */ export function getDefaultAgents(adapter: string = 'claude'): Agent[] { + // Shell agents need an explicit command, so init cannot create a useful generic one. + if (adapter === 'shell') return []; + const model = resolveModel(adapter, 'balanced'); // MCP skills (colon-format) only work with Claude CLI const skills: string[] = adapter === 'claude' diff --git a/src/infrastructure/adapters/shell.ts b/src/infrastructure/adapters/shell.ts index 645246d..c41b8d1 100644 --- a/src/infrastructure/adapters/shell.ts +++ b/src/infrastructure/adapters/shell.ts @@ -59,6 +59,22 @@ export class ShellAdapter implements IAgentAdapter { const signal = params.signal; const processManager = this.processManager; + // Subscribe immediately after spawn. The process may finish while the + // orchestrator is still persisting/consuming its final output event. + // Resolving (rather than rejecting) keeps an early spawn error from + // becoming an unhandled promise rejection before the generator is read. + const exitPromise = new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + error?: Error; + }>((resolve) => { + proc.once('close', (code, exitSignal) => { + resolve({ code, signal: exitSignal }); + }); + proc.once('error', (error) => { + resolve({ code: proc.exitCode, signal: proc.signalCode, error }); + }); + }); async function* generateEvents(): AsyncGenerator { // Ring buffer with backpressure replaces Array.shift() polling @@ -76,61 +92,61 @@ export class ShellAdapter implements IAgentAdapter { } } - const stdoutPromise = (async () => { - if (!proc.stdout) return; - for await (const line of readLines(proc.stdout)) { - if (signal?.aborted) break; - await buffer.push({ - type: 'output', - timestamp: new Date().toISOString(), - data: line, - }); - } - })(); - - const stderrPromise = (async () => { - if (!proc.stderr) return; - for await (const line of readLines(proc.stderr)) { - if (signal?.aborted) break; - await buffer.push({ - type: 'error', - timestamp: new Date().toISOString(), - data: line, - errorKind: classifyAdapterError(line), - }); - } - })(); + try { + const stdoutPromise = (async () => { + if (!proc.stdout) return; + for await (const line of readLines(proc.stdout)) { + if (signal?.aborted) break; + await buffer.push({ + type: 'output', + timestamp: new Date().toISOString(), + data: line, + }); + } + })(); + + const stderrPromise = (async () => { + if (!proc.stderr) return; + for await (const line of readLines(proc.stderr)) { + if (signal?.aborted) break; + await buffer.push({ + type: 'error', + timestamp: new Date().toISOString(), + data: line, + errorKind: classifyAdapterError(line), + }); + } + })(); - // Close the buffer once both streams are drained (or on error) - void Promise.all([stdoutPromise, stderrPromise]).then( - () => buffer.close(), - () => buffer.close(), - ); + // Close the buffer once both streams are drained (or on error) + void Promise.all([stdoutPromise, stderrPromise]).then( + () => buffer.close(), + () => buffer.close(), + ); - // Yield events as they arrive — no polling, no busy-wait - yield* buffer; + // Yield events as they arrive — no polling, no busy-wait + yield* buffer; - // Clean up abort listener - if (signal && !signal.aborted) { - signal.removeEventListener('abort', onAbort); - } + const exit = await exitPromise; + if (signal?.aborted) return; - // Wait for process to exit - await new Promise((resolve, reject) => { - // If process already exited, resolve immediately - if (proc.exitCode !== null || proc.killed) { - resolve(); - return; + if (exit.error) { + throw Object.assign(exit.error, { + errorKind: classifyAdapterError(exit.error.message, exit.code ?? undefined), + }); } - proc.on('close', (code) => { - if (code === 0 || signal?.aborted) { - resolve(); - } else { - reject(new Error(`Shell command exited with code ${code}`)); - } - }); - proc.on('error', reject); - }); + + if (exit.code !== 0) { + const message = exit.code === null + ? `Shell command terminated by signal ${exit.signal ?? 'unknown'}` + : `Shell command exited with code ${exit.code}`; + throw Object.assign(new Error(message), { + errorKind: classifyAdapterError(message, exit.code ?? undefined), + }); + } + } finally { + signal?.removeEventListener('abort', onAbort); + } } return { pid, events: generateEvents() }; diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 39b9698..606260c 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -104,6 +104,13 @@ const MAX_TOASTS = 5; /** Statuses that allow R (run) action */ const RUNNABLE: Set = new Set(['todo', 'failed', 'cancelled']); +function createdAgentMessage(agent: Agent): string { + const commandHint = agent.adapter === 'shell' && agent.config.command + ? ` · ${capLine(agent.config.command, 60)}` + : ''; + return `\u2713 Created agent "${agent.name}" (${agent.id}, ${agent.adapter})${commandHint}`; +} + /** History entry returned by onLoadHistory */ export interface HistoryEntry { timestamp: string; @@ -135,13 +142,13 @@ export interface AppProps { // History (loaded progressively from disk on startup) onLoadHistory?: (onBatch: (entries: HistoryEntry[]) => void) => Promise; // New actions - onAddAgent?: (name: string, adapter?: string, opts?: { model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => Promise; + onAddAgent?: (name: string, adapter?: string, opts?: { command?: string; model?: string; effort?: string; role?: string; approval_policy?: string; skills?: string[] }) => Promise; onDeleteAgent?: (agentId: string) => Promise; onApproveTask?: (taskId: string) => Promise; onRejectTask?: (taskId: string, feedback?: string) => Promise; onDeleteTask?: (taskId: string) => Promise; onUpdateTask?: (taskId: string, fields: { title?: string; description?: string; priority?: number; attachments?: string[] }) => Promise; - onUpdateAgent?: (agentId: string, fields: { name?: string; adapter?: string; role?: string; model?: string; effort?: string; approval_policy?: string }) => Promise; + onUpdateAgent?: (agentId: string, fields: { name?: string; adapter?: string; role?: string; command?: string; model?: string; effort?: string; approval_policy?: string }) => Promise; onForceStopAgent?: (agentId: string) => Promise; onCreateTeam?: (input: CreateTeamInput) => Promise; onListTeams?: () => Promise; @@ -986,23 +993,32 @@ export function App({ }, [onLoadModelCatalog]); // Wizard launchers - const launchAgentWizard = useCallback(() => { + const launchAgentWizard = useCallback((defaults?: { name?: string; adapter?: string }) => { + const adapter = defaults?.adapter ?? defaultAdapter; + const steps = getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog, adapter).map((step) => { + if (step.id === 'name' && defaults?.name) return { ...step, defaultValue: defaults.name }; + return step; + }); setWizardConfig({ title: 'NEW AGENT', - steps: getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog), + steps, kind: 'agent', }); setInputMode('wizard'); - }, [liveAgents, modelCatalog]); + }, [defaultAdapter, liveAgents, modelCatalog]); const launchShopWizard = useCallback(() => { + if (defaultAdapter === 'shell') { + addMessage('Agent Shop requires an AI provider. Press N to create a Shell agent.', tuiColors.yellow); + return; + } setWizardConfig({ title: 'AGENT SHOP', steps: getShopWizardSteps(), kind: 'agent_shop', }); setInputMode('wizard'); - }, []); + }, [addMessage, defaultAdapter]); const launchTaskWizard = useCallback(() => { setPendingAttachments([]); @@ -1078,7 +1094,18 @@ export function App({ setWizardConfig((current) => { if (!current) return current; if (current.kind === 'agent') { - return { ...current, steps: getAgentWizardSteps(liveAgentsRef.current, liveTeamsRef.current, modelCatalog) }; + const adapter = current.steps.find((step) => step.id === 'adapter')?.defaultValue ?? defaultAdapter; + const name = current.steps.find((step) => step.id === 'name')?.defaultValue; + const steps = getAgentWizardSteps( + liveAgentsRef.current, + liveTeamsRef.current, + modelCatalog, + adapter, + ).map((step) => step.id === 'name' && name ? { ...step, defaultValue: name } : step); + return { + ...current, + steps, + }; } if (current.kind === 'edit_agent' && current.targetId) { const agent = liveAgentsRef.current.find((candidate) => candidate.id === current.targetId); @@ -1092,7 +1119,7 @@ export function App({ if (current.kind === 'agent_from_shop' && current.templateKey) { const template = getShopTemplateByKey(current.templateKey); if (template) { - const baseSteps = getAgentWizardSteps(liveAgentsRef.current, liveTeamsRef.current, modelCatalog); + const baseSteps = getAgentWizardSteps(liveAgentsRef.current, liveTeamsRef.current, modelCatalog, defaultAdapter); return { ...current, steps: applyShopTemplate(baseSteps, template, defaultAdapter) }; } } @@ -1121,7 +1148,7 @@ export function App({ const templateKey = values.shop_template; const template = templateKey ? getShopTemplateByKey(templateKey) : undefined; if (template) { - const baseSteps = getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog); + const baseSteps = getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog, defaultAdapter); const prefilledSteps = applyShopTemplate(baseSteps, template, defaultAdapter); setWizardConfig({ title: `NEW AGENT \u2014 ${template.name}`, @@ -1140,6 +1167,7 @@ export function App({ const input = agentWizardToInput(values, defaultAdapter); addMessage(`Creating agent "${input.name}"...`, tuiColors.amber); onAddAgent(input.name, input.adapter, { + command: input.command, model: input.model, effort: input.effort, role: input.role, @@ -1147,7 +1175,7 @@ export function App({ skills: input.skills, }).then( (agent) => { - addMessage(`\u2713 Created agent "${agent.name}" (${agent.id}, ${agent.adapter})`, tuiColors.green); + addMessage(createdAgentMessage(agent), tuiColors.green); // Auto-join team if selected in wizard if (input.team_id && onJoinTeam) { onJoinTeam(input.team_id, agent.id).then( @@ -1207,10 +1235,11 @@ export function App({ ); } else if (kind === 'edit_agent' && targetId && onUpdateAgent) { const fields = editAgentWizardToFields(values); - const newTeamId = fields.team_id ?? ''; const oldTeamId = liveTeams.find(t => t.members.some(m => m.agent_id === targetId))?.id ?? ''; + // A skipped Team step means "unchanged", not "leave the current team". + const newTeamId = values.team === undefined ? oldTeamId : fields.team_id ?? ''; addMessage(`Updating agent...`, tuiColors.amber); - onUpdateAgent(targetId, { name: fields.name, adapter: fields.adapter, role: fields.role, model: fields.model, effort: fields.effort }).then( + onUpdateAgent(targetId, { name: fields.name, adapter: fields.adapter, role: fields.role, command: fields.command, model: fields.model, effort: fields.effort }).then( (agent) => { addMessage(`\u2713 Updated agent "${agent.name}"`, tuiColors.green); // Handle team change @@ -1289,9 +1318,13 @@ export function App({ /** Handle template selection from inline suggestion list in agent wizard name step */ const handleSuggestionSelected = useCallback((templateKey: string) => { + if (defaultAdapter === 'shell') { + addMessage('Agent Shop requires an AI provider. Choose Shell and enter a command instead.', tuiColors.yellow); + return; + } const template = getShopTemplateByKey(templateKey); if (!template) return; - const baseSteps = getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog); + const baseSteps = getAgentWizardSteps(liveAgents, liveTeamsRef.current, modelCatalog, defaultAdapter); const prefilledSteps = applyShopTemplate(baseSteps, template, defaultAdapter); setWizardConfig({ title: `NEW AGENT \u2014 ${template.name}`, @@ -1300,7 +1333,7 @@ export function App({ templateKey: template.key, }); setInputMode('wizard'); - }, [liveAgents, modelCatalog, defaultAdapter]); + }, [addMessage, liveAgents, modelCatalog, defaultAdapter]); // Live event subscription — update activity feed AND refresh data useEffect(() => { @@ -1603,10 +1636,18 @@ export function App({ const name = parts[2]; if (!name) { launchAgentWizard(); return; } if (!onAddAgent) { addMessage('Agent creation not available', tuiColors.yellow); return; } - const adapter = parts[3]; + const adapter = parts[3] ?? defaultAdapter; + const command = adapter === 'shell' ? parts.slice(4).join(' ').trim() : ''; + if (adapter === 'shell' && !command) { + launchAgentWizard({ name, adapter: 'shell' }); + return; + } addMessage(`Creating agent "${name}"...`, tuiColors.amber); - onAddAgent(name, adapter).then( - (agent) => { addMessage(`\u2713 Created agent "${agent.name}" (${agent.id}, ${agent.adapter})`, tuiColors.green); refreshAll(); }, + onAddAgent(name, adapter, adapter === 'shell' ? { command, approval_policy: 'auto' } : undefined).then( + (agent) => { + addMessage(createdAgentMessage(agent), tuiColors.green); + refreshAll(); + }, (err) => addMessage(`Failed: ${errMsg(err)}`, tuiColors.red), ); } else if (sub === 'list') { @@ -1889,7 +1930,7 @@ export function App({ onCancelTask, onRetryTask, onAssignTask, onRunAll, onRunTask, onCreateTask, onDisableAgent, onEnableAgent, onAddAgent, onApproveTask, onRejectTask, onDeleteTask, onJoinTeam, onLeaveTeam, onDisbandTeam, onSetTeamLead, - onStartWatch, onStopWatch, addMessage, exit, refreshAll, launchTaskWizard, launchAgentWizard, launchTeamWizard, launchConfigWizard, changePalette]); + onStartWatch, onStopWatch, addMessage, exit, refreshAll, launchTaskWizard, launchAgentWizard, launchTeamWizard, launchConfigWizard, changePalette, defaultAdapter]); useInput((input, key) => { // ── Help overlay dismiss: any key closes it ── @@ -3791,6 +3832,7 @@ function AgentDetailPanel({ agent, height, state, taskTitleMap, teamName }: { const statusColor = getAgentStatusDetailColor(agent.status); const runningEntry = Object.values(state.running).find((e) => e.agent_id === agent.id); const taskTitle = agent.current_task ? taskTitleMap.get(agent.current_task) : undefined; + const isShell = agent.adapter === 'shell'; const col1Width = 24; @@ -3808,16 +3850,20 @@ function AgentDetailPanel({ agent, height, state, taskTitleMap, teamName }: { - {/* Row 2: model + task */} + {/* Row 2: model/task for AI agents; task/command for shell agents */} - model - {agent.config.model ?? '\u2014'} + {isShell ? ' task ' : ' model '} + + {isShell ? (taskTitle ?? '\u2014') : (agent.config.model ?? '\u2014')} + - task - - {taskTitle ?? '\u2014'} + {isShell ? ' command ' : ' task '} + + {isShell + ? (agent.config.command ?? '\u2014') + : (taskTitle ?? '\u2014')} diff --git a/src/tui/components/FormWizard.tsx b/src/tui/components/FormWizard.tsx index 2599b8b..f16926d 100644 --- a/src/tui/components/FormWizard.tsx +++ b/src/tui/components/FormWizard.tsx @@ -194,7 +194,12 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, // Inline validation state const [dirty, setDirty] = useState(false); // true after user types at least one character - const [validationError, setValidationError] = useState(null); + const [validationState, setValidationState] = useState<{ + error: string | null; + value: string | null; + }>({ error: null, value: null }); + const validationError = validationState.error; + const validatedValue = validationState.value; const [enterBlockHint, setEnterBlockHint] = useState(false); const debounceTimerRef = useRef | null>(null); const enterBlockTimerRef = useRef | null>(null); @@ -210,9 +215,12 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, // Debounced validation (300ms) const runValidation = useCallback((value: string, validateFn?: (v: string) => string | null) => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); - if (!validateFn) { setValidationError(null); return; } + if (!validateFn) { + setValidationState({ error: null, value }); + return; + } debounceTimerRef.current = setTimeout(() => { - setValidationError(validateFn(value)); + setValidationState({ error: validateFn(value), value }); }, 300); }, []); @@ -265,7 +273,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, setBrowsingSuggestions(false); setSuggestionIndex(0); setDirty(false); - setValidationError(null); + setValidationState({ error: null, value: null }); setEnterBlockHint(false); if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); @@ -344,7 +352,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, setBrowsingSuggestions(false); setSuggestionIndex(0); setDirty(false); - setValidationError(null); + setValidationState({ error: null, value: null }); setEnterBlockHint(false); if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); @@ -373,6 +381,16 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, } }; + const blockForValidationError = (error: string | null): boolean => { + if (error === null) return false; + setDirty(true); + setValidationState({ error, value: currentValue }); + setEnterBlockHint(true); + if (enterBlockTimerRef.current) clearTimeout(enterBlockTimerRef.current); + enterBlockTimerRef.current = setTimeout(() => setEnterBlockHint(false), 2000); + return true; + }; + useInput((input, key) => { if (!step) return; @@ -427,13 +445,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, if (key.return || key.tab) { const val = textInput.trim(); if (step.required && !val) { setDirty(true); return; } - if (validationError !== null) { - setDirty(true); - setEnterBlockHint(true); - if (enterBlockTimerRef.current) clearTimeout(enterBlockTimerRef.current); - enterBlockTimerRef.current = setTimeout(() => setEnterBlockHint(false), 2000); - return; - } + if (blockForValidationError(step.validate?.(val) ?? null)) return; goToNextStep(val); return; } @@ -464,13 +476,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, if ((key.return && (key.ctrl || key.meta)) || key.tab) { const val = taLines.join('\n').trim(); if (step.required && !val) { setDirty(true); return; } - if (validationError !== null) { - setDirty(true); - setEnterBlockHint(true); - if (enterBlockTimerRef.current) clearTimeout(enterBlockTimerRef.current); - enterBlockTimerRef.current = setTimeout(() => setEnterBlockHint(false), 2000); - return; - } + if (blockForValidationError(step.validate?.(val) ?? null)) return; goToNextStep(val); return; } @@ -665,17 +671,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, if (key.return || key.tab) { const selected = options[clampedSelectIndex]; if (selected) { - if (step.validate) { - const err = step.validate(selected.value); - if (err !== null) { - setDirty(true); - setValidationError(err); - setEnterBlockHint(true); - if (enterBlockTimerRef.current) clearTimeout(enterBlockTimerRef.current); - enterBlockTimerRef.current = setTimeout(() => setEnterBlockHint(false), 2000); - return; - } - } + if (blockForValidationError(step.validate?.(selected.value) ?? null)) return; goToNextStep(selected.value); } return; @@ -717,8 +713,12 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, if (!step) return null; - // Only show validation errors visually after user has interacted (dirty) - const visibleError = dirty ? validationError : null; + // Only render an error for the exact value that produced it. While the next + // debounced validation is pending, an error from the previous value is stale. + const validationIsCurrent = + (step.type !== 'text' && step.type !== 'textarea') || + validatedValue === currentValue; + const visibleError = dirty && validationIsCurrent ? validationError : null; const maxW = Math.max(20, width - 6); const progressText = `${currentStep + 1}/${totalSteps}`; diff --git a/src/tui/wizardConfigs.ts b/src/tui/wizardConfigs.ts index 1cec891..a8817ac 100644 --- a/src/tui/wizardConfigs.ts +++ b/src/tui/wizardConfigs.ts @@ -161,7 +161,7 @@ export function applyShopTemplate( case 'role': return { ...step, defaultValue: '__custom__' }; case 'role_custom': - return { ...step, defaultValue: template.role, skip: undefined }; + return { ...step, defaultValue: template.role }; case 'skills': { // MCP skills (colon-format) only work with Claude CLI — filter for other adapters const skills = defaultAdapter === 'claude' @@ -179,7 +179,12 @@ export function applyShopTemplate( // ── Agent creation wizard ── -export function getAgentWizardSteps(agents?: Agent[], teams?: Team[], modelCatalog?: ModelCatalog): WizardStep[] { +export function getAgentWizardSteps( + agents?: Agent[], + teams?: Team[], + modelCatalog?: ModelCatalog, + defaultAdapter: string = 'claude', +): WizardStep[] { const teamOptions = buildTeamOptions(teams); return [ @@ -205,6 +210,17 @@ export function getAgentWizardSteps(agents?: Agent[], teams?: Team[], modelCatal label: 'Provider', type: 'select', options: ADAPTERS, + defaultValue: defaultAdapter, + }, + { + id: 'command', + label: 'Command', + type: 'text', + placeholder: 'e.g. npm test or bash scripts/deploy.sh', + description: 'Runs from task workspace · exit 0 = done · non-zero = failed', + required: true, + skip: (vals) => vals.adapter !== 'shell', + validate: (v) => v.trim() ? null : 'Command is required for Shell', }, { id: 'model', @@ -214,6 +230,7 @@ export function getAgentWizardSteps(agents?: Agent[], teams?: Team[], modelCatal getSuggestions: (vals) => getModelOptions(vals.adapter, modelCatalog), suggestionMode: 'value', suggestionsLabel: 'available models', + skip: (vals) => vals.adapter === 'shell', }, { id: 'effort', @@ -227,19 +244,21 @@ export function getAgentWizardSteps(agents?: Agent[], teams?: Team[], modelCatal label: 'Role / specialization', type: 'select', options: ROLE_PRESETS, + skip: (vals) => vals.adapter === 'shell', }, { id: 'role_custom', label: 'Describe the role', type: 'textarea', placeholder: 'e.g. Specialist in React and TypeScript', - skip: (vals) => vals.role !== '__custom__', + skip: (vals) => vals.adapter === 'shell' || vals.role !== '__custom__', }, { id: 'skills', label: 'Skills (comma-separated)', type: 'text', placeholder: 'e.g. feature-dev:feature-dev, testing-suite:generate-tests', + skip: (vals) => vals.adapter === 'shell', }, { id: 'approval_policy', @@ -252,26 +271,35 @@ export function getAgentWizardSteps(agents?: Agent[], teams?: Team[], modelCatal label: 'Join team', type: 'select', options: teamOptions, - skip: () => teamOptions.length <= 1, // Skip if no teams exist + skip: (vals) => vals.adapter === 'shell' || teamOptions.length <= 1, // Skip if no teams exist }, ]; } /** Convert wizard values → CreateAgentInput-compatible object */ export function agentWizardToInput(vals: Record, defaultAdapter: string = 'claude') { - const role = vals.role === '__custom__' ? (vals.role_custom || undefined) : (vals.role || undefined); - const skills = vals.skills ? vals.skills.split(',').map((s) => s.trim()).filter(Boolean) : undefined; - const approval_policy = (vals.approval_policy as 'auto' | 'suggest' | 'manual' | undefined) || 'auto'; - const effort = (vals.effort as ReasoningEffort | undefined) || undefined; + const adapter = vals.adapter || defaultAdapter; + const isShell = adapter === 'shell'; + const role = isShell + ? undefined + : vals.role === '__custom__' ? (vals.role_custom || undefined) : (vals.role || undefined); + const skills = !isShell && vals.skills + ? vals.skills.split(',').map((s) => s.trim()).filter(Boolean) + : undefined; + const approval_policy = isShell + ? 'auto' + : (vals.approval_policy as 'auto' | 'suggest' | 'manual' | undefined) || 'auto'; + const effort = isShell ? undefined : (vals.effort as ReasoningEffort | undefined) || undefined; return { name: vals.name!, - adapter: vals.adapter || defaultAdapter, + adapter, + command: isShell ? vals.command?.trim() || undefined : undefined, role, - model: vals.model || undefined, + model: isShell ? undefined : vals.model || undefined, effort, approval_policy, skills, - team_id: vals.team || undefined, + team_id: isShell ? undefined : vals.team || undefined, }; } @@ -460,6 +488,17 @@ export function getEditAgentWizardSteps(agent: Agent, agents?: Agent[], teams?: options: ADAPTERS, defaultValue: agent.adapter, }, + { + id: 'command', + label: 'Command', + type: 'text', + defaultValue: agent.config.command ?? '', + placeholder: 'e.g. npm test or bash scripts/deploy.sh', + description: 'Runs from task workspace · exit 0 = done · non-zero = failed', + required: true, + skip: (vals) => (vals.adapter || agent.adapter) !== 'shell', + validate: (v) => v.trim() ? null : 'Command is required for Shell', + }, { id: 'model', label: 'Model', @@ -470,6 +509,7 @@ export function getEditAgentWizardSteps(agent: Agent, agents?: Agent[], teams?: suggestionsLabel: 'available models', getDefaultValue: (vals) => (vals.adapter || agent.adapter) === agent.adapter ? (agent.config.model ?? '') : '', + skip: (vals) => (vals.adapter || agent.adapter) === 'shell', }, { id: 'effort', @@ -485,6 +525,7 @@ export function getEditAgentWizardSteps(agent: Agent, agents?: Agent[], teams?: type: 'select', options: ROLE_PRESETS, defaultValue: roleDefault, + skip: (vals) => (vals.adapter || agent.adapter) === 'shell', }, { id: 'role_custom', @@ -492,7 +533,7 @@ export function getEditAgentWizardSteps(agent: Agent, agents?: Agent[], teams?: type: 'textarea', defaultValue: agent.role && !currentRoleInPresets ? agent.role : '', placeholder: 'e.g. Specialist in React and TypeScript', - skip: (vals) => vals.role !== '__custom__', + skip: (vals) => (vals.adapter || agent.adapter) === 'shell' || vals.role !== '__custom__', }, { id: 'team', @@ -500,7 +541,7 @@ export function getEditAgentWizardSteps(agent: Agent, agents?: Agent[], teams?: type: 'select', options: teamOptions, defaultValue: currentTeamId ?? '', - skip: () => teamOptions.length <= 1, + skip: (vals) => (vals.adapter || agent.adapter) === 'shell' || teamOptions.length <= 1, }, ]; } @@ -595,15 +636,17 @@ export function getConfigWizardSteps( } export function editAgentWizardToFields(vals: Record) { + const isShell = vals.adapter === 'shell'; const role = vals.role === '__custom__' ? (vals.role_custom || undefined) : (vals.role || undefined); const effort = vals.effort !== undefined ? vals.effort as ReasoningEffort | '' : undefined; return { name: vals.name, adapter: vals.adapter, - role, - model: vals.model, - effort, - team_id: vals.team || undefined, + command: isShell ? vals.command?.trim() : vals.adapter ? '' : undefined, + role: isShell ? undefined : role, + model: isShell ? undefined : vals.model, + effort: isShell ? undefined : effort, + team_id: isShell ? undefined : vals.team || undefined, }; } diff --git a/test/unit/application/agent-factory.test.ts b/test/unit/application/agent-factory.test.ts index aeef92f..bf17284 100644 --- a/test/unit/application/agent-factory.test.ts +++ b/test/unit/application/agent-factory.test.ts @@ -41,10 +41,9 @@ describe('templateToAgentInput', () => { expect(input.model).toBe('gpt-5.3-codex'); }); - it('resolves empty model for shell adapter', () => { - const input = templateToAgentInput(baseTemplate, 'shell'); - expect(input.adapter).toBe('shell'); - expect(input.model).toBeUndefined(); // empty string → undefined + it('rejects shell because shop templates do not define executable commands', () => { + expect(() => templateToAgentInput(baseTemplate, 'shell')) + .toThrow('Agent Shop templates require an AI adapter'); }); it('resolves model for pi adapter', () => { diff --git a/test/unit/application/agent-service.test.ts b/test/unit/application/agent-service.test.ts index b81ecd6..4c23c94 100644 --- a/test/unit/application/agent-service.test.ts +++ b/test/unit/application/agent-service.test.ts @@ -123,6 +123,29 @@ describe('AgentService', () => { const agent = await service.create({ name: 'no-effort', adapter: 'claude' }); expect(agent.config.effort).toBeUndefined(); }); + + it('requires a non-empty command for shell agents', async () => { + await expect(service.create({ name: 'runner', adapter: 'shell' })) + .rejects.toThrow('Shell agents require a command'); + await expect(service.create({ name: 'runner', adapter: 'shell', command: ' ' })) + .rejects.toThrow(InvalidArgumentsError); + expect(agentStore.save).not.toHaveBeenCalled(); + }); + + it('trims shell commands and defaults their approval policy to auto', async () => { + const agent = await service.create({ + name: 'runner', + adapter: 'shell', + command: ' npm test ', + model: 'stale-model', + effort: 'high', + }); + + expect(agent.config.command).toBe('npm test'); + expect(agent.config.approval_policy).toBe('auto'); + expect(agent.config.model).toBeUndefined(); + expect(agent.config.effort).toBeUndefined(); + }); }); describe('get', () => { @@ -238,6 +261,67 @@ describe('AgentService', () => { const agent = await service.update('agt_test1', { effort: '' as any }); expect(agent.config.effort).toBeUndefined(); }); + + it('requires a command when switching an agent to shell', async () => { + agentStore = createMockAgentStore([makeAgent({ status: 'idle', adapter: 'claude' })]); + service = new AgentService(agentStore, stateStore, eventBus, DEFAULT_CONFIG); + + await expect(service.update('agt_test1', { adapter: 'shell' })) + .rejects.toThrow('Shell agents require a command'); + expect(agentStore.save).not.toHaveBeenCalled(); + }); + + it('updates and trims a shell command', async () => { + agentStore = createMockAgentStore([makeAgent({ + status: 'idle', + adapter: 'shell', + config: { ...makeAgent().config, command: 'npm test' }, + })]); + service = new AgentService(agentStore, stateStore, eventBus, DEFAULT_CONFIG); + + const agent = await service.update('agt_test1', { command: ' npm run lint ' }); + expect(agent.config.command).toBe('npm run lint'); + }); + + it('preserves an explicit policy when editing an existing shell agent', async () => { + agentStore = createMockAgentStore([makeAgent({ + status: 'idle', + adapter: 'shell', + config: { + ...makeAgent().config, + command: 'npm test', + approval_policy: 'manual', + }, + })]); + service = new AgentService(agentStore, stateStore, eventBus, DEFAULT_CONFIG); + + const agent = await service.update('agt_test1', { command: 'npm run lint' }); + expect(agent.config.approval_policy).toBe('manual'); + }); + + it('can switch to shell when a command is provided in the same update', async () => { + agentStore = createMockAgentStore([makeAgent({ + status: 'idle', + adapter: 'claude', + config: { + ...makeAgent().config, + model: 'claude-sonnet-4-6', + effort: 'high', + approval_policy: 'suggest', + }, + })]); + service = new AgentService(agentStore, stateStore, eventBus, DEFAULT_CONFIG); + + const agent = await service.update('agt_test1', { + adapter: 'shell', + command: 'npm test', + }); + expect(agent.adapter).toBe('shell'); + expect(agent.config.command).toBe('npm test'); + expect(agent.config.approval_policy).toBe('auto'); + expect(agent.config.model).toBeUndefined(); + expect(agent.config.effort).toBeUndefined(); + }); }); describe('findBestAgent', () => { diff --git a/test/unit/cli/commands-init.test.ts b/test/unit/cli/commands-init.test.ts index 98289ef..f19ca5b 100644 --- a/test/unit/cli/commands-init.test.ts +++ b/test/unit/cli/commands-init.test.ts @@ -93,6 +93,10 @@ describe('getDefaultAgents()', () => { expect(getDefaultAgents('pi')[0].adapter).toBe('pi'); }); + it('does not create an invalid commandless agent for the shell adapter', () => { + expect(getDefaultAgents('shell')).toEqual([]); + }); + it('resolves model from adapter tier', () => { expect(getDefaultAgents('claude')[0].config.model).toBe('claude-sonnet-4-6'); expect(getDefaultAgents('codex')[0].config.model).toBe('gpt-5.3-codex'); @@ -228,6 +232,19 @@ describe('init command', () => { expect(output).toContain('Agent Creator'); }); + it('initializes shell without a default agent and shows the explicit add command', async () => { + await program.parseAsync(['init', '--adapter', 'shell'], { from: 'user' }); + + const agentWrites = mocks.writeYaml.mock.calls.filter(([p]) => String(p).includes('/agents/')); + const output = (console.log as ReturnType).mock.calls + .map((c: unknown[]) => String(c[0] ?? '')) + .join('\n'); + + expect(agentWrites).toHaveLength(0); + expect(output).toContain('orch agent add "Test Runner" --adapter shell --command "npm test"'); + expect(output).not.toContain('--assignee agt_creator'); + }); + it('skips init when .orchestry already exists (no writeYaml for agents)', async () => { mocks.pathExists.mockResolvedValue(true); await program.parseAsync(['init'], { from: 'user' }); diff --git a/test/unit/cli/commands-run.test.ts b/test/unit/cli/commands-run.test.ts index 9caaa89..f7a5e12 100644 --- a/test/unit/cli/commands-run.test.ts +++ b/test/unit/cli/commands-run.test.ts @@ -1,21 +1,41 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Command } from 'commander'; import { registerRunCommand } from '../../../src/cli/commands/run.js'; import { makeContainer } from './helpers.js'; +import type { OrchestratorEvent } from '../../../src/domain/events.js'; describe('run command', () => { let program: Command; let container: Container; + let emitRunEvent: ((event: OrchestratorEvent) => void) | undefined; beforeEach(() => { + process.exitCode = undefined; program = new Command(); program.exitOverride(); container = makeContainer(); + (container.eventBus.onAny as ReturnType).mockImplementation( + (handler: (event: OrchestratorEvent) => void) => { + emitRunEvent = handler; + return vi.fn(); + }, + ); + (container.orchestrator.runTask as ReturnType).mockImplementation( + async (taskId: string) => { + emitRunEvent?.({ type: 'agent:started', taskId, runId: 'run_1', agentId: 'agt_1' }); + emitRunEvent?.({ type: 'agent:completed', runId: 'run_1', agentId: 'agt_1', success: true }); + }, + ); vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); registerRunCommand(program, container); }); + afterEach(() => { + process.exitCode = undefined; + vi.restoreAllMocks(); + }); + describe('run ', () => { it('calls orchestrator.runTask with task id', async () => { await program.parseAsync(['run', 'tsk_1'], { from: 'user' }); @@ -49,6 +69,27 @@ describe('run command', () => { expect(unsub).toHaveBeenCalled(); }); + + it('sets exit code 1 when the requested run fails after dispatch returns', async () => { + (container.orchestrator.runTask as ReturnType).mockImplementation( + async (taskId: string) => { + emitRunEvent?.({ type: 'agent:started', taskId, runId: 'run_failed', agentId: 'agt_1' }); + }, + ); + + await program.parseAsync(['run', 'tsk_1'], { from: 'user' }); + expect(process.exitCode).toBeUndefined(); + + emitRunEvent?.({ type: 'agent:completed', runId: 'run_failed', agentId: 'agt_1', success: false }); + + expect(process.exitCode).toBe(1); + }); + + it('leaves the exit code unchanged when the requested run succeeds', async () => { + await program.parseAsync(['run', 'tsk_1'], { from: 'user' }); + + expect(process.exitCode).toBeUndefined(); + }); }); describe('run --all', () => { diff --git a/test/unit/cli/editor-agent.test.ts b/test/unit/cli/editor-agent.test.ts index 65dfd45..36d004d 100644 --- a/test/unit/cli/editor-agent.test.ts +++ b/test/unit/cli/editor-agent.test.ts @@ -8,12 +8,14 @@ describe('agentToEditorContent', () => { it('serializes all fields into YAML frontmatter + role body', () => { const result = agentToEditorContent({ name: 'Backend Agent', + command: 'npm test', model: 'claude-sonnet-4-6', role: 'You are a backend developer.', }); expect(result).toContain('---'); expect(result).toContain('name: Backend Agent'); + expect(result).toContain('command: npm test'); expect(result).toContain('model: claude-sonnet-4-6'); expect(result).toContain('You are a backend developer.'); // Comments should be present @@ -24,6 +26,7 @@ describe('agentToEditorContent', () => { it('handles missing model (empty string)', () => { const result = agentToEditorContent({ name: 'Test', role: 'A role' }); expect(result).toContain('model: '); + expect(result).toContain('command: '); expect(result).toContain('name: Test'); expect(result).toContain('A role'); }); @@ -52,6 +55,7 @@ describe('agentFromEditorContent', () => { '# Role description goes below the second --- separator.', '---', 'name: Backend Agent', + 'command: npm test', 'model: claude-sonnet-4-6', '---', '', @@ -60,6 +64,7 @@ describe('agentFromEditorContent', () => { const parsed = agentFromEditorContent(content); expect(parsed.name).toBe('Backend Agent'); + expect(parsed.command).toBe('npm test'); expect(parsed.model).toBe('claude-sonnet-4-6'); expect(parsed.role).toBe('You are a backend developer.'); }); @@ -208,6 +213,7 @@ describe('agentFromEditorContent', () => { const parsed = agentFromEditorContent(content); expect(parsed.name).toBe('Minimal'); + expect(parsed.command).toBe(''); expect(parsed.model).toBe(''); expect(parsed.role).toBe(''); }); @@ -240,6 +246,7 @@ describe('agentFromEditorContent', () => { const content = [ '---', 'name: ', + 'command: ', 'model: ', '---', '', @@ -248,6 +255,7 @@ describe('agentFromEditorContent', () => { const parsed = agentFromEditorContent(content); // Must be '' not undefined — '' allows clearing via update() expect(parsed.name).toBe(''); + expect(parsed.command).toBe(''); expect(parsed.model).toBe(''); expect(parsed.role).toBe(''); }); diff --git a/test/unit/domain/agent-shop.test.ts b/test/unit/domain/agent-shop.test.ts index 667b20c..69fbdc7 100644 --- a/test/unit/domain/agent-shop.test.ts +++ b/test/unit/domain/agent-shop.test.ts @@ -116,12 +116,13 @@ describe('applyShopTemplate', () => { expect(roleStep!.defaultValue).toBe('__custom__'); }); - it('injects role prompt into role_custom and removes skip', () => { + it('injects role prompt into role_custom while preserving conditional visibility', () => { const base = getAgentWizardSteps(); const result = applyShopTemplate(base, template, 'claude'); const customStep = result.find((s) => s.id === 'role_custom'); expect(customStep!.defaultValue).toBe(template.role); - expect(customStep!.skip).toBeUndefined(); + expect(customStep!.skip!({ adapter: 'claude', role: '__custom__' })).toBe(false); + expect(customStep!.skip!({ adapter: 'shell', role: '__custom__' })).toBe(true); }); it('injects skills as comma-separated string', () => { diff --git a/test/unit/infrastructure/shell-adapter.test.ts b/test/unit/infrastructure/shell-adapter.test.ts index 09a616e..c9360ec 100644 --- a/test/unit/infrastructure/shell-adapter.test.ts +++ b/test/unit/infrastructure/shell-adapter.test.ts @@ -14,12 +14,18 @@ function createMockProcess() { stdin: null; pid: number; kill: () => void; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + killed: boolean; }; proc.stdout = new PassThrough(); proc.stderr = new PassThrough(); proc.stdin = null; proc.pid = 9999; proc.kill = vi.fn(); + proc.exitCode = null; + proc.signalCode = null; + proc.killed = false; return proc; } @@ -87,6 +93,35 @@ describe('ShellAdapter', () => { expect(events.some((e) => e.type === 'error' && e.data === 'something went wrong')).toBe(true); }); + it('rejects a non-zero exit that occurs before output consumption finishes', async () => { + const proc = createMockProcess(); + const pm = createMockProcessManager(proc); + const adapter = new ShellAdapter(pm); + + const handle = adapter.execute(makeParams({ config: { adapter: 'shell', command: 'exit 7' } })); + + // Reproduce the real race: the child exits while the orchestrator is still + // handling its stderr event, before it resumes the adapter generator. + proc.stderr.write('intentional failure\n'); + proc.exitCode = 7; + proc.emit('close', 7, null); + proc.stdout.end(); + proc.stderr.end(); + + const consume = async () => { + for await (const event of handle.events) { + if (event.type === 'error') { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + }; + + await expect(consume()).rejects.toMatchObject({ + message: 'Shell command exited with code 7', + errorKind: AdapterErrorKind.PROCESS_CRASH, + }); + }); + it('does not hang when stream emits an error (buffer.close is called)', async () => { const proc = createMockProcess(); const pm = createMockProcessManager(proc); diff --git a/test/unit/tui/app.test.tsx b/test/unit/tui/app.test.tsx index d24e0a0..baae0d8 100644 --- a/test/unit/tui/app.test.tsx +++ b/test/unit/tui/app.test.tsx @@ -16,6 +16,7 @@ import type { Task } from '../../../src/domain/task.js'; import type { Agent } from '../../../src/domain/agent.js'; import type { Goal } from '../../../src/domain/goal.js'; import type { OrchestratorState } from '../../../src/domain/state.js'; +import type { Team } from '../../../src/domain/team.js'; import { DEFAULT_STATE } from '../../../src/domain/state.js'; const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -595,6 +596,60 @@ describe('App', () => { expect(output).toContain('frontend'); }); + it('creates a shell agent through the minimal A → N wizard path', async () => { + const state: OrchestratorState = { ...DEFAULT_STATE }; + let received: { name?: string; adapter?: string; command?: string; approval?: string } = {}; + const onAddAgent: NonNullable['onAddAgent']> = async (name, adapter, opts) => { + received = { + name, + adapter, + command: opts?.command, + approval: opts?.approval_policy, + }; + return makeAgent({ + id: 'shell-wizard', + name, + adapter: adapter ?? 'shell', + config: { command: opts?.command, approval_policy: 'auto' }, + }); + }; + const { stdin, lastFrame } = render( + React.createElement(App, { projectName: 'test', tasks: [], agents: [], state, onAddAgent }), + ); + + stdin.write('a'); + await delay(25); + stdin.write('n'); + await delay(25); + expect(lastFrame()!).toContain('Agent name'); + + stdin.write('Test Runner'); + await delay(25); + stdin.write('\r'); + await delay(25); + expect(lastFrame()!).toContain('Provider'); + + for (let i = 0; i < 7; i++) stdin.write('j'); + await delay(25); + stdin.write('\r'); + await delay(25); + expect(lastFrame()!).toContain('Command'); + expect(lastFrame()!).toContain('exit 0 = done'); + expect(lastFrame()!).not.toContain('Model'); + + stdin.write('npm test'); + await delay(25); + stdin.write('\r'); + await delay(100); + + expect(received).toEqual({ + name: 'Test Runner', + adapter: 'shell', + command: 'npm test', + approval: 'auto', + }); + }); + it('shows footer with view hints T A L', () => { const state: OrchestratorState = { ...DEFAULT_STATE }; const { lastFrame } = render( @@ -627,6 +682,74 @@ describe('App', () => { expect(output).toContain('Backend developer'); }); + it('shows the executable command instead of a model for shell agents', async () => { + const agents = [makeAgent({ + id: 'shell-1', + name: 'test-runner', + adapter: 'shell', + config: { command: 'npm run test:unit', approval_policy: 'auto' }, + })]; + const state: OrchestratorState = { ...DEFAULT_STATE }; + const { stdin, lastFrame } = render( + React.createElement(App, { projectName: 'test', tasks: [], agents, state }), + ); + + stdin.write('a'); + await delay(50); + stdin.write('\r'); + await delay(50); + + expect(lastFrame()!).toContain('command'); + expect(lastFrame()!).toContain('npm run test:unit'); + }); + + it('does not leave a team when editing a shell agent with the Team step hidden', async () => { + const agent = makeAgent({ + id: 'shell-team', + name: 'team-runner', + adapter: 'shell', + config: { command: 'npm test', approval_policy: 'auto' }, + }); + const team: Team = { + id: 'team_1', + name: 'QA', + status: 'active', + members: [{ agent_id: agent.id, role: 'member', joined_at: '2026-01-01T00:00:00.000Z' }], + task_pool: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + config: { auto_claim: true }, + }; + const onUpdateAgent = vi.fn(async () => agent); + const onLeaveTeam = vi.fn(async () => team); + const { stdin } = render( + React.createElement(App, { + projectName: 'test', + tasks: [], + agents: [agent], + state: { ...DEFAULT_STATE }, + onUpdateAgent, + onLeaveTeam, + onListTeams: async () => [team], + }), + ); + + await delay(50); + stdin.write('a'); + await delay(25); + stdin.write('e'); + await delay(25); + stdin.write('\r'); + await delay(25); + stdin.write('\r'); + await delay(25); + stdin.write('\r'); + await delay(100); + + expect(onUpdateAgent).toHaveBeenCalledOnce(); + expect(onLeaveTeam).not.toHaveBeenCalled(); + }); + it('does not switch views when detail panel is open', async () => { const tasks = [makeTask({ id: '1', title: 'Task' })]; const state: OrchestratorState = { ...DEFAULT_STATE }; @@ -1556,6 +1679,76 @@ describe('Command bar — /task add', () => { expect(lastFrame()!).toContain('myworker'); }); + it('creates a shell agent from the command bar with its full command', async () => { + const state: OrchestratorState = { ...DEFAULT_STATE }; + let received: { name?: string; adapter?: string; command?: string; approval?: string } = {}; + const onAddAgent: NonNullable['onAddAgent']> = async (name, adapter, opts) => { + received = { + name, + adapter, + command: opts?.command, + approval: opts?.approval_policy, + }; + return makeAgent({ + id: 'agt-shell', + name, + adapter: adapter ?? 'shell', + config: { command: opts?.command, approval_policy: 'auto' }, + }); + }; + const { stdin, lastFrame } = render( + React.createElement(App, { projectName: 'test', tasks: [], agents: [], state, onAddAgent }), + ); + + stdin.write('/'); + await delay(50); + for (const ch of 'agent add runner shell npm run test:unit') stdin.write(ch); + await delay(50); + stdin.write('\r'); + await delay(200); + + expect(received).toEqual({ + name: 'runner', + adapter: 'shell', + command: 'npm run test:unit', + approval: 'auto', + }); + expect(lastFrame()!).toContain('npm run test:unit'); + }); + + it('opens the prefilled shell wizard when the default adapter needs a command', async () => { + const state: OrchestratorState = { ...DEFAULT_STATE }; + const onAddAgent = vi.fn(); + const { stdin, lastFrame } = render( + React.createElement(App, { + projectName: 'test', + tasks: [], + agents: [], + state, + defaultAdapter: 'shell', + onAddAgent, + }), + ); + + stdin.write('/'); + await delay(50); + for (const ch of 'agent add runner') stdin.write(ch); + await delay(50); + stdin.write('\r'); + await delay(50); + + expect(lastFrame()!).toContain('NEW AGENT'); + expect(lastFrame()!).toContain('runner'); + expect(onAddAgent).not.toHaveBeenCalled(); + + stdin.write('\r'); + await delay(25); + stdin.write('\r'); + await delay(25); + expect(lastFrame()!).toContain('Command'); + expect(lastFrame()!).toContain('exit 0 = done'); + }); + it('approves task via /task approve command', async () => { const state: OrchestratorState = { ...DEFAULT_STATE }; const tasks = [makeTask({ id: 'tsk_1', title: 'Review me', status: 'review' })]; diff --git a/test/unit/tui/form-wizard-validation.test.tsx b/test/unit/tui/form-wizard-validation.test.tsx index 40d6184..5d6645f 100644 --- a/test/unit/tui/form-wizard-validation.test.tsx +++ b/test/unit/tui/form-wizard-validation.test.tsx @@ -325,6 +325,55 @@ describe('FormWizard inline validation', () => { expect(onComplete).toHaveBeenCalledWith({ name: 'valid input' }); }); + it('allows immediate Enter when current text is valid but the debounced error is stale', async () => { + const onComplete = vi.fn(); + const { stdin, lastFrame } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextStep({ validate: (value) => value.trim() ? null : 'Name is required' }), + onComplete, + onCancel: vi.fn(), + width: 60, + height: 20, + }), + ); + await delay(400); // Initial empty-value validation has completed. + + stdin.write('valid'); + await delay(20); // Current input is rendered; the 300ms validation is still stale. + expect(lastFrame()!).not.toContain('Name is required'); + stdin.write('\r'); + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ name: 'valid' }); + }); + + it('allows immediate textarea confirmation when current value is valid but the error is stale', async () => { + const onComplete = vi.fn(); + const { stdin, lastFrame } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaStep({ + required: true, + validate: (value) => value.trim() ? null : 'Description is required', + }), + onComplete, + onCancel: vi.fn(), + width: 60, + height: 20, + }), + ); + await delay(400); // Initial empty-value validation has completed. + + stdin.write('valid'); + await delay(20); // Current input is rendered; the 300ms validation is still stale. + expect(lastFrame()!).not.toContain('Description is required'); + stdin.write(CTRL_ENTER); + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: 'valid' }); + }); + /* ── 6. Select validate — synchronous on confirm ── */ it('blocks select confirmation and shows error when validate returns error', async () => { diff --git a/test/unit/tui/text-input.test.tsx b/test/unit/tui/text-input.test.tsx index 822f7c2..26372e3 100644 --- a/test/unit/tui/text-input.test.tsx +++ b/test/unit/tui/text-input.test.tsx @@ -487,18 +487,16 @@ describe('Unified text input — visual rendering', () => { }); it('shows error border when validation fails', async () => { - vi.useFakeTimers(); const { stdin, lastFrame } = renderWizard( makeTextStep({ required: true, validate: (v) => (v.length < 3 ? 'Too short' : null), }), ); - await vi.advanceTimersByTimeAsync(50); + await delay(50); stdin.write('ab'); - await vi.advanceTimersByTimeAsync(300); // fire validation debounce + await delay(350); const output = lastFrame()!; expect(output).toContain('Too short'); - vi.useRealTimers(); }); }); diff --git a/test/unit/tui/wizard-configs-team.test.ts b/test/unit/tui/wizard-configs-team.test.ts index d8b2392..7a20462 100644 --- a/test/unit/tui/wizard-configs-team.test.ts +++ b/test/unit/tui/wizard-configs-team.test.ts @@ -147,7 +147,7 @@ describe('editAgentWizardToFields — team_id field', () => { it('returns undefined when team is empty string (None selected)', () => { // Uses `|| undefined` — empty string is falsy so maps to undefined. - // App.tsx handles this via `fields.team_id ?? ''` which normalises undefined → ''. + // App.tsx only normalises this to '' when the Team step was actually visited. const fields = editAgentWizardToFields({ name: 'Agent', role: '', model: '', team: '' }); expect(fields.team_id).toBeUndefined(); }); diff --git a/test/unit/tui/wizard-shell.test.ts b/test/unit/tui/wizard-shell.test.ts new file mode 100644 index 0000000..713fede --- /dev/null +++ b/test/unit/tui/wizard-shell.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { Agent } from '../../../src/domain/agent.js'; +import type { Team } from '../../../src/domain/team.js'; +import { + agentWizardToInput, + editAgentWizardToFields, + getAgentWizardSteps, + getEditAgentWizardSteps, +} from '../../../src/tui/wizardConfigs.js'; + +function makeShellAgent(command = 'npm test'): Agent { + return { + id: 'agt_shell', + name: 'Test Runner', + adapter: 'shell', + status: 'idle', + config: { command, approval_policy: 'auto' }, + stats: { tasks_completed: 0, tasks_failed: 0, total_runs: 0, total_runtime_ms: 0 }, + }; +} + +const team: Team = { + id: 'team_1', + name: 'QA', + status: 'active', + members: [], + task_pool: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + config: { auto_claim: true }, +}; + +function visibleStepIds( + steps: ReturnType, + values: Record, +): string[] { + return steps.filter((step) => !step.skip?.(values)).map((step) => step.id); +} + +describe('shell agent wizard', () => { + it('reduces creation to name, provider, and command even when teams exist', () => { + const steps = getAgentWizardSteps([], [team]); + expect(visibleStepIds(steps, { adapter: 'shell' })).toEqual([ + 'name', + 'adapter', + 'command', + ]); + }); + + it('shows command guidance and requires a non-empty value', () => { + const commandStep = getAgentWizardSteps().find((step) => step.id === 'command')!; + expect(commandStep.required).toBe(true); + expect(commandStep.description).toBe('Runs from task workspace · exit 0 = done · non-zero = failed'); + expect(commandStep.validate!(' ')).toBe('Command is required for Shell'); + expect(commandStep.validate!('npm test')).toBeNull(); + }); + + it('keeps the command step hidden for AI adapters', () => { + const steps = getAgentWizardSteps(); + const visible = visibleStepIds(steps, { adapter: 'claude' }); + expect(visible).not.toContain('command'); + expect(visible).toContain('model'); + expect(visible).toContain('role'); + }); + + it('prefills the configured default adapter', () => { + const adapterStep = getAgentWizardSteps(undefined, undefined, undefined, 'shell') + .find((step) => step.id === 'adapter')!; + expect(adapterStep.defaultValue).toBe('shell'); + }); + + it('maps shell input without stale AI-only values', () => { + const input = agentWizardToInput({ + name: 'Test Runner', + adapter: 'shell', + command: ' npm test ', + model: 'stale-model', + effort: 'high', + role: 'stale role', + skills: 'stale-skill', + team: 'team_1', + approval_policy: 'manual', + }); + + expect(input).toMatchObject({ + name: 'Test Runner', + adapter: 'shell', + command: 'npm test', + approval_policy: 'auto', + }); + expect(input.model).toBeUndefined(); + expect(input.effort).toBeUndefined(); + expect(input.role).toBeUndefined(); + expect(input.skills).toBeUndefined(); + expect(input.team_id).toBeUndefined(); + }); + + it('prefills and exposes command when editing a shell agent', () => { + const steps = getEditAgentWizardSteps(makeShellAgent(), undefined, [team]); + const commandStep = steps.find((step) => step.id === 'command')!; + const visible = steps + .filter((step) => !step.skip?.({ adapter: 'shell' })) + .map((step) => step.id); + + expect(commandStep.defaultValue).toBe('npm test'); + expect(visible).toEqual(['name', 'adapter', 'command']); + }); + + it('maps shell edits and clears stale commands after switching back to AI', () => { + expect(editAgentWizardToFields({ + name: 'Runner', + adapter: 'shell', + command: ' npm run lint ', + }).command).toBe('npm run lint'); + + expect(editAgentWizardToFields({ + name: 'Runner', + adapter: 'claude', + model: '', + }).command).toBe(''); + }); +});