Просмотр исходного кода

test(ui-agent-preset): cover the surface where it is introduced

The package landed here with one store spec; the rest of its tests were written
three layers up, so this layer and the two above it failed the per-file
coverage gate on 191 locations while every test passed.

Adds the node half's invariant companion, the two components, the registration
apply, and the stores' failure paths. `currentValue` drops a `?? ''` that an
empty roster already returned before reaching — the same shape the later layer
arrived at, so the two converge instead of conflicting.
Yichen Jiang 1 месяц назад
Родитель
Сommit
c96b480bc4

+ 5 - 2
packages/client/ui-agent-preset/src/client/settings-store.ts

@@ -73,7 +73,8 @@ export class AgentPresetSettingsController {
         return
       }
       const presets = response.result.value.presets
-      if (presets.length === 0) {
+      const [first] = presets
+      if (first === undefined) {
         this.set({ status: 'unavailable', options: [], currentValue: '' })
         return
       }
@@ -87,7 +88,9 @@ export class AgentPresetSettingsController {
         error: null,
         writable: described.result.ok && described.result.value.writable,
         options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
-        currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
+        // A roster can mark nothing default: settings can name a preset that
+        // was since deleted, and the picker still has to show something.
+        currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
       })
     } catch (error) {
       this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })

+ 211 - 0
packages/client/ui-agent-preset/tests/apply.spec.ts

