Skip to content

Commit 2b40a24

Browse files
committed
feat(cli): levelcode agents — TUI-free swarm console
- fast-path subcommand (dynamic import like doctor): reads team configs and tasks straight from ~/.config/levelcode/{teams,tasks}/, no agent runtime - overview: per-team member status glyphs, in-progress task per agent, task summary, last-active marker; detail view adds per-task blockers - pure formatting module (plainTheme/ansiTheme, NO_COLOR aware) with unit tests + disk-backed integration tests; exit 1 on unknown team (CI-friendly) - listTasks now skips corrupted task files instead of throwing — one bad file no longer breaks the whole team view/status/tools (regression test)
1 parent a7054a7 commit 2b40a24

6 files changed

Lines changed: 533 additions & 10 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2+
import fs from 'fs'
3+
import os from 'os'
4+
import path from 'path'
5+
6+
import {
7+
formatAgentsOverview,
8+
formatTeamDetail,
9+
plainTheme,
10+
} from '../agents-console'
11+
import { collectAgentsData, renderAgentsCommand } from '../run-agents'
12+
13+
import type { TeamConfig, TeamMember, TeamTask } from '@levelcode/common/types/team-config'
14+
15+
const theme = plainTheme
16+
17+
function makeMember(over: Partial<TeamMember> = {}): TeamMember {
18+
return {
19+
agentId: 'agent-1',
20+
name: 'alice',
21+
role: 'senior-engineer',
22+
agentType: 'senior-engineer',
23+
model: 'test-model',
24+
joinedAt: Date.now(),
25+
status: 'idle',
26+
cwd: '/tmp',
27+
...over,
28+
}
29+
}
30+
31+
function makeConfig(over: Partial<TeamConfig> = {}): TeamConfig {
32+
return {
33+
name: 'test-team',
34+
description: '',
35+
createdAt: 1700000000000,
36+
leadAgentId: 'agent-1',
37+
phase: 'planning',
38+
members: [makeMember()],
39+
settings: { maxMembers: 10, autoAssign: false },
40+
...over,
41+
}
42+
}
43+
44+
function makeTask(over: Partial<TeamTask> = {}): TeamTask {
45+
return {
46+
id: '1',
47+
subject: 'Implement thing',
48+
description: '',
49+
status: 'pending',
50+
priority: 'medium',
51+
blockedBy: [],
52+
blocks: [],
53+
phase: 'planning',
54+
createdAt: 1,
55+
updatedAt: 1,
56+
...over,
57+
}
58+
}
59+
60+
describe('formatAgentsOverview', () => {
61+
it('renders an empty-state message when no teams exist', () => {
62+
const out = formatAgentsOverview({ teams: [] }, theme)
63+
expect(out).toContain('No teams found.')
64+
expect(out).toContain('/team:create')
65+
})
66+
67+
it('shows team, lead, members with statuses, and task counts', () => {
68+
const out = formatAgentsOverview(
69+
{
70+
teams: [
71+
{
72+
name: 'test-team',
73+
config: makeConfig({
74+
members: [
75+
makeMember({ name: 'alice', status: 'working' }),
76+
makeMember({ agentId: 'agent-2', name: 'bob', status: 'idle' }),
77+
],
78+
}),
79+
tasks: [
80+
makeTask({ id: '1', status: 'in_progress', owner: 'agent-1' }),
81+
makeTask({ id: '2', status: 'pending' }),
82+
makeTask({ id: '3', status: 'completed' }),
83+
],
84+
isLastActive: true,
85+
},
86+
],
87+
},
88+
theme,
89+
)
90+
91+
expect(out).toContain('test-team')
92+
expect(out).toContain('← last active')
93+
expect(out).toContain('alice')
94+
expect(out).toContain('bob')
95+
expect(out).toContain('#1 Implement thing')
96+
expect(out).toContain('tasks: 1 pending · 1 in progress · 0 blocked · 1 completed')
97+
expect(out).toContain('1 team · 2 agents · 2 open tasks')
98+
})
99+
100+
it('shows a no-members placeholder for empty teams', () => {
101+
const out = formatAgentsOverview(
102+
{ teams: [{ name: 'empty', config: makeConfig({ members: [] }), tasks: [], isLastActive: false }] },
103+
theme,
104+
)
105+
expect(out).toContain('(no members)')
106+
})
107+
})
108+
109+
describe('formatTeamDetail', () => {
110+
it('renders members with lead marker and tasks with blockers', () => {
111+
const out = formatTeamDetail(
112+
'detail-team',
113+
makeConfig({
114+
name: 'detail-team',
115+
phase: 'alpha',
116+
members: [
117+
makeMember({ name: 'lead-1', status: 'active' }),
118+
makeMember({ agentId: 'agent-2', name: 'dev-2', status: 'failed' }),
119+
],
120+
}),
121+
[
122+
makeTask({ id: '1', status: 'in_progress', owner: 'agent-1' }),
123+
makeTask({ id: '2', status: 'blocked', blockedBy: ['1'], subject: 'Depends on 1' }),
124+
],
125+
false,
126+
theme,
127+
)
128+
129+
expect(out).toContain('detail-team')
130+
expect(out).toContain('phase: alpha')
131+
expect(out).toContain('(lead)')
132+
expect(out).toContain('blocked by 1')
133+
expect(out).toContain('Depends on 1')
134+
})
135+
})
136+
137+
describe('agents command (disk-backed)', () => {
138+
let origHome: Record<string, string | undefined>
139+
let tmpHome: string
140+
141+
beforeEach(() => {
142+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-console-'))
143+
// os.homedir() follows USERPROFILE on Windows — set both.
144+
origHome = {
145+
HOME: process.env.HOME,
146+
USERPROFILE: process.env.USERPROFILE,
147+
}
148+
process.env.HOME = tmpHome
149+
process.env.USERPROFILE = tmpHome
150+
})
151+
152+
afterEach(() => {
153+
for (const [key, value] of Object.entries(origHome)) {
154+
if (value === undefined) delete process.env[key]
155+
else process.env[key] = value
156+
}
157+
fs.rmSync(tmpHome, { recursive: true, force: true })
158+
})
159+
160+
function seedTeam(name: string): void {
161+
const teamDir = path.join(tmpHome, '.config', 'levelcode', 'teams', name)
162+
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true })
163+
const config = makeConfig({
164+
name,
165+
members: [makeMember({ agentId: `lead-${name}`, name: 'team-lead', status: 'working' })],
166+
leadAgentId: `lead-${name}`,
167+
})
168+
fs.writeFileSync(
169+
path.join(teamDir, 'config.json'),
170+
JSON.stringify(config),
171+
'utf-8',
172+
)
173+
const tasksDir = path.join(tmpHome, '.config', 'levelcode', 'tasks', name)
174+
fs.mkdirSync(tasksDir, { recursive: true })
175+
fs.writeFileSync(
176+
path.join(tasksDir, '1.json'),
177+
JSON.stringify(makeTask({ id: '1', status: 'in_progress', owner: `lead-${name}` })),
178+
'utf-8',
179+
)
180+
}
181+
182+
it('collectAgentsData reads seeded teams from disk', () => {
183+
seedTeam('disk-team')
184+
const data = collectAgentsData()
185+
186+
expect(data.teams).toHaveLength(1)
187+
expect(data.teams[0]!.name).toBe('disk-team')
188+
expect(data.teams[0]!.tasks).toHaveLength(1)
189+
})
190+
191+
it('renderAgentsCommand renders the overview and errors on unknown team', () => {
192+
seedTeam('render-team')
193+
194+
const overview = renderAgentsCommand()
195+
expect(overview.exitCode).toBe(0)
196+
expect(overview.output).toContain('render-team')
197+
198+
const missing = renderAgentsCommand('no-such-team')
199+
expect(missing.exitCode).toBe(1)
200+
expect(missing.output).toContain('No team named "no-such-team"')
201+
202+
const detail = renderAgentsCommand('render-team')
203+
expect(detail.exitCode).toBe(0)
204+
expect(detail.output).toContain('render-team')
205+
expect(detail.output).toContain('Tasks')
206+
})
207+
})

0 commit comments

Comments
 (0)