test: cover the agent-preset wire routes and both restore edges

master moved the RPC surface onto the fetch transport, so the two agentPreset
methods arrived there with nothing driving them. Adds the round trip, plus the
two `recompose` edges that had none: an agent that never composed a preset, so
there is nothing to restore, and a restore that fails because the composition
it reaches for is gone — the roster is a live directory.

Marks the deliberate non-Error rejections in the store specs, which is the
branch they exist to cover.
This commit is contained in:
Yichen Jiang
2026-08-07 16:39:44 +08:00
parent d13cb724ee
commit e265c6038f
3 changed files with 64 additions and 1 deletions

View File

@@ -169,6 +169,7 @@ describe('the agent-preset settings controller', () => {
describe: () => Promise.resolve({
rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
}),
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error branch is what this covers
update: () => Promise.reject(value),
},
} as unknown as IApiClient)
@@ -181,6 +182,7 @@ describe('the agent-preset settings controller', () => {
expect(controller.store.getSnapshot().error).toBe('socket closed')
const failing = new AgentPresetSettingsController({
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error branch is what this covers
agentPresets: { list: () => Promise.reject('offline') },
settings: {
describe: () => Promise.resolve({
@@ -368,7 +370,9 @@ describe('the composer seat controller', () => {
it('reads a rejection that is not an Error', async () => {
const api = {
agentPresets: {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error branch is what this covers
list: () => Promise.reject('offline'),
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error branch is what this covers
select: () => Promise.reject('socket closed'),
},
} as unknown as IApiClient

View File

@@ -226,6 +226,18 @@ describe('unary round trip', () => {
expect(appended.result.ok).toBe(true)
})
it('routes the agent-preset roster and switch through the wire', async () => {
const c = client(scriptedApi())
const listed = await c.agentPresets.list({})
expect(listed.result).toEqual({ ok: true, value: { presets: [] } })
// The switch carries the session it is about: the host refuses one whose
// conversation has started, and it can only know which by id.
const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
})
it('passes business errors through as 200 + err result, not a throw', async () => {
const api = scriptedApi({
sessions: {

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -331,6 +331,53 @@ describe('replacing a composition', () => {
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
})
it('leaves an agent that never composed one with nothing to restore', async () => {
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') })
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// There was no previous composition to put back, so the failure is the
// whole outcome — not a restore that silently invents one.
expect(toolNames(ctx, handle.agent)).toEqual([])
})
it('reports the switch failure when the restore fails too', async () => {
// A preset root this test owns, so removing the composition mid-flight
// cannot disturb the shipped fixtures.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']]
for (const [id, body] of seeded) {
await mkdir(join(root, id))
await writeFile(join(root, id, COMPOSITION_FILE), body)
}
const scoped = new Context()
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
await scoped.plugin(Loader)
scoped.loader.builtins.include = Include
await scoped.plugin(LlmService)
await scoped.plugin(SessionStore)
await scoped.plugin(SystemPrompt, { persona: '' })
await scoped.plugin(ToolRegistry)
await scoped.plugin(AgentRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] })
const handle = await scoped.agents.create({
sessionId: SessionId('sess-restore-gone'),
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'),
})
// The roster is a live directory: the composition the agent came from can
// be gone by the time the restore reaches for it.
await rm(join(root, 'first'), { recursive: true })
await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// The switch failure is the actionable one; the restore's is swallowed.
expect(toolNames(scoped, handle.agent)).toEqual([])
})
it('refuses an unscoped context', async () => {
await expect(ctx.agentPresets.recompose(ctx, 'minimal'))
.rejects.toThrow(/unscoped context/)