@@ -0,0 +1,211 @@
+/**
+ * Registration: the General row and the composer seat both come from one
+ * apply, and each defers until the slot it fills has been declared. A pushed
+ * settings change or a reconnect re-reads the roster the row is showing.
+ */
+
+import { Context } from 'cordis'
+import { describe, expect, it, vi } from 'vitest'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
+import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
+import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
+import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
+import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
+import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
+
+const ROSTER = {
+  rpcId: 'r',
+  result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }] } },
+}
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotsService).await()
+  ctx.provide('locale', new LocaleService(ctx))
+  const calls: string[] = []
+  ctx.provide('connection', {
+    api: {
+      agentPresets: {
+        list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
+        select: (payload: { agentPreset: string }) => {
+          calls.push(`select:${payload.agentPreset}`)
+          return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
+        },
+      },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r',
+          result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+        update: (payload: { patch: unknown }) => {
+          calls.push(`settings:${JSON.stringify(payload.patch)}`)
+          return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } })
+        },
+      },
+    },
+  } as never)
+  return { ctx, slots: ctx.get('slots') as SlotsService, calls }
+}
+
+function declareRoot(slots: SlotsService): () => void {
+  return slots.register({
+    name: 'root',
+    children: {
+      'settings.general.item': { kind: 'list', scope: 'root' },
+      conversation: { kind: 'single', scope: 'root' },
+    },
+  } as never, () => null)
+}
+
+/** The conversation's own declaration, which the composer seat waits for. */
+function declareConversation(slots: SlotsService): () => void {
+  return slots.register({
+    name: 'conversation',
+    children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
+  } as never, () => null)
+}
+
+/** A sessions double whose list the seat reads its summary from. */
+function sessionsDouble(byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>) {
+  return { list: { getSnapshot: () => ({ byId }), subscribe: () => () => {} } }
+}
+
+describe('ui-agent-preset apply', () => {
+  it('declares the services it uses', () => {
+    expect(inject).toEqual(['slots', 'locale', 'connection'])
+  })
+
+  it('registers the General row', async () => {
+    const { ctx, slots } = await bench()
+    declareRoot(slots)
+
+    await ctx.plugin({ inject: [...inject], apply }).await()
+
+    const row = slots.entries('settings.general.item')[0]!
+    expect(row.component).toBe(AgentPresetRow)
+    expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 })
+  })
+
+  it('registers into a declaration that arrives after apply', async () => {
+    const { ctx, slots } = await bench()
+    await ctx.plugin({ inject: [...inject], apply }).await()
+
+    declareRoot(slots)
+
+    await vi.waitFor(() => { expect(slots.entries('settings.general.item')).toHaveLength(1) })
+  })
+
+  it('routes the row\'s actions to one controller', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+
+    const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
+    await row.load()
+    await row.select('standard')
+
+    expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
+    // Already the default, so the row writes nothing — one controller behind
+    // both thunks is what makes it able to know that.
+    expect(calls).toEqual(['list'])
+  })
+
+  it('refreshes the row on its own namespace, and ignores others', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
+    await row.load()
+    const before = calls.length
+
+    ctx.emit('settings/changed', 'agent-presets')
+    await vi.waitFor(() => { expect(calls.length).toBe(before + 1) })
+    const afterRelevant = calls.length
+
+    ctx.emit('settings/changed', 'llm-deepseek')
+    await Promise.resolve()
+
+    // An unrelated namespace moves nothing, which rules out a blanket refresh
+    // on every settings write.
+    expect(calls.length).toBe(afterRelevant)
+  })
+
+  it('re-reads the row when the connection comes back', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
+    await row.load()
+    const before = calls.length
+
+    ctx.emit('connection/reset')
+
+    // A reconnect can land on a host whose roster changed under the browser.
+    await vi.waitFor(() => { expect(calls.length).toBe(before + 1) })
+  })
+
+  it('registers the composer seat and drops it on disposal', async () => {
+    const { ctx, slots } = await bench()
+    declareRoot(slots)
+    declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    ctx.provide('sessions', sessionsDouble({}) as never)
+
+    const fiber = await ctx.plugin({ inject: [...inject], apply }).await()
+    const seat = slots.entries('conversation.input.agentPreset')[0]!
+    expect(seat.component).toBe(AgentPresetSeat)
+
+    await fiber.dispose()
+
+    expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
+  })
+
+  it('gives each session its own seat controller, and keeps it', async () => {
+    const { ctx, slots } = await bench()
+    declareRoot(slots)
+    declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    ctx.provide('sessions', sessionsDouble({
+      s1: { id: 's1', blank: true, agentPreset: 'standard' },
+      s2: { id: 's2', blank: false },
+    }) as never)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const face = slots.entries('conversation.input.agentPreset')[0]!
+      .inject as unknown as (id: string) => AgentPresetSeatInjected
+
+    const first = face('s1')
+    const second = face('s2')
+
+    // The switch and the "may it still switch" bit are per-session facts, so
+    // two sessions never share a store — and asking twice never rebuilds one.
+    expect(first.hooks.agentPresetSeat).not.toBe(second.hooks.agentPresetSeat)
+    expect(face('s1').hooks.agentPresetSeat).toBe(first.hooks.agentPresetSeat)
+
+    await first.load()
+    await second.load()
+
+    // s1 records a preset and is blank; s2 has started, so it may not switch.
+    expect(first.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'standard', switchable: true })
+    expect(second.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'standard', switchable: false })
+  })
+
+  it('reports no session state for a seat the list has never seen', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    ctx.provide('sessions', sessionsDouble({}) as never)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const face = (slots.entries('conversation.input.agentPreset')[0]!
+      .inject as unknown as (id: string) => AgentPresetSeatInjected)('ghost')
+
+    await face.load()
+    await face.select('standard')
+
+    // No summary means nothing is known to be blank, so the seat refuses the
+    // switch rather than sending one the host would reject.
+    expect(face.hooks.agentPresetSeat.getSnapshot().switchable).toBe(false)
+    expect(calls).toEqual(['list'])
+  })
+})

+ 231 - 0
packages/client/ui-agent-preset/tests/components.spec.tsx

