From a862d00153c041baea843631d1882d06e4539a45 Mon Sep 17 00:00:00 2001 From: Jorge Vidaurre Date: Tue, 31 Mar 2026 16:38:18 -0300 Subject: [PATCH 1/3] feat(ux): show agent discovery when squads run called with no args Instead of immediately launching autopilot, `squads run` now displays all available agents grouped by squad with their roles and run hints. Autopilot is still accessible via explicit intent flags (--once, -i, --budget, --phased). Closes #694 --- src/cli.ts | 5 ++-- src/commands/run.ts | 50 ++++++++++++++++++++++++++++++++-- test/commands/run.test.ts | 56 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 5cf197a9..0b7baf5d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -283,7 +283,7 @@ program.command('create ', { hidden: true }).description('[renamed]').acti // Run command - execute squads or individual agents program .command('run [target]') - .description('Run a squad, agent, or autopilot (no target = autopilot mode)') + .description('Run a squad or agent (no target = show available agents)') .option('-v, --verbose', 'Verbose output') .option('-d, --dry-run', 'Show what would be run without executing') .option('-a, --agent ', 'Run specific agent within squad') @@ -326,7 +326,8 @@ Examples: $ squads run engineering -w Run in background but tail logs $ squads run research --provider=google Use Gemini CLI instead of Claude $ squads run engineering/issue-solver --cloud Dispatch to cloud worker - $ squads run Autopilot mode (watch → decide → dispatch → learn) + $ squads run Show available agents grouped by squad + $ squads run --once Run one autopilot cycle then exit $ squads run --once --dry-run Preview one autopilot cycle $ squads run -i 15 --budget 50 Autopilot: 15min cycles, $50/day cap `) diff --git a/src/commands/run.ts b/src/commands/run.ts index 6dea1e43..632aefa7 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -250,9 +250,14 @@ export async function runCommand( return; } - // MODE 1: Autopilot — no target means run all squads continuously + // MODE 1: No target — show agent discovery or run autopilot if flags indicate intent if (!target) { - await runAutopilot(squadsDir, options); + const autopilotIntent = options.once || options.interval || options.budget || options.phased; + if (autopilotIntent) { + await runAutopilot(squadsDir, options); + } else { + await showAgentDiscovery(squadsDir); + } return; } @@ -329,6 +334,47 @@ export async function runCommand( } } +async function showAgentDiscovery(squadsDir: string): Promise { + const squads = listSquads(squadsDir); + + writeLine(); + writeLine(` ${gradient('squads')} ${colors.dim}run${RESET}`); + writeLine(); + + if (squads.length === 0) { + writeLine(` ${colors.dim}No squads found. Run \`squads init\` to create your first squad.${RESET}`); + writeLine(); + return; + } + + writeLine(` ${bold}Available agents${RESET} ${colors.dim}${squads.length} squad${squads.length === 1 ? '' : 's'}${RESET}`); + writeLine(); + + for (const squadName of squads) { + const squad = loadSquad(squadName); + if (!squad) continue; + + const agentCount = squad.agents.length; + writeLine(` ${colors.cyan}${bold}${squadName}${RESET} ${colors.dim}${agentCount} agent${agentCount === 1 ? '' : 's'}${RESET}`); + if (squad.mission) { + writeLine(` ${colors.dim}${squad.mission}${RESET}`); + } + + for (const agent of squad.agents) { + const role = agent.role ? ` ${colors.dim}${agent.role}${RESET}` : ''; + writeLine(` ${icons.empty} ${colors.cyan}${agent.name}${RESET}${role}`); + } + writeLine(); + } + + writeLine(` ${colors.dim}Run:${RESET}`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}${RESET} run squad conversation`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}/${RESET} run specific agent`); + writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}${RESET} --task ${colors.cyan}"..."${RESET} run with a directive`); + writeLine(` ${colors.dim}$${RESET} squads run --once one autopilot cycle`); + writeLine(); +} + async function runSquad( squad: ReturnType, squadsDir: string, diff --git a/test/commands/run.test.ts b/test/commands/run.test.ts index b9ed4fb4..e10ad9e7 100644 --- a/test/commands/run.test.ts +++ b/test/commands/run.test.ts @@ -175,13 +175,14 @@ vi.mock('../../src/lib/run-context.js', () => ({ // ── Imports (after mocks) ────────────────────────────────────────────────── import { runCommand, runSquadCommand } from '../../src/commands/run.js'; -import { findSquadsDir, loadSquad, listAgents, findSimilarSquads } from '../../src/lib/squad-parser.js'; +import { findSquadsDir, loadSquad, listAgents, listSquads, findSimilarSquads } from '../../src/lib/squad-parser.js'; import { writeLine } from '../../src/lib/terminal.js'; import { isProviderCLIAvailable } from '../../src/lib/llm-clis.js'; const mockFindSquadsDir = vi.mocked(findSquadsDir); const mockLoadSquad = vi.mocked(loadSquad); const mockListAgents = vi.mocked(listAgents); +const mockListSquads = vi.mocked(listSquads); const mockFindSimilarSquads = vi.mocked(findSimilarSquads); const mockWriteLine = vi.mocked(writeLine); const mockIsProviderCLIAvailable = vi.mocked(isProviderCLIAvailable); @@ -450,6 +451,59 @@ describe('runCommand', () => { }); }); +// ── Tests: agent discovery (no target) ──────────────────────────────────── +describe('agent discovery (no target)', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.SQUADS_SKIP_CHECKS = '1'; + exitSpy = makeExitSpy(); + }); + + afterEach(() => { + exitSpy.mockRestore(); + delete process.env.SQUADS_SKIP_CHECKS; + }); + + it('shows available agents grouped by squad when no target given', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue(['cli', 'engineering']); + mockLoadSquad + .mockReturnValueOnce({ name: 'cli', dir: 'cli', mission: 'Build the CLI', agents: [{ name: 'issue-solver', role: 'solve issues', trigger: 'manual' }], pipelines: [], triggers: { scheduled: [], event: [], manual: [] }, routines: [], dependencies: [], outputPath: '', goals: [] }) + .mockReturnValueOnce({ name: 'engineering', dir: 'engineering', mission: '', agents: [], pipelines: [], triggers: { scheduled: [], event: [], manual: [] }, routines: [], dependencies: [], outputPath: '', goals: [] }); + + await runCommand(null, {}); + + const calls = mockWriteLine.mock.calls.map(c => c[0]); + expect(calls.some(msg => msg?.toString().includes('Available agents'))).toBe(true); + expect(calls.some(msg => msg?.toString().includes('issue-solver'))).toBe(true); + }); + + it('shows "no squads found" when squads directory is empty', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue([]); + + await runCommand(null, {}); + + const calls = mockWriteLine.mock.calls.map(c => c[0]); + expect(calls.some(msg => msg?.toString().includes('No squads found'))).toBe(true); + }); + + it('does not show agent discovery when --once flag is set (autopilot intent)', async () => { + mockFindSquadsDir.mockReturnValue('/project/.agents/squads'); + mockListSquads.mockReturnValue([]); + + // once=true → autopilot intent → runAutopilot called, not showAgentDiscovery + // runAutopilot crashes on cognition mock returning {} (no beliefs) — that's fine, + // we only care that agent discovery was NOT triggered + await runCommand(null, { once: true }).catch(() => {/* autopilot may throw from mock */}); + + // listSquads is called by showAgentDiscovery, not by runAutopilot + expect(mockListSquads).not.toHaveBeenCalled(); + }); +}); + // ── Tests: runSquadCommand ───────────────────────────────────────────────── describe('runSquadCommand', () => { let exitSpy: ReturnType; From b2d765742c144ff86e7f713accd0eb835006b010 Mon Sep 17 00:00:00 2001 From: Jorge Vidaurre Date: Tue, 31 Mar 2026 16:40:48 -0300 Subject: [PATCH 2/3] test(workflow): fix child_process mock to include spawn The workflow rewrite (3956f75) switched from execSync to spawn for running agents, but the vi.mock factory in workflow.test.ts didn't export spawn, crashing all runConversation tests. Changes: - Use importOriginal pattern in child_process mock so spawn is exported - Add makeFakeChild() helper that emits stdout + close events async - Replace mockExecSync calls with mockSpawn in 6 runConversation tests - Capture prompts from stdin.write instead of execSync cmd arg - Fix runConversation to pass options.maxTurns to detectConvergence (was hardcoded to 100, making the max-turns test fail) Fixes 6 failing tests; all 16 workflow tests now pass. --- src/lib/workflow.ts | 2 +- test/lib/workflow.test.ts | 64 +++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/lib/workflow.ts b/src/lib/workflow.ts index 340465d1..e2e4e519 100644 --- a/src/lib/workflow.ts +++ b/src/lib/workflow.ts @@ -304,7 +304,7 @@ ${squadContext}`; addTurn(transcript, lead.name, 'lead', planOutput, estimateTurnCost(options.model || 'sonnet')); // Check if lead declared done immediately (nothing to do) - const conv = detectConvergence(transcript, 100, costCeiling); + const conv = detectConvergence(transcript, options.maxTurns || 100, costCeiling); if (conv.converged) { return { transcript, turnCount: transcript.turns.length, totalCost: transcript.totalCost, converged: true, reason: conv.reason }; } diff --git a/test/lib/workflow.test.ts b/test/lib/workflow.test.ts index 501bc78d..498e6cc6 100644 --- a/test/lib/workflow.test.ts +++ b/test/lib/workflow.test.ts @@ -16,10 +16,15 @@ vi.mock('fs', () => ({ })); // Mock child_process before import -vi.mock('child_process', () => ({ - execSync: vi.fn(), - exec: vi.fn(), -})); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + exec: vi.fn(), + spawn: vi.fn(), + }; +}); // Mock squad-parser vi.mock('../../src/lib/squad-parser.js', () => ({ @@ -41,7 +46,7 @@ vi.mock('../../src/lib/conversation.js', async () => { }); import { existsSync, writeFileSync, mkdirSync } from 'fs'; -import { execSync } from 'child_process'; +import { execSync, spawn } from 'child_process'; import { findSquadsDir } from '../../src/lib/squad-parser.js'; import { runConversation, saveTranscript } from '../../src/lib/workflow.js'; import { createTranscript, addTurn } from '../../src/lib/conversation.js'; @@ -51,8 +56,37 @@ const mockExistsSync = vi.mocked(existsSync); const mockWriteFileSync = vi.mocked(writeFileSync); const mockMkdirSync = vi.mocked(mkdirSync); const mockExecSync = vi.mocked(execSync); +const mockSpawn = vi.mocked(spawn); const mockFindSquadsDir = vi.mocked(findSquadsDir); +/** + * Create a fake child_process spawn result that emits stdout then closes. + * Returns a plain object — cast to ReturnType at call site. + */ +function makeFakeChild(stdout: string) { + const stdoutCbs: ((chunk: Buffer) => void)[] = []; + const closeCbs: ((code: number) => void)[] = []; + return { + stdin: { write: vi.fn(), end: vi.fn() }, + stdout: { + on: vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') stdoutCbs.push(cb); + }), + }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + if (event === 'close') { + closeCbs.push(cb as (code: number) => void); + setImmediate(() => { + stdoutCbs.forEach(fn => fn(Buffer.from(stdout))); + closeCbs.forEach(fn => fn(0)); + }); + } + }), + kill: vi.fn(), + }; +} + // Minimal squad fixture function makeSquad(overrides: Partial = {}): Squad { return { @@ -105,7 +139,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // agent file exists // Lead outputs a convergence phrase immediately - mockExecSync.mockReturnValue('Session complete. All PRs merged.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete. All PRs merged.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -121,7 +155,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // Each lead turn produces non-convergent output but we set very low cost ceiling - mockExecSync.mockReturnValue('Still working on it.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Still working on it.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -142,7 +176,7 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); // Each turn produces non-convergent output with no cost (free) - mockExecSync.mockImplementation(() => 'Still working on it.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Still working on it.') as unknown as ReturnType); const squad = makeSquad({ agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }], @@ -163,9 +197,13 @@ describe('runConversation', () => { mockExistsSync.mockReturnValue(true); const capturedPrompts: string[] = []; - mockExecSync.mockImplementation((cmd: string) => { - capturedPrompts.push(cmd); - return 'Session complete.' as never; + mockSpawn.mockImplementation(() => { + const child = makeFakeChild('Session complete.'); + child.stdin.write.mockImplementation((data: unknown) => { + capturedPrompts.push(typeof data === 'string' ? data : String(data)); + return true; + }); + return child as unknown as ReturnType; }); const squad = makeSquad({ @@ -190,7 +228,7 @@ describe('runConversation', () => { return false; }); - mockExecSync.mockReturnValue('Session complete.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete.') as unknown as ReturnType); const squad = makeSquad({ repo: 'agents-squads/squads-cli', @@ -206,7 +244,7 @@ describe('runConversation', () => { mockFindSquadsDir.mockReturnValue('/fake/.agents/squads'); mockExistsSync.mockReturnValue(true); - mockExecSync.mockReturnValue('Session complete.' as never); + mockSpawn.mockReturnValue(makeFakeChild('Session complete.') as unknown as ReturnType); const squad = makeSquad({ agents: [ From 1b3d3938048b7314ed7ab7b9404c3d119d4e091d Mon Sep 17 00:00:00 2001 From: Jorge Vidaurre Date: Tue, 31 Mar 2026 16:51:41 -0300 Subject: [PATCH 3/3] fix(conversation): classify review/check as verifier, fix compaction threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'review' and 'check' keywords to verifier classification in classifyAgent() - Change compaction threshold from ≤6 to ≤5 turns - Always preserve initial brief (first turn) in compacted transcripts Fixes 3 failing tests in conversation.test.ts that were blocking CI across all open PRs. --- src/lib/conversation.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts index c2e6e9a6..79b16453 100644 --- a/src/lib/conversation.ts +++ b/src/lib/conversation.ts @@ -31,7 +31,7 @@ export function classifyAgent(agentName: string, roleDescription?: string): Agen const lower = roleDescription.toLowerCase(); if (lower.includes('orchestrat') || lower.includes('triage') || lower.includes('coordinat') || lower.includes('lead')) return 'lead'; if (lower.includes('scan') || lower.includes('monitor') || lower.includes('detect')) return 'scanner'; - if (lower.includes('verif') || lower.includes('critic')) return 'verifier'; + if (lower.includes('verif') || lower.includes('critic') || lower.includes('review') || lower.includes('check')) return 'verifier'; return 'worker'; } @@ -112,8 +112,8 @@ export function serializeTranscript(transcript: Transcript): string { } } - // If short conversation (≤6 turns or single cycle), return everything - if (turns.length <= 6 || cycleBoundaries.length <= 1) { + // If short conversation (≤5 turns or single cycle), return everything + if (turns.length <= 5 || cycleBoundaries.length <= 1) { return formatTurns(turns, transcript.turns.length); } @@ -128,6 +128,16 @@ export function serializeTranscript(transcript: Transcript): string { // Assemble const lines = ['## Conversation So Far\n']; + // Always preserve the initial brief (first turn) + const firstTurn = turns[0]; + lines.push(`**${firstTurn.agent} (${firstTurn.role}):**`); + lines.push(firstTurn.content); + lines.push(''); + + if (oldTurns.length > 1) { + lines.push(`*(${oldTurns.length - 1} earlier turns compacted)*\n`); + } + if (digest) { lines.push('### Prior Cycles (digest)'); lines.push(digest);