Skip to content

Commit 3f16403

Browse files
Merge web changes from brandon/base-lite branch
- Rename jest.config.js to jest.config.cjs with additional module mapping - Add next-auth mocking to prevent SessionProvider requirements in tests - Implement comprehensive subagent resolution functionality with validation - Add extensive test coverage for subagent resolution logic 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 2e38497 commit 3f16403

File tree

5 files changed

+272
-30
lines changed

5 files changed

+272
-30
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const config = {
1111
moduleNameMapper: {
1212
'^@/(.*)$': '<rootDir>/src/$1',
1313
'^common/(.*)$': '<rootDir>/../common/src/$1',
14+
'^@codebuff/internal/xml-parser$': '<rootDir>/src/test-stubs/xml-parser.ts',
1415
},
1516
}
1617

web/src/__tests__/unit/home.spec.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { render, screen } from '@testing-library/react'
22

3+
// Mock next-auth session to avoid requiring a SessionProvider
4+
jest.mock('next-auth/react', () => ({
5+
useSession: () => ({ data: null, status: 'unauthenticated' }),
6+
}))
7+
38
import Home from '../../app/page'
49

510
// Mock window.matchMedia
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { resolveAndValidateSubagents, SubagentResolutionError, type AgentVersionEntry } from '../subagent-resolution'
2+
3+
describe('resolveAndValidateSubagents', () => {
4+
const requestedPublisherId = 'me'
5+
6+
function makeAgents(entries: { id: string; version: string; spawnableAgents?: string[] }[]): AgentVersionEntry[] {
7+
return entries.map((e) => ({ id: e.id, version: e.version, data: { id: e.id, version: e.version, spawnableAgents: e.spawnableAgents } }))
8+
}
9+
10+
test('simple same-publisher id resolves to batch version when present', async () => {
11+
const agents = makeAgents([
12+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['file-picker'] },
13+
{ id: 'file-picker', version: '1.0.11' },
14+
])
15+
16+
const exists = (full: string) => full === 'me/file-picker@1.0.11' || full === 'me/file-explorer@1.0.10'
17+
const latest = async () => null
18+
19+
await resolveAndValidateSubagents({
20+
agents,
21+
requestedPublisherId,
22+
existsInSamePublisher: exists,
23+
getLatestPublishedVersion: latest,
24+
})
25+
26+
expect(agents[0].data.spawnableAgents).toEqual(['me/file-picker@1.0.11'])
27+
})
28+
29+
test('simple same-publisher id resolves to latest published when not in batch', async () => {
30+
const agents = makeAgents([
31+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['file-picker'] },
32+
])
33+
34+
const exists = (full: string) => full === 'me/file-explorer@1.0.10' || full === 'me/file-picker@1.0.9'
35+
const latest = async (pub: string, id: string) => (pub === 'me' && id === 'file-picker' ? '1.0.9' : null)
36+
37+
await resolveAndValidateSubagents({ agents, requestedPublisherId, existsInSamePublisher: exists, getLatestPublishedVersion: latest })
38+
39+
expect(agents[0].data.spawnableAgents).toEqual(['me/file-picker@1.0.9'])
40+
})
41+
42+
test('fully-qualified same-publisher refs are kept and validated', async () => {
43+
const agents = makeAgents([
44+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['me/file-picker@1.0.8'] },
45+
])
46+
47+
const exists = (full: string) => full === 'me/file-picker@1.0.8' || full === 'me/file-explorer@1.0.10'
48+
const latest = async () => null
49+
50+
await resolveAndValidateSubagents({ agents, requestedPublisherId, existsInSamePublisher: exists, getLatestPublishedVersion: latest })
51+
52+
expect(agents[0].data.spawnableAgents).toEqual(['me/file-picker@1.0.8'])
53+
})
54+
55+
test('cross-publisher simple refs resolve to latest without same-publisher validation', async () => {
56+
const agents = makeAgents([
57+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['other/file-picker'] },
58+
])
59+
60+
const exists = (full: string) => full === 'me/file-explorer@1.0.10'
61+
const latest = async (pub: string, id: string) => (pub === 'other' && id === 'file-picker' ? '2.0.1' : null)
62+
63+
await resolveAndValidateSubagents({ agents, requestedPublisherId, existsInSamePublisher: exists, getLatestPublishedVersion: latest })
64+
65+
expect(agents[0].data.spawnableAgents).toEqual(['other/file-picker@2.0.1'])
66+
})
67+
68+
test('throws when simple ref has no published versions', async () => {
69+
const agents = makeAgents([
70+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['missing'] },
71+
])
72+
73+
const exists = (full: string) => full === 'me/file-explorer@1.0.10'
74+
const latest = async () => null
75+
76+
await expect(
77+
resolveAndValidateSubagents({ agents, requestedPublisherId, existsInSamePublisher: exists, getLatestPublishedVersion: latest })
78+
).rejects.toBeInstanceOf(SubagentResolutionError)
79+
})
80+
81+
test('throws when fully-qualified same-publisher ref does not exist', async () => {
82+
const agents = makeAgents([
83+
{ id: 'file-explorer', version: '1.0.10', spawnableAgents: ['me/file-picker@1.0.0'] },
84+
])
85+
86+
const exists = (full: string) => full === 'me/file-explorer@1.0.10' // not the picker
87+
const latest = async () => null
88+
89+
await expect(
90+
resolveAndValidateSubagents({ agents, requestedPublisherId, existsInSamePublisher: exists, getLatestPublishedVersion: latest })
91+
).rejects.toBeInstanceOf(SubagentResolutionError)
92+
})
93+
})

