Repository navigation
fix(cast): pass repo root to LocalAgentSource instead of .squad/ dir #892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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) invokingrunCast()from a nested subdirectory (whichresolveSquadPaths()supports via walk-up), and (2) remote mode where agents should be discovered frompaths.teamDir(team repo root). Adding regression coverage for these cases would prevent future path regressions.