Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

# Conflicts:
#	apps/cli/tests/web-agent-presets.e2e.ts
#	packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-09 22:48:47 +08:00
44 changed files with 809 additions and 236 deletions

View File

@@ -2680,9 +2680,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
skills: {
// Skill lookup never touches the Agent registry: the session address
// resolves to a canonical cwd from the host-resident session header, so
// listing skills cannot create or resume an agent as a side effect.
// Skill lookup never creates or resumes an agent: the session address
// resolves to a canonical cwd from the host-resident session header, and
// the view scope is the live agent or the preset's standing key.
async list(request) {
const { sessionId } = request.payload
const session = ctx.sessions.get(sessionId)
@@ -2699,12 +2699,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
}
const cwd = session.header.cwd
// The registry is per session when a preset mounts one — a preset
// ships its own skill directory, so the catalog IS the session's — and
// that instance sits behind an `isolate` realm no host context
// resolves. Address it through the live agent; `agents.get` keeps the
// no-side-effect stance above (a cold session creates nothing and
// falls through to whatever the host composes).
// The host registry is layered per scope and serves every session. A
// composition may still realm-mount its own registry instead; that
// instance is invisible to host contexts, so address it through the
// live agent (`agents.get` keeps the no-side-effect stance above).
const live = ctx.agents.get(sessionId)
const presets = ctx.get('agentPresets')
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
@@ -2716,8 +2714,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (skillRegistry === undefined) {
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
}
// The scope presenters resolve in — the live agent, else the recorded
// preset's standing key, else the global layer — so a cold session's
// '/' popup lists the catalog its composition actually serves.
const scope = await presenterScopeFor(sessionId, session.header)
try {
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,

View File

@@ -384,6 +384,59 @@ describe('agentPreset.select', () => {
})
})
describe('skills over the layered host registry', () => {
it('passes the live agent as the view scope to the host registry', async () => {
const { api, ctx } = await harness(['standard'])
const seen: unknown[] = []
ctx.provide('skills', {
list: (options: { scope?: unknown }) => {
seen.push(options.scope)
return Promise.resolve([])
},
} as never)
await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
})
it('resolves a cold session to its recorded preset standing key', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
const seen: unknown[] = []
ctx.provide('skills', {
list: (options: { scope?: unknown }) => {
seen.push(options.scope)
return Promise.resolve([])
},
} as never)
ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } })
const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
expect(seen).toEqual([standingKeys.get('core-web')])
})
it('serves the global view when the roster no longer supplies the recorded preset', async () => {
const { api, ctx } = await harness(['standard'])
const seen: unknown[] = []
ctx.provide('skills', {
list: (options: { scope?: unknown }) => {
seen.push(options.scope)
return Promise.resolve([])
},
} as never)
ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
expect(seen).toEqual([undefined])
})
})
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])