@@ -0,0 +1,231 @@
+// @vitest-environment jsdom
+/**
+ * The two preset surfaces: the General-settings row naming the default for
+ * later sessions, and the composer seat naming this one's. The split is the
+ * host's rule — a session's history is produced under its preset's tools, so
+ * the choice is only ever offered while the conversation has not started.
+ */
+
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
+import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
+import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
+import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx'
+import type { AgentPresetSettingsState } from '../src/client/settings-store.ts'
+import type { AgentPresetSeatState } from '../src/client/seat-store.ts'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const ROW_READY: AgentPresetSettingsState = {
+  status: 'ready',
+  error: null,
+  writable: true,
+  currentValue: 'standard',
+  options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
+}
+
+const SEAT_READY: AgentPresetSeatState = {
+  current: 'standard',
+  options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
+  switchable: true,
+  busy: false,
+  error: null,
+}
+
+function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
+  const store = createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, ...state })
+  const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
+  render(<AgentPresetRow {...({
+    ...actions,
+    useAgentPreset: bindSnapshotSelector(store),
+    t: (key: keyof typeof en) => en[key],
+  } as unknown as AgentPresetRowProps)} />)
+  return { ...actions, store }
+}
+
+function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
+  const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
+  const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
+  render(<AgentPresetSeat {...({
+    ...actions,
+    locked,
+    useAgentPresetSeat: bindSnapshotSelector(store),
+    t: (key: keyof typeof en) => en[key],
+  } as unknown as AgentPresetSeatProps)} />)
+  return { ...actions, store }
+}
+
+describe('the General-settings row', () => {
+  it('reads the roster once and shows the current default', async () => {
+    const { load } = renderRow()
+
+    await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
+    expect(screen.getByRole('button').textContent).toContain('standard')
+    expect(screen.getByText(en.title)).toBeTruthy()
+  })
+
+  it('marks a locally authored option as local', () => {
+    renderRow()
+
+    fireEvent.click(screen.getByRole('button'))
+
+    // A locally authored preset is as privileged as the plugins it names, so
+    // the list says so rather than presenting every row as shipped and vetted.
+    expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
+    // A shipped preset carries no such mark — the id is all the menu says.
+    expect(screen.getByRole('menu').textContent).toBe(`standardmine · ${en.userTrust}`)
+  })
+
+  it('writes the picked preset and closes the menu', () => {
+    const { select } = renderRow()
+
+    fireEvent.click(screen.getByRole('button'))
+    fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
+
+    expect(select).toHaveBeenCalledWith('mine')
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('closes on an outside dismissal', () => {
+    renderRow()
+    fireEvent.click(screen.getByRole('button'))
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('says it is loading before the roster answers', () => {
+    renderRow({ status: 'loading', currentValue: '' })
+
+    expect(screen.getByRole('button').textContent).toContain(en.loading)
+    expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)
+  })
+
+  it('shows a failure in place of the description', () => {
+    renderRow({ error: 'boom' })
+
+    expect(screen.getByRole('alert').textContent).toBe('boom')
+  })
+
+  it('renders nothing when the deployment composes no presets', () => {
+    const { container } = render(<AgentPresetRow {...({
+      load: vi.fn(() => Promise.resolve()),
+      select: vi.fn(() => Promise.resolve()),
+      useAgentPreset: bindSnapshotSelector(
+        createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, status: 'unavailable' }),
+      ),
+      t: (key: keyof typeof en) => en[key],
+    } as unknown as AgentPresetRowProps)} />)
+
+    expect(container.textContent).toBe('')
+  })
+
+  it('closes and locks the menu when the settings turn read-only', () => {
+    const { store } = renderRow()
+    fireEvent.click(screen.getByRole('button'))
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+
+    act(() => { store.set({ ...ROW_READY, writable: false }) })
+
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+    expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)
+  })
+})
+
+describe('the composer seat', () => {
+  it('reads this session\'s state once and shows the preset it runs', async () => {
+    const { load } = renderSeat()
+
+    await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
+    expect(screen.getByRole('button').textContent).toContain('standard')
+    expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
+  })
+
+  it('switches this session and closes the menu', () => {
+    const { select } = renderSeat()
+
+    fireEvent.click(screen.getByRole('button'))
+    fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
+
+    expect(select).toHaveBeenCalledWith('mine')
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('closes on an outside dismissal', () => {
+    renderSeat()
+    fireEvent.click(screen.getByRole('button'))
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('offers no control once the conversation has started', () => {
+    renderSeat({ switchable: false })
+
+    // A disabled menu would suggest the preset could still be changed; past
+    // the first turn it is a fact about the session, not a control.
+    expect(screen.queryByRole('button')).toBeNull()
+    expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
+  })
+
+  it('closes an open menu when the session stops being switchable', () => {
+    const { store } = renderSeat()
+    fireEvent.click(screen.getByRole('button'))
+    expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+
+    act(() => { store.set({ ...SEAT_READY, switchable: false }) })
+
+    expect(screen.queryByRole('button')).toBeNull()
+  })
+
+  it('disables the trigger while a switch is in flight, and while the composer is', () => {
+    const { store } = renderSeat({ busy: true })
+    expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)
+
+    act(() => { store.set(SEAT_READY) })
+    expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)
+
+    cleanup()
+    renderSeat({}, true)
+    expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)
+  })
+
+  it('shows a refused switch on the trigger', () => {
+    renderSeat({ error: 'agent-preset-locked' })
+
+    expect(screen.getByRole('button').getAttribute('title')).toBe('agent-preset-locked')
+  })
+
+  it('renders nothing before the roster arrives or when there is none', () => {
+    const { container } = render(<AgentPresetSeat {...({
+      load: vi.fn(() => Promise.resolve()),
+      select: vi.fn(() => Promise.resolve()),
+      locked: false,
+      useAgentPresetSeat: bindSnapshotSelector(
+        createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] }),
+      ),
+      t: (key: keyof typeof en) => en[key],
+    } as unknown as AgentPresetSeatProps)} />)
+
+    expect(container.textContent).toBe('')
+    cleanup()
+
+    const bare = render(<AgentPresetSeat {...({
+      load: vi.fn(() => Promise.resolve()),
+      select: vi.fn(() => Promise.resolve()),
+      locked: false,
+      useAgentPresetSeat: bindSnapshotSelector(
+        createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' }),
+      ),
+      t: (key: keyof typeof en) => en[key],
+    } as unknown as AgentPresetSeatProps)} />)
+
+    expect(bare.container.textContent).toBe('')
+  })
+})

