Skip to content

Commit 2b69f50

Browse files
[feat][codecane] support local agents in cli (#355)
1 parent d0bdda4 commit 2b69f50

39 files changed

+4827
-447
lines changed

bun.lock

Lines changed: 88 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
"string-width": "^7.2.0",
4545
"react": "^19.0.0",
4646
"react-reconciler": "^0.32.0",
47+
"remark-breaks": "^4.0.0",
48+
"remark-gfm": "^4.0.1",
4749
"remark-parse": "^11.0.0",
4850
"unified": "^11.0.0",
4951
"yoga-layout": "^3.2.1",
@@ -55,6 +57,7 @@
5557
"@types/node": "22",
5658
"@types/react": "^18.3.12",
5759
"@types/react-reconciler": "^0.32.0",
60+
"react-dom": "^19.0.0",
5861
"strip-ansi": "^7.1.2"
5962
}
6063
}
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
import {
2+
describe,
3+
test,
4+
expect,
5+
beforeEach,
6+
afterEach,
7+
mock,
8+
} from 'bun:test'
9+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'
10+
import os from 'os'
11+
import path from 'path'
12+
13+
import { validateAgents } from '@codebuff/sdk'
14+
15+
import {
16+
findAgentsDirectory,
17+
__resetLocalAgentRegistryForTests,
18+
} from '../../utils/local-agent-registry'
19+
import { loadAgentDefinitions } from '../../utils/load-agent-definitions'
20+
import { setProjectRoot, getProjectRoot } from '../../project-files'
21+
22+
const MODEL_NAME = 'anthropic/claude-sonnet-4'
23+
24+
const writeAgentFile = (agentsDir: string, fileName: string, contents: string) =>
25+
writeFileSync(path.join(agentsDir, fileName), contents, 'utf8')
26+
27+
describe('Local Agent Integration', () => {
28+
let tempDir: string
29+
let agentsDir: string
30+
let originalCwd: string
31+
let originalProjectRoot: string | undefined
32+
33+
beforeEach(() => {
34+
tempDir = mkdtempSync(path.join(os.tmpdir(), 'codebuff-agents-'))
35+
originalCwd = process.cwd()
36+
originalProjectRoot = getProjectRoot()
37+
38+
process.chdir(tempDir)
39+
setProjectRoot(tempDir)
40+
__resetLocalAgentRegistryForTests()
41+
42+
agentsDir = path.join(tempDir, '.agents')
43+
})
44+
45+
afterEach(() => {
46+
process.chdir(originalCwd)
47+
setProjectRoot(originalProjectRoot ?? originalCwd)
48+
__resetLocalAgentRegistryForTests()
49+
rmSync(tempDir, { recursive: true, force: true })
50+
mock.restore()
51+
})
52+
53+
test('handles missing .agents directory gracefully', () => {
54+
expect(findAgentsDirectory()).toBeNull()
55+
56+
const definitions = loadAgentDefinitions()
57+
expect(definitions).toHaveLength(0)
58+
})
59+
60+
test('handles empty .agents directory', () => {
61+
mkdirSync(agentsDir, { recursive: true })
62+
63+
expect(findAgentsDirectory()).toBe(agentsDir)
64+
expect(loadAgentDefinitions()).toHaveLength(0)
65+
})
66+
67+
test('skips files lacking displayName/id metadata', () => {
68+
mkdirSync(agentsDir, { recursive: true })
69+
writeAgentFile(
70+
agentsDir,
71+
'no-meta.ts',
72+
`export const nothing = { instructions: 'noop' }`,
73+
)
74+
75+
expect(loadAgentDefinitions()).toHaveLength(0)
76+
})
77+
78+
test('excludes definitions missing required fields', () => {
79+
mkdirSync(agentsDir, { recursive: true })
80+
81+
writeAgentFile(
82+
agentsDir,
83+
'valid.ts',
84+
`
85+
export default {
86+
id: 'valid-agent',
87+
displayName: 'Valid Agent',
88+
model: '${MODEL_NAME}',
89+
instructions: 'Do helpful work'
90+
}
91+
`,
92+
)
93+
94+
writeAgentFile(
95+
agentsDir,
96+
'missing-model.ts',
97+
`
98+
export default {
99+
id: 'incomplete-agent',
100+
displayName: 'Incomplete Agent',
101+
instructions: 'Should be filtered out'
102+
}
103+
`,
104+
)
105+
106+
const definitions = loadAgentDefinitions()
107+
expect(definitions).toEqual(
108+
expect.arrayContaining([expect.objectContaining({ id: 'valid-agent' })]),
109+
)
110+
expect(
111+
definitions.find((agent) => agent.id === 'incomplete-agent'),
112+
).toBeUndefined()
113+
})
114+
115+
test('reports duplicate agent ids', async () => {
116+
mkdirSync(agentsDir, { recursive: true })
117+
118+
writeAgentFile(
119+
agentsDir,
120+
'dup-one.ts',
121+
`
122+
export default {
123+
id: 'duplicate-id',
124+
displayName: 'Agent One',
125+
model: '${MODEL_NAME}',
126+
instructions: 'First duplicate'
127+
}
128+
`,
129+
)
130+
131+
writeAgentFile(
132+
agentsDir,
133+
'dup-two.ts',
134+
`
135+
export default {
136+
id: 'duplicate-id',
137+
displayName: 'Agent Two',
138+
model: '${MODEL_NAME}',
139+
instructions: 'Second duplicate'
140+
}
141+
`,
142+
)
143+
144+
const definitions = loadAgentDefinitions()
145+
const validation = await validateAgents(definitions, { remote: false })
146+
147+
expect(validation.success).toBe(false)
148+
expect(
149+
validation.validationErrors.some((error) =>
150+
error.message.includes('Duplicate'),
151+
),
152+
).toBe(true)
153+
})
154+
155+
test('continues when agent module throws on require', () => {
156+
mkdirSync(agentsDir, { recursive: true })
157+
158+
writeAgentFile(
159+
agentsDir,
160+
'bad.ts',
161+
`
162+
throw new Error('intentional require failure')
163+
`,
164+
)
165+
166+
writeAgentFile(
167+
agentsDir,
168+
'healthy.ts',
169+
`
170+
export default {
171+
id: 'healthy',
172+
displayName: 'Healthy Agent',
173+
model: '${MODEL_NAME}',
174+
instructions: 'Loads fine'
175+
}
176+
`,
177+
)
178+
179+
const definitions = loadAgentDefinitions()
180+
expect(definitions).toHaveLength(1)
181+
expect(definitions[0].id).toBe('healthy')
182+
})
183+
184+
test('ignores files without default export', () => {
185+
mkdirSync(agentsDir, { recursive: true })
186+
187+
writeAgentFile(
188+
agentsDir,
189+
'named-export.ts',
190+
`
191+
export const agent = {
192+
id: 'named-agent',
193+
displayName: 'Named Agent',
194+
model: '${MODEL_NAME}',
195+
instructions: 'Not default'
196+
}
197+
`,
198+
)
199+
200+
expect(loadAgentDefinitions()).toHaveLength(0)
201+
})
202+
203+
test('reloads handleSteps after source edits', () => {
204+
mkdirSync(agentsDir, { recursive: true })
205+
206+
const agentPath = path.join(agentsDir, 'dynamic.ts')
207+
208+
const writeAgentWithDisplayName = (displayName: string) =>
209+
writeFileSync(
210+
agentPath,
211+
`
212+
export default {
213+
id: 'dynamic-agent',
214+
displayName: '${displayName}',
215+
model: '${MODEL_NAME}',
216+
instructions: 'Check for hot reload',
217+
handleSteps: function* () { yield 'STEP' }
218+
}
219+
`,
220+
'utf8',
221+
)
222+
223+
writeAgentWithDisplayName('First Name')
224+
let definitions = loadAgentDefinitions()
225+
expect(definitions[0]?.displayName).toBe('First Name')
226+
227+
writeAgentWithDisplayName('Updated Name')
228+
definitions = loadAgentDefinitions()
229+
expect(definitions[0]?.displayName).toBe('Updated Name')
230+
})
231+
232+
test('discovers nested agent directories', () => {
233+
const nestedDir = path.join(agentsDir, 'level', 'deeper')
234+
mkdirSync(nestedDir, { recursive: true })
235+
236+
writeAgentFile(
237+
nestedDir,
238+
'nested.ts',
239+
`
240+
export default {
241+
id: 'nested-agent',
242+
displayName: 'Nested Agent',
243+
model: '${MODEL_NAME}',
244+
instructions: 'Nested structure'
245+
}
246+
`,
247+
)
248+
249+
const definitions = loadAgentDefinitions()
250+
expect(definitions).toHaveLength(1)
251+
expect(definitions[0].id).toBe('nested-agent')
252+
})
253+
254+
test('ignores non-TypeScript artifacts', () => {
255+
mkdirSync(agentsDir, { recursive: true })
256+
257+
writeAgentFile(
258+
agentsDir,
259+
'real.ts',
260+
`
261+
export default {
262+
id: 'real-agent',
263+
displayName: 'Real Agent',
264+
model: '${MODEL_NAME}',
265+
instructions: 'Legitimate agent'
266+
}
267+
`,
268+
)
269+
writeFileSync(path.join(agentsDir, 'ignored.js'), 'console.log("noop")')
270+
writeFileSync(path.join(agentsDir, 'ignored.d.ts'), 'export {}')
271+
272+
const definitions = loadAgentDefinitions()
273+
expect(definitions).toHaveLength(1)
274+
expect(definitions[0].id).toBe('real-agent')
275+
})
276+
277+
test('surfaces validation errors to UI logic', async () => {
278+
mkdirSync(agentsDir, { recursive: true })
279+
280+
writeAgentFile(
281+
agentsDir,
282+
'invalid-schema.ts',
283+
`
284+
export default {
285+
id: 'invalid-schema',
286+
displayName: 'Invalid Schema Agent',
287+
model: '${MODEL_NAME}',
288+
instructions: 'Uses schema without enabling structured output',
289+
outputSchema: {
290+
type: 'object',
291+
properties: {
292+
summary: { type: 'string' }
293+
}
294+
}
295+
}
296+
`,
297+
)
298+
299+
const definitions = loadAgentDefinitions()
300+
const result = await validateAgents(definitions, { remote: false })
301+
302+
expect(result.success).toBe(false)
303+
expect(
304+
result.validationErrors
305+
.map((error) => error.message)
306+
.join('\n')
307+
.toLowerCase(),
308+
).toContain('structured_output')
309+
})
310+
311+
test('loads agent definitions without auth', () => {
312+
mkdirSync(agentsDir, { recursive: true })
313+
314+
writeAgentFile(
315+
agentsDir,
316+
'valid.ts',
317+
`
318+
export default {
319+
id: 'authless-agent',
320+
displayName: 'Authless Agent',
321+
model: '${MODEL_NAME}',
322+
instructions: 'Agent used when auth is missing'
323+
}
324+
`,
325+
)
326+
327+
const definitions = loadAgentDefinitions()
328+
expect(definitions).toHaveLength(1)
329+
expect(definitions[0].id).toBe('authless-agent')
330+
})
331+
})

0 commit comments

Comments
 (0)