From c96b480bc4e1939b00dcd8608a967fda9cb37b32 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 14:58:03 +0800 Subject: [PATCH] test(ui-agent-preset): cover the surface where it is introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/client/settings-store.ts | 7 +- .../ui-agent-preset/tests/apply.spec.ts | 211 ++++++++++++++++ .../ui-agent-preset/tests/components.spec.tsx | 231 ++++++++++++++++++ .../ui-agent-preset/tests/invariant.spec.ts | 25 ++ .../tests/settings-store.spec.ts | 181 ++++++++++++++ 5 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 packages/client/ui-agent-preset/tests/apply.spec.ts create mode 100644 packages/client/ui-agent-preset/tests/components.spec.tsx create mode 100644 packages/client/ui-agent-preset/tests/invariant.spec.ts diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 6c35dcdccb..087b5cb7f2 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/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) }) diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts new file mode 100644 index 0000000000..80e63650b8 --- /dev/null +++ b/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) { + 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']) + }) +}) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx new file mode 100644 index 0000000000..f3e8e7967b --- /dev/null +++ b/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 = {}) { + const store = createSnapshotStore({ ...ROW_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render( en[key], + } as unknown as AgentPresetRowProps)} />) + return { ...actions, store } +} + +function renderSeat(state: Partial = {}, locked = false) { + const store = createSnapshotStore({ ...SEAT_READY, ...state }) + const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + render( 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( Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + useAgentPreset: bindSnapshotSelector( + createSnapshotStore({ ...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( Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + locked: false, + useAgentPresetSeat: bindSnapshotSelector( + createSnapshotStore({ ...SEAT_READY, options: [] }), + ), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetSeatProps)} />) + + expect(container.textContent).toBe('') + cleanup() + + const bare = render( Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + locked: false, + useAgentPresetSeat: bindSnapshotSelector( + createSnapshotStore({ ...SEAT_READY, current: '' }), + ), + t: (key: keyof typeof en) => en[key], + } as unknown as AgentPresetSeatProps)} />) + + expect(bare.container.textContent).toBe('') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.spec.ts new file mode 100644 index 0000000000..300e561856 --- /dev/null +++ b/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') + }) +}) diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts index 16c7f8cc99..92f198db6b 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.spec.ts +++ b/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((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') + }) })