web/src/app/api/agents/publish/route.ts

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,23 @@ import {
88
stringifyVersion,
99
versionExists,
1010
} from '@codebuff/internal'
11-
import { eq, and, or } from 'drizzle-orm'
11+
import { eq, and, or, desc } from 'drizzle-orm'
1212
import { NextResponse } from 'next/server'
1313
import { getServerSession } from 'next-auth'
1414

15+
import { logger } from '@/util/logger'
16+
17+
import {
18+
resolveAndValidateSubagents,
19+
SubagentResolutionError,
20+
type AgentVersionEntry,
21+
} from './subagent-resolution'
1522
import { authOptions } from '../../auth/[...nextauth]/auth-options'
1623

1724
import type { DynamicAgentTemplate } from '@codebuff/common/types/dynamic-agent-template'
1825
import type { Version } from '@codebuff/internal'
1926
import type { NextRequest } from 'next/server'
2027

21-
import { logger } from '@/util/logger'
22-
2328
async function getPublishedAgentIds(publisherId: string) {
2429
const agents = await db
2530
.select({
@@ -228,34 +233,57 @@ export async function POST(request: NextRequest) {
228233
)
229234
const publishedAgentIds = await getPublishedAgentIds(requestedPublisherId)
230235

231-
for (const agent of agents) {
232-
if (agent.spawnableAgents) {
233-
for (const subagent of agent.spawnableAgents) {
234-
const versionMatch = subagent.match(/^([^/]+)\/(.+)@(.+)$/)
235-
if (!versionMatch) {
236-
return NextResponse.json(
237-
{
238-
error: 'Invalid spawnable agent format',
239-
details: `Agent '${agent.id}' references spawnable agent '${subagent}' with an invalid format. Expected format: {publisherId}/{agentId}@{version}`,
240-
},
241-
{ status: 400 }
242-
)
243-
}
244-
245-
if (
246-
!publishingAgentIds.has(subagent) &&
247-
!publishedAgentIds.has(subagent)
248-
) {
249-
return NextResponse.json(
250-
{
251-
error: 'Invalid spawnable agent',
252-
details: `Agent '${agent.id}' references spawnable agent '${subagent}' which is not published and not included in this request.`,
253-
},
254-
{ status: 400 }
255-
)
256-
}
257-
}
236+
const existsInSamePublisher = (full: string) =>
237+
publishingAgentIds.has(full) || publishedAgentIds.has(full)
238+
239+
async function getLatestPublishedVersion(
240+
publisherId: string,
241+
agentId: string
242+
): Promise<string | null> {
243+
const latest = await db
244+
.select({ version: schema.agentConfig.version })
245+
.from(schema.agentConfig)
246+
.where(
247+
and(
248+
eq(schema.agentConfig.publisher_id, publisherId),
249+
eq(schema.agentConfig.id, agentId)
250+
)
251+
)
252+
.orderBy(
253+
desc(schema.agentConfig.major),
254+
desc(schema.agentConfig.minor),
255+
desc(schema.agentConfig.patch)
256+
)
257+
.limit(1)
258+
.then((rows) => rows[0])
259+
return latest?.version ?? null
260+
}
261+
262+
const agentEntries: AgentVersionEntry[] = agentVersions.map((av) => ({
263+
id: av.id,
264+
version: stringifyVersion(av.version),
265+
data: av.data,
266+
}))
267+
268+
try {
269+
await resolveAndValidateSubagents({
270+
agents: agentEntries,
271+
requestedPublisherId,
272+
existsInSamePublisher,
273+
getLatestPublishedVersion,
274+
})
275+
} catch (err) {
276+
if (err instanceof SubagentResolutionError) {
277+
return NextResponse.json(
278+
{
279+
error: 'Invalid spawnable agent',
280+
details: err.message,
281+
hint: "To fix this, also publish the referenced agent (include it in the same request's data array, or publish it first for the same publisher).",
282+
},
283+
{ status: 400 }
284+
)
258285
}
286+
throw err
259287
}
260288

261289
// If we get here, all agents can be published. Insert them all in a transaction
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
export class SubagentResolutionError extends Error {}
2+
3+
export type AgentVersionEntry = {
4+
id: string
5+
version: string // stringified
6+
data: any // must contain optional `spawnableAgents?: string[]`
7+
}
8+
9+
// Resolves subagent references to fully-qualified form and validates them.
10+
// Behavior parity with existing route logic:
11+
// - For already fully-qualified refs (publisher/id@version):
12+
// - Validate only for same-publisher via `existsInSamePublisher`, else error
13+
// - For simple refs ("id" or "publisher/id"):
14+
// - Prefer batch version for same publisher
15+
// - Otherwise resolve to latest published via `getLatestPublishedVersion`
16+
// - For same-publisher, validate existence via `existsInSamePublisher`
17+
export async function resolveAndValidateSubagents(params: {
18+
agents: AgentVersionEntry[]
19+
requestedPublisherId: string
20+
// Returns latest published version string or null if none
21+
getLatestPublishedVersion: (
22+
publisherId: string,
23+
agentId: string
24+
) => Promise<string | null>
25+
// Checks if a fully-qualified ref exists within the same publisher context
26+
existsInSamePublisher: (fullyQualifiedRef: string) => boolean
27+
}): Promise<void> {
28+
const {
29+
agents,
30+
requestedPublisherId,
31+
getLatestPublishedVersion,
32+
existsInSamePublisher,
33+
} = params
34+
35+
const publishingVersionsById = new Map<string, string>(
36+
agents.map(({ id, version }) => [id, version])
37+
)
38+
39+
const fqRegex = /^([^/]+)\/(.+)@(.+)$/
40+
const publisherIdRegex = /^([^/]+)\/(.+)$/
41+
42+
for (const agentEntry of agents) {
43+
const agent = agentEntry.data
44+
45+
// Determine input list with backward-compat (prefer spawnableAgents)
46+
const inputList: string[] = (agent?.spawnableAgents ??
47+
agent?.subagents ??
48+
[]) as string[]
49+
if (!inputList || inputList.length === 0) continue
50+
51+
const transformed: string[] = []
52+
// Iterate over normalized list (supports spawnableAgents or legacy subagents)
53+
for (const sub of inputList) {
54+
const fqMatch = sub.match(fqRegex)
55+
if (fqMatch) {
56+
const fullKey = sub
57+
// Validate only for same publisher (to match existing behavior)
58+
const [pub] = fullKey.split('/')
59+
if (pub === requestedPublisherId) {
60+
if (!existsInSamePublisher(fullKey)) {
61+
throw new SubagentResolutionError(
62+
`Invalid spawnable agent: '${sub}' is not published and not included in this request.`
63+
)
64+
}
65+
}
66+
transformed.push(fullKey)
67+
continue
68+
}
69+
70+
// Handle simple refs: 'id' or 'publisher/id'
71+
let targetPublisher = requestedPublisherId
72+
let targetId = sub
73+
const pubMatch = sub.match(publisherIdRegex)
74+
if (pubMatch) {
75+
targetPublisher = pubMatch[1]!
76+
targetId = pubMatch[2]!
77+
}
78+
79+
// Prefer batch version for same publisher
80+
let resolvedVersion: string | null = null
81+
if (
82+
targetPublisher === requestedPublisherId &&
83+
publishingVersionsById.has(targetId)
84+
) {
85+
resolvedVersion = publishingVersionsById.get(targetId)!
86+
} else {
87+
resolvedVersion = await getLatestPublishedVersion(
88+
targetPublisher,
89+
targetId
90+
)
91+
}
92+
93+
if (!resolvedVersion) {
94+
throw new SubagentResolutionError(
95+
`Invalid spawnable agent: '${sub}' has no published versions to resolve to.`
96+
)
97+
}
98+
99+
const full = `${targetPublisher}/${targetId}@${resolvedVersion}`
100+
101+
if (
102+
targetPublisher === requestedPublisherId &&
103+
!existsInSamePublisher(full)
104+
) {
105+
throw new SubagentResolutionError(
106+
`Invalid spawnable agent: '${sub}' resolves to '${full}' but is not published and not included in this request.`
107+
)
108+
}
109+
110+
transformed.push(full)
111+
}
112+
113+
agent.spawnableAgents = transformed
114+
}
115+
}

0 commit comments

Comments
 (0)