Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ program.command('create <name>', { 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 <agent>', 'Run specific agent within squad')
Expand Down Expand Up @@ -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
`)
Expand Down
50 changes: 48 additions & 2 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -329,6 +334,47 @@ export async function runCommand(
}
}

async function showAgentDiscovery(squadsDir: string): Promise<void> {
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}<squad>${RESET} run squad conversation`);
writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}<squad>/<agent>${RESET} run specific agent`);
writeLine(` ${colors.dim}$${RESET} squads run ${colors.cyan}<squad>${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<typeof loadSquad>,
squadsDir: string,
Expand Down
16 changes: 13 additions & 3 deletions src/lib/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
56 changes: 55 additions & 1 deletion test/commands/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -450,6 +451,59 @@ describe('runCommand', () => {
});
});

// ── Tests: agent discovery (no target) ────────────────────────────────────
describe('agent discovery (no target)', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;

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<typeof vi.spyOn>;
Expand Down
64 changes: 51 additions & 13 deletions test/lib/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
exec: vi.fn(),
spawn: vi.fn(),
};
});

// Mock squad-parser
vi.mock('../../src/lib/squad-parser.js', () => ({
Expand All @@ -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';
Expand All @@ -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<typeof spawn> 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> = {}): Squad {
return {
Expand Down Expand Up @@ -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<typeof spawn>);

const squad = makeSquad({
agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }],
Expand All @@ -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<typeof spawn>);

const squad = makeSquad({
agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }],
Expand All @@ -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<typeof spawn>);

const squad = makeSquad({
agents: [{ name: 'squad-lead', role: 'orchestrates the team', model: undefined }],
Expand All @@ -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<typeof spawn>;
});

const squad = makeSquad({
Expand All @@ -190,7 +228,7 @@ describe('runConversation', () => {
return false;
});

mockExecSync.mockReturnValue('Session complete.' as never);
mockSpawn.mockReturnValue(makeFakeChild('Session complete.') as unknown as ReturnType<typeof spawn>);

const squad = makeSquad({
repo: 'agents-squads/squads-cli',
Expand All @@ -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<typeof spawn>);

const squad = makeSquad({
agents: [
Expand Down
Loading