+ 25 - 0
packages/client/ui-agent-preset/tests/invariant.spec.ts

@@ -0,0 +1,25 @@
+/** The package's node half: an empty host body and an explained empty invariant companion. */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant'
+
+describe('invariant companion', () => {
+  it('reserves package ownership with an empty installer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+
+    await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined()
+  })
+
+  it('has an empty node half', async () => {
+    const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset')
+
+    // The host body exists only so the plugin appears in the host cordis.yml;
+    // every surface this package ships lives in the browser half.
+    apply()
+
+    expect(typeof apply).toBe('function')
+  })
+})

+ 181 - 0
packages/client/ui-agent-preset/tests/settings-store.spec.ts

@@ -143,6 +143,130 @@ describe('the agent-preset settings controller', () => {
     expect(state.status).toBe('error')
     expect(state.error).toBe('host down')
   })
+
+  it('falls back to the first preset when the roster names no default', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: false },
+      { id: 'core-web', trust: 'system', isDefault: false },
+    ]))
+
+    await controller.load()
+
+    // The row has to show something the menu can select; the first row of the
+    // roster is the deployment's own order.
+    expect(controller.store.getSnapshot().currentValue).toBe('standard')
+  })
+
+  it('reads a rejection that is not an Error', async () => {
+    const rejecting = (value: unknown): IApiClient => ({
+      agentPresets: {
+        list: () => Promise.resolve({
+          rpcId: 'r',
+          result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }] } },
+        }),
+      },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+        update: () => Promise.reject(value),
+      },
+    } as unknown as IApiClient)
+    const controller = new AgentPresetSettingsController(rejecting('socket closed'))
+    await controller.load()
+
+    await controller.select('core-web')
+
+    // A transport may reject with anything; the row still has to say something.
+    expect(controller.store.getSnapshot().error).toBe('socket closed')
+
+    const failing = new AgentPresetSettingsController({
+      agentPresets: { list: () => Promise.reject('offline') },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+      },
+    } as unknown as IApiClient)
+
+    await failing.load()
+
+    expect(failing.store.getSnapshot().error).toBe('offline')
+  })
+
+  it('lets one roster call in flight answer for every caller', async () => {
+    let answer = (): void => {}
+    const pending = new Promise<void>((resolve) => { answer = resolve })
+    let calls = 0
+    const api = {
+      agentPresets: {
+        list: async () => {
+          calls += 1
+          await pending
+          return { rpcId: 'r', result: { ok: true as const, value: { presets: [] } } }
+        },
+      },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSettingsController(api)
+
+    // Both the settings surface and a reconnect can ask at once; a second
+    // request must not race a roster the first is already reading.
+    const first = controller.load()
+    await controller.load()
+    answer()
+    await first
+
+    expect(calls).toBe(1)
+  })
+
+  it('reports a transport failure rather than throwing at the row', async () => {
+    const api = {
+      agentPresets: { list: () => Promise.reject(new Error('offline')) },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+        update: () => Promise.reject(new Error('socket closed')),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSettingsController(api)
+
+    await controller.load()
+
+    expect(controller.store.getSnapshot().status).toBe('error')
+    expect(controller.store.getSnapshot().error).toBe('offline')
+  })
+
+  it('restores the previous default when the write never reached the host', async () => {
+    const presets = [
+      { id: 'standard', trust: 'system' as const, isDefault: true },
+      { id: 'core-web', trust: 'system' as const, isDefault: false },
+    ]
+    const api = {
+      agentPresets: {
+        list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
+      },
+      settings: {
+        describe: () => Promise.resolve({
+          rpcId: 'r', result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
+        }),
+        update: () => Promise.reject(new Error('socket closed')),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSettingsController(api)
+    await controller.load()
+
+    await controller.select('core-web')
+
+    expect(controller.store.getSnapshot().currentValue).toBe('standard')
+    expect(controller.store.getSnapshot().status).toBe('ready')
+    expect(controller.store.getSnapshot().error).toBe('socket closed')
+  })
 })
 
 describe('the composer seat controller', () => {
@@ -240,4 +364,61 @@ describe('the composer seat controller', () => {
     expect(controller.store.getSnapshot().options).toEqual([])
     expect(controller.store.getSnapshot().switchable).toBe(false)
   })
+
+  it('reads a rejection that is not an Error', async () => {
+    const api = {
+      agentPresets: {
+        list: () => Promise.reject('offline'),
+        select: () => Promise.reject('socket closed'),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSeatController(api, 's1' as never, () => ({ blank: true }))
+
+    await controller.load()
+    expect(controller.store.getSnapshot().error).toBe('offline')
+
+    controller.store.set({ ...controller.store.getSnapshot(), current: 'standard', switchable: true })
+    await controller.select('core-web')
+
+    // A transport may reject with anything; the seat still has to say something.
+    expect(controller.store.getSnapshot().error).toBe('socket closed')
+  })
+
+  it('surfaces a roster failure and keeps the seat unswitchable', async () => {
+    const api = {
+      agentPresets: {
+        list: () => Promise.resolve({
+          rpcId: 'r',
+          result: { ok: false as const, error: { code: 'internal', message: 'roster down', details: {} } },
+        }),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSeatController(api, 's1' as never, () => ({ blank: true }))
+
+    await controller.load()
+
+    expect(controller.store.getSnapshot().error).toBe('roster down')
+    expect(controller.store.getSnapshot().switchable).toBe(false)
+  })
+
+  it('reports a transport failure on either call rather than throwing at the seat', async () => {
+    const api = {
+      agentPresets: {
+        list: () => Promise.reject(new Error('offline')),
+        select: () => Promise.reject(new Error('socket closed')),
+      },
+    } as unknown as IApiClient
+    const controller = new AgentPresetSeatController(api, 's1' as never, () => ({ blank: true }))
+
+    await controller.load()
+    expect(controller.store.getSnapshot().error).toBe('offline')
+
+    // A switch that never reached the host leaves the session on what it ran.
+    controller.store.set({ ...controller.store.getSnapshot(), current: 'standard', switchable: true })
+    await controller.select('core-web')
+
+    expect(controller.store.getSnapshot().current).toBe('standard')
+    expect(controller.store.getSnapshot().busy).toBe(false)
+    expect(controller.store.getSnapshot().error).toBe('socket closed')
+  })
 })