Skip to content
Merged
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: 5 additions & 0 deletions .changeset/fix-cast-base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bradygaster/squad-cli': patch
---

Passes repo root to LocalAgentSource instead of .squad/ dir, preventing a double-nested .squad/.squad/agents/ lookup. In remote mode, passes paths.teamDir (team repo root) so agents are discovered from the correct location.
12 changes: 10 additions & 2 deletions packages/squad-cli/src/cli/commands/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @module cli/commands/cast
*/

import * as path from 'node:path';
import { LocalAgentSource } from '@bradygaster/squad-sdk/config/agent-source';
import { resolvePersonalAgents, mergeSessionCast } from '@bradygaster/squad-sdk/agents/personal';
import { resolveSquadPaths } from '@bradygaster/squad-sdk/resolution';
Expand All @@ -23,8 +24,15 @@ export async function runCast(cwd: string): Promise<void> {
fatal('No squad found. Run "squad init" first.');
}

// Discover project agents
const projectSource = new LocalAgentSource(paths.teamDir);
// Discover project agents.
// LocalAgentSource appends .squad/agents to its base path, so we must supply:
// - local mode: parent of paths.projectDir (the repo root)
// - remote mode: paths.teamDir (the team repo root, which itself contains .squad/agents)
const agentBase =
paths.mode === 'remote'
? paths.teamDir
: path.resolve(paths.projectDir, '..');
const projectSource = new LocalAgentSource(agentBase);
const projectAgents = await projectSource.listAgents();

// Discover personal agents
Expand Down
153 changes: 153 additions & 0 deletions test/cli/cast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* squad cast — session cast display tests
*
* Verifies the cast command correctly discovers project agents
* by deriving the correct base path from resolveSquadPaths():
* - local mode: parent of paths.projectDir (repo root)
* - remote mode: paths.teamDir (team repo root)
* Regression tests for #871 (double-nested .squad/.squad/agents path).
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomBytes } from 'crypto';

const TEST_ROOT = join(process.cwd(), `.test-cast-${randomBytes(4).toString('hex')}`);

/**
* Charter using ## Identity sections, as expected by parseCharterMetadata().
* The agent name and role are read from these sections; fallback is the
* directory name when the section is absent.
*/
const SAMPLE_CHARTER = `## Identity

**Name:** TestAgent
**Role:** Core Dev

Test agent for cast command tests.
`;

async function scaffold(root: string): Promise<void> {
const sq = join(root, '.squad');
await mkdir(join(sq, 'agents', 'test-agent'), { recursive: true });
await writeFile(join(sq, 'agents', 'test-agent', 'charter.md'), SAMPLE_CHARTER);
await mkdir(join(sq, 'casting'), { recursive: true });
await writeFile(join(sq, 'team.md'), '# Team\n\n## Members\n\n- TestAgent\n');
await writeFile(join(sq, 'routing.md'), '# Routing\n');
await writeFile(join(sq, 'decisions.md'), '# Decisions\n');
await writeFile(
join(sq, 'casting', 'registry.json'),
JSON.stringify({ agents: [] }, null, 2),
);
}

// Mock personal agents to isolate project agent discovery
vi.mock('@bradygaster/squad-sdk/agents/personal', () => ({
resolvePersonalAgents: vi.fn(async () => [] as unknown[]),
mergeSessionCast: vi.fn((project: unknown[], personal: unknown[]) => [...(project as unknown[]), ...(personal as unknown[])]),
}));

describe('squad cast', () => {
beforeEach(async () => {
if (existsSync(TEST_ROOT)) {
await rm(TEST_ROOT, { recursive: true, force: true });
}
await mkdir(TEST_ROOT, { recursive: true });
});

afterEach(async () => {
vi.restoreAllMocks();
if (existsSync(TEST_ROOT)) {
await rm(TEST_ROOT, { recursive: true, force: true });
}
});

it('discovers project agents using repo root, not .squad/ dir (#871)', async () => {
await scaffold(TEST_ROOT);

// Suppress console output during test
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
await runCast(TEST_ROOT);

// If the bug were present (passing paths.teamDir = .squad/ to LocalAgentSource),
// it would look in .squad/.squad/agents/ — which doesn't exist — and find 0 agents.
// With the fix, it looks in TEST_ROOT/.squad/agents/ and finds our test agent.
const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(output).toContain('TestAgent');
expect(output).toContain('Session Cast');
});

it('does not look in double-nested .squad/.squad/agents/ path', async () => {
await scaffold(TEST_ROOT);

// Create a decoy agent at the WRONG double-nested path
const wrongPath = join(TEST_ROOT, '.squad', '.squad', 'agents', 'decoy');
await mkdir(wrongPath, { recursive: true });
await writeFile(join(wrongPath, 'charter.md'), `## Identity\n\n**Name:** Decoy\n**Role:** Wrong\n`);

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
await runCast(TEST_ROOT);

const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
// Should find TestAgent from correct path, not decoy from wrong path
expect(output).toContain('TestAgent');
expect(output).not.toContain('Decoy');
});

it('discovers agents when runCast is called from a nested subdirectory', async () => {
await scaffold(TEST_ROOT);

// Simulate invoking from a deep nested working directory — resolveSquadPaths
// walks up the tree until it finds .squad/, then the fix computes the repo root
// as path.resolve(projectDir, '..') so LocalAgentSource scans the right path.
const nestedDir = join(TEST_ROOT, 'src', 'feature', 'deep');
await mkdir(nestedDir, { recursive: true });

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
await runCast(nestedDir);

const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(output).toContain('TestAgent');
expect(output).toContain('Session Cast');
});

it('discovers agents from team repository in remote mode', async () => {
// Remote mode: project has .squad/config.json with teamRoot pointing to a
// separate team repo. Agents live in <teamRoot>/.squad/agents/, not in the
// project's own .squad/.
const projectRoot = join(TEST_ROOT, 'project');
const teamRoot = join(TEST_ROOT, 'team');

// Bootstrap project's .squad with a config.json that enables remote mode
const projectSq = join(projectRoot, '.squad');
await mkdir(projectSq, { recursive: true });
await writeFile(
join(projectSq, 'config.json'),
JSON.stringify({ version: 1, teamRoot: '../team' }),
);

// Team repo holds the agents
await mkdir(join(teamRoot, '.squad', 'agents', 'remote-agent'), { recursive: true });
await writeFile(
join(teamRoot, '.squad', 'agents', 'remote-agent', 'charter.md'),
`## Identity\n\n**Name:** RemoteAgent\n**Role:** Remote Engineer\n`,
);

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

const { runCast } = await import('@bradygaster/squad-cli/commands/cast');
await runCast(projectRoot);

const output = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
expect(output).toContain('RemoteAgent');
expect(output).toContain('Session Cast');
});
Comment on lines +67 to +152

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests cover local-mode discovery when runCast() is invoked with the repo root, but they don’t cover two important path-resolution scenarios that this change affects: (1) invoking runCast() from a nested subdirectory (which resolveSquadPaths() supports via walk-up), and (2) remote mode where agents should be discovered from paths.teamDir (team repo root). Adding regression coverage for these cases would prevent future path regressions.

Copilot uses AI. Check for mistakes.
});