feat(web): let a blank session switch its agent preset

`agentPreset.select` recomposes one session's agent from a different preset.
It is allowed only while the session is blank — once a turn has run, that
history was produced under the preset's tools and swapping them would strand
logged tool calls, so the attempt answers `agent-preset-locked`.

The agent and the session survive; only the preset subtree is swapped. That
was forced by what the host actually owns: api-proxy discards the `AgentHandle`
it creates, and there is no delete RPC, so neither disposing nor recreating the
session was available. Swapping the subtree is also the better answer — the
session id, its workspace attachment, and its projections all stay put.

`recompose` is unmount-then-mount because two compositions cannot coexist: both
would register the same tool names into one layer. So it resolves the new
preset BEFORE tearing anything down (an unknown id is a no-op) and restores
the previous composition when the new one fails to mount, rather than leaving
the agent with no tools at all. Both paths are pinned by test.

Also restores the English half of the `agentPreset.list` README paragraph,
which was lost before the previous commit — and `verify-translation-pairing
--write` recorded the pair as consistent anyway, because it records whatever
state it finds rather than checking the two sides say the same thing.
This commit is contained in:
Yichen Jiang
2026-08-04 10:26:43 +08:00
parent 6758da87ae
commit bf4356cf35
19 changed files with 295 additions and 11 deletions

View File

@@ -52,6 +52,10 @@ function roster(ids: readonly string[]): unknown {
const perAgent = services.get(String(agent.id))
return perAgent?.[name]
},
recompose: (_ctx: Context, id: string) => {
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
},
}
}
@@ -262,3 +266,56 @@ describe('agentPreset.list', () => {
expect(response.result.value.presets).toEqual([])
})
})
describe('agentPreset.select', () => {
it('recomposes a blank session', async () => {
const { api } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
const response = await api.agentPresets.select(
request({ sessionId: SessionId('sel-1'), agentPreset: 'core-web' }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.agentPreset).toBe('core-web')
})
it('refuses once the conversation has started', async () => {
const { api, ctx } = await harness(['standard', 'core-web'])
await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
// One turn is enough: the history from here on was produced under
// `standard`'s tools, and a swap would strand those tool calls.
ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
const response = await api.agentPresets.select(
request({ sessionId: SessionId('sel-2'), agentPreset: 'core-web' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-locked')
})
it('reports an unknown preset without disturbing the session', async () => {
const { api } = await harness(['standard'])
await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
const response = await api.agentPresets.select(
request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-not-found')
})
it('reports a deployment that composes no presets', async () => {
const { api } = await harness()
await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
const response = await api.agentPresets.select(
request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-not-found')
})
})

View File

@@ -87,7 +87,11 @@ function scriptedApi(overrides: {
...overrides.commands,
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets },
agentPresets: {
list: r => ok(r, { presets: [] }),
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
...overrides.agentPresets,
},
goals: {
create: err,
edit: err,

View File

@@ -197,6 +197,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
list(request: RpcRequest<{}>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } })
},
select(request: RpcRequest<{ agentPreset: string }>) {
const value = { agentPreset: request.payload.agentPreset }
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
},
skills: {
async list(request) {