feat(web): author agent presets from a settings page
A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
241
packages/client/ui-agent-preset/tests/apply.spec.ts
Normal file
241
packages/client/ui-agent-preset/tests/apply.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Registration: the General row, the settings section, and the per-session
|
||||
* composer seat all come from one apply, and each defers until the slot it
|
||||
* fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* that are already showing, so a default set from one converges the other.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
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 { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROSTER = {
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
agentPresets: {
|
||||
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
|
||||
read: () => Promise.resolve({
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
|
||||
}),
|
||||
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
},
|
||||
settings: {
|
||||
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' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
conversation: { kind: 'single', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** The composer's own declaration, which the seat registration waits for. */
|
||||
function declareComposer(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'conversation',
|
||||
children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', 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 })
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.component).toBe(AgentPresetSection)
|
||||
expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('Agent preset')
|
||||
})
|
||||
|
||||
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.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('hands each surface its own store and actions', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection)
|
||||
// Each thunk reaches its own controller: the row's load fills the row's
|
||||
// store, and the section's default write does not go through the row.
|
||||
await row.load()
|
||||
await row.select('standard')
|
||||
await section.makeDefault('standard')
|
||||
expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows)
|
||||
.toEqual([{ id: 'standard', trust: 'system', isDefault: true }])
|
||||
})
|
||||
|
||||
it('routes the section actions to one controller', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
await section.load()
|
||||
section.setId('mine')
|
||||
section.setContent('- id: x\n')
|
||||
section.confirmDelete('mine')
|
||||
section.close()
|
||||
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
|
||||
|
||||
// One controller behind every action: the delete the section confirmed is
|
||||
// the one its remove() sees.
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
// neither, so this rules out a blanket refresh on every settings write.
|
||||
expect(calls.length).toBe(afterRelevant)
|
||||
})
|
||||
|
||||
it('re-reads both surfaces when the connection comes back', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.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 + 2) })
|
||||
})
|
||||
|
||||
it('leaves the section alone until it has been opened once', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
|
||||
|
||||
// Only the General row reloads: a section nobody opened has nothing to
|
||||
// converge, and reading the roster for it would be a wasted round trip.
|
||||
expect(calls.length - before).toBe(1)
|
||||
})
|
||||
|
||||
it('gives each session its own seat controller and drops its registrations on disposal', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareComposer(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const seat = slots.entries('conversation.input.agentPreset')[0]!
|
||||
expect(seat.component).toBe(AgentPresetSeat)
|
||||
const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
// Same session, same controller; a different session gets its own, because
|
||||
// "may it still switch" is a per-session fact.
|
||||
expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat)
|
||||
expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('reads a seat\'s session state through the live session list', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareComposer(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const byId: Record<string, { blank: boolean; agentPreset?: string }> = {}
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const make = slots.entries('conversation.input.agentPreset')[0]!
|
||||
.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
|
||||
const seat = make('s1')
|
||||
await seat.load()
|
||||
const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable
|
||||
// A session created before presets existed records none; the seat then
|
||||
// shows the roster default rather than an empty control.
|
||||
byId['s1'] = { blank: true }
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
byId['s1'] = { blank: true, agentPreset: 'standard' }
|
||||
await seat.load()
|
||||
|
||||
// A session the list has not caught up to offers no switch rather than
|
||||
// guessing that it is blank.
|
||||
expect(unknownSession).toBe(false)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' })
|
||||
await make('s1').select('standard')
|
||||
})
|
||||
})
|
||||
232
packages/client/ui-agent-preset/tests/components.spec.tsx
Normal file
232
packages/client/ui-agent-preset/tests/components.spec.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two picker surfaces: the General-settings row that names the default for
|
||||
* later sessions, and the composer seat that names this one's — the seat stops
|
||||
* being a control once the conversation starts, because the history from there
|
||||
* on was produced under the preset's tools.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
})
|
||||
|
||||
it('marks a locally authored option as local', () => {
|
||||
renderRow()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// A local preset is exactly as privileged as the plugins it names, so the
|
||||
// list says which rows are local rather than presenting all as vetted.
|
||||
expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
|
||||
// The shipped one carries no marker; only local rows are called out.
|
||||
expect(screen.getAllByText('standard')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('writes the picked preset and closes the menu', () => {
|
||||
const actions = renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
|
||||
expect(actions.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')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failure in place of the description', () => {
|
||||
renderRow({ error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('roster unavailable')
|
||||
})
|
||||
|
||||
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', options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('closes and locks the menu when the settings turn read-only', () => {
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>(ROW_READY)
|
||||
render(<AgentPresetRow {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
useAgentPreset: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...ROW_READY, writable: false }) })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat', () => {
|
||||
it('reads the roster once and offers the session\'s preset', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('switches the session and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('becomes a plain label once the conversation has started', () => {
|
||||
renderSeat({ switchable: false })
|
||||
|
||||
// Not a disabled menu: that would suggest the preset could still change.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('closes an open menu the moment the session stops being blank', () => {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
|
||||
render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
|
||||
|
||||
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the trigger while the composer or a switch is busy', () => {
|
||||
renderSeat({ busy: true })
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failed switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = 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(empty.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const unresolved = 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(unresolved.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
25
packages/client/ui-agent-preset/tests/invariant.spec.ts
Normal file
25
packages/client/ui-agent-preset/tests/invariant.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
551
packages/client/ui-agent-preset/tests/section-store.spec.ts
Normal file
551
packages/client/ui-agent-preset/tests/section-store.spec.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* The agent-preset management controller: it holds one draft at a time, opens
|
||||
* a shipped preset read-only, treats "new" as a copy of an existing
|
||||
* composition, and re-reads the roster after every mutation because a save can
|
||||
* change more than the row it targeted.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
||||
import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
interface FakePreset { trust: 'system' | 'user'; content: string }
|
||||
interface Recorded { method: string; payload: unknown }
|
||||
|
||||
interface FakeOptions {
|
||||
/** Every call the controller made, in order. */
|
||||
calls?: Recorded[]
|
||||
/** Reject `list` with this message. */
|
||||
failList?: string
|
||||
/** Reject `read` with this message. */
|
||||
failRead?: string
|
||||
/** Reject `write` with this message. */
|
||||
failWrite?: string
|
||||
/** Reject `remove` with this message. */
|
||||
failRemove?: string
|
||||
/** Reject `settings.update` with this message. */
|
||||
failSettings?: string
|
||||
/** Throw from `list` rather than answering, as a dead transport does. */
|
||||
throwList?: boolean
|
||||
/** Whether the deployment configures a writable root. */
|
||||
authorable?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
|
||||
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
|
||||
const fail = (message: string) =>
|
||||
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
|
||||
|
||||
/**
|
||||
* A wire face over an in-memory preset store: writes land, so the roster the
|
||||
* controller re-reads after a save is the one the save produced.
|
||||
* @param presets - the starting compositions by id.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake client.
|
||||
*/
|
||||
function fakeApi(
|
||||
presets: Map<string, FakePreset>,
|
||||
defaultId: { id: string },
|
||||
options: FakeOptions = {},
|
||||
): Pick<IApiClient, 'agentPresets' | 'settings'> {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failList !== undefined) return fail(options.failList)
|
||||
return ok({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
})
|
||||
},
|
||||
read: (payload: { agentPreset: string }) => {
|
||||
record('read', payload)
|
||||
if (options.failRead !== undefined) return fail(options.failRead)
|
||||
const preset = presets.get(payload.agentPreset)
|
||||
/* v8 ignore next -- every test reads an id the fake store holds */
|
||||
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
|
||||
return ok({
|
||||
agentPreset: payload.agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
writable: preset.trust === 'user',
|
||||
})
|
||||
},
|
||||
write: (payload: { agentPreset: string; content: string }) => {
|
||||
record('write', payload)
|
||||
if (options.failWrite !== undefined) return fail(options.failWrite)
|
||||
presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
|
||||
return ok({ agentPreset: payload.agentPreset })
|
||||
},
|
||||
remove: async (payload: { agentPreset: string }) => {
|
||||
record('remove', payload)
|
||||
await options.holdRemove
|
||||
if (options.failRemove !== undefined) return await fail(options.failRemove)
|
||||
presets.delete(payload.agentPreset)
|
||||
return await ok({})
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: { default?: string } }) => {
|
||||
record('settings.update', payload)
|
||||
if (options.failSettings !== undefined) return fail(options.failSettings)
|
||||
/* v8 ignore next -- the controller only ever patches `default` */
|
||||
defaultId.id = payload.patch.default ?? defaultId.id
|
||||
return ok({})
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
|
||||
}
|
||||
|
||||
function seed(): Map<string, FakePreset> {
|
||||
return new Map<string, FakePreset>([
|
||||
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
|
||||
['mine', { trust: 'user', content: '- id: tool-read\n' }],
|
||||
])
|
||||
}
|
||||
|
||||
function harness(options: FakeOptions = {}) {
|
||||
const presets = seed()
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
||||
)
|
||||
return { controller, presets, defaultId, calls }
|
||||
}
|
||||
|
||||
function draftOf(controller: AgentPresetSectionController): PresetDraft {
|
||||
const { draft } = controller.store.getSnapshot()
|
||||
if (draft === null) throw new Error('expected an open draft')
|
||||
return draft
|
||||
}
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
|
||||
const { controller } = harness()
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.authorable).toBe(true)
|
||||
expect(state.rows).toEqual([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an empty roster as a deployment that composes no presets', async () => {
|
||||
const presets = new Map<string, FakePreset>()
|
||||
const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Not an error: every session then shares the host composition, and the
|
||||
// section renders nothing rather than an empty management page.
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(controller.store.getSnapshot().authorable).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a rejected roster call', async () => {
|
||||
const { controller } = harness({ failList: 'roster unavailable' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
|
||||
})
|
||||
|
||||
it('surfaces a transport that rejects rather than answering', async () => {
|
||||
const { controller } = harness({ throwList: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a composition', () => {
|
||||
it('opens a locally authored preset for editing', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a shipped preset read-only', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('standard')
|
||||
|
||||
// Readable on purpose: it is the known-good composition a local preset is
|
||||
// written against, and duplicating it is how authoring starts.
|
||||
expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
|
||||
})
|
||||
|
||||
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
|
||||
const { controller } = harness({ failRead: 'permission denied' })
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
|
||||
})
|
||||
|
||||
it('surfaces a transport failure on the page', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('closes the editor without writing anything', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.setContent('- id: changed\n')
|
||||
controller.close()
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creating a preset', () => {
|
||||
it('copies the default composition when no source is named', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom()
|
||||
|
||||
// The default is the composition a new session gets, so it is the one
|
||||
// worth starting from — and a copy is writable wherever it came from.
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
|
||||
expect(draftOf(controller).content).toBe('- id: tool-bash\n')
|
||||
})
|
||||
|
||||
it('copies a named preset', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom('mine')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
|
||||
})
|
||||
|
||||
it('does nothing before the roster loaded', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await controller.createFrom()
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the save blocker', () => {
|
||||
const base: PresetDraft = {
|
||||
id: '', source: 'standard', creating: true, content: '', writable: true, saving: false, error: null,
|
||||
}
|
||||
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
|
||||
|
||||
it('never blocks an edit of an existing preset', () => {
|
||||
expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a name', () => {
|
||||
expect(draftBlocker(base, rows)).toBe('idRequired')
|
||||
})
|
||||
|
||||
it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
|
||||
// The id becomes a directory name, so the client mirrors the host's own
|
||||
// containment rule instead of letting the save round-trip to find out.
|
||||
expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
|
||||
})
|
||||
|
||||
it('rejects a name already in use', () => {
|
||||
// Replacing is what Edit is for; a create landing on an existing name
|
||||
// would overwrite a preset the user never opened.
|
||||
expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
|
||||
})
|
||||
|
||||
it('accepts an unused, containable id', () => {
|
||||
expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saving', () => {
|
||||
it('creates the preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
controller.setId('my-agent')
|
||||
controller.setContent('- id: tool-web-search\n')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
|
||||
})
|
||||
|
||||
it('replaces an existing composition', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
controller.setContent('- id: tool-edit\n')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
|
||||
})
|
||||
|
||||
it('refuses to write a blocked draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
expect(controller.store.getSnapshot().draft).not.toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to write a read-only draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('standard')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing without an open draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the draft open and reports a rejected save', async () => {
|
||||
const { controller } = harness({ failWrite: 'composition is not an entry list' })
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await controller.save()
|
||||
|
||||
// The text stays in the editor: it is the only copy, and the message says
|
||||
// what to fix.
|
||||
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-save', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a second save while one is in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await Promise.all([controller.save(), controller.save()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a save failure when the text changes', async () => {
|
||||
const { controller } = harness({ failWrite: 'invalid' })
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
await controller.save()
|
||||
|
||||
controller.setContent('- id: fixed\n')
|
||||
|
||||
expect(draftOf(controller).error).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an edit with no draft open', () => {
|
||||
const { controller } = harness()
|
||||
|
||||
controller.setId('x')
|
||||
controller.setContent('y')
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting', () => {
|
||||
it('deletes the confirmed preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
expect(presets.has('mine')).toBe(false)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
|
||||
})
|
||||
|
||||
it('closes an editor open on the deleted preset', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
// The file is gone; leaving its text in an editor whose Save would
|
||||
// resurrect it is worse than closing.
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a copy-in-progress open when its source is deleted', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom('mine')
|
||||
controller.setId('mine')
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
// The draft is a new preset that happens to be named after the one just
|
||||
// deleted; its text is unsaved work.
|
||||
expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
|
||||
})
|
||||
|
||||
it('dismisses the confirmation without deleting', async () => {
|
||||
const { controller, calls, presets } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
controller.confirmDelete(null)
|
||||
await controller.remove()
|
||||
|
||||
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
||||
expect(presets.has('mine')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a refused delete on the page', async () => {
|
||||
const { controller } = harness({ failRemove: 'it ships with the deployment' })
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('standard')
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
pendingDelete: null, deleting: false, error: 'it ships with the deployment',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-delete', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a second delete while one is in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
await Promise.all([controller.remove(), controller.remove()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores a confirmation change while a delete is in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const held = new Promise<void>((resolve) => { release = resolve })
|
||||
const presets = seed()
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
|
||||
)
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
const pending = controller.remove()
|
||||
// Dismissing mid-flight cannot un-delete the file, so the confirmation
|
||||
// stays put rather than the page claiming nothing is happening.
|
||||
controller.confirmDelete(null)
|
||||
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
|
||||
release()
|
||||
await pending
|
||||
|
||||
expect(presets.has('mine')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default preset', () => {
|
||||
it('writes the settings field and re-reads the roster', async () => {
|
||||
const { controller, calls, defaultId } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(calls.find(call => call.method === 'settings.update')?.payload)
|
||||
.toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
|
||||
expect(defaultId.id).toBe('mine')
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
|
||||
})
|
||||
|
||||
it('reports a refused write and leaves the roster alone', async () => {
|
||||
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
|
||||
expect(defaultId.id).toBe('standard')
|
||||
})
|
||||
})
|
||||
260
packages/client/ui-agent-preset/tests/section.spec.tsx
Normal file
260
packages/client/ui-agent-preset/tests/section.spec.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The management section's rendering rules: which actions a row offers depends
|
||||
* on its trust and whether it is the default, a shipped composition opens
|
||||
* without a Save, and a draft the host would refuse is blocked before it is
|
||||
* sent.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } 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 { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionState } from '../src/client/section-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const READY: AgentPresetSectionState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable: true,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
draft: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the section over a fixed snapshot, with every action a spy.
|
||||
* @param state - the snapshot to render.
|
||||
* @returns the spies, so a test can assert what a click reached.
|
||||
*/
|
||||
function renderSection(state: Partial<AgentPresetSectionState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
createFrom: vi.fn(() => Promise.resolve()),
|
||||
close: vi.fn(),
|
||||
setId: vi.fn(),
|
||||
setContent: vi.fn(),
|
||||
save: vi.fn(() => Promise.resolve()),
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
makeDefault: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
useAgentPresetSection: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSectionProps
|
||||
render(<AgentPresetSection {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function rowFor(id: string): HTMLElement {
|
||||
const row = screen.getByText(id).closest('li')
|
||||
/* v8 ignore next -- every rendered row id resolves to its card */
|
||||
if (row === null) throw new Error(`no row for ${id}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('the preset list', () => {
|
||||
it('reads the roster once when it first renders', async () => {
|
||||
const actions = renderSection()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('marks trust and the default, and offers no "set default" on the default row', () => {
|
||||
renderSection()
|
||||
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(standard).getByText(en.defaultBadge)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.setDefault)).toBeNull()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers Edit for a local preset and View for a shipped one', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByText(en.edit)).toBeTruthy()
|
||||
// A shipped composition is readable but not editable, and the label is
|
||||
// what says so before the editor opens.
|
||||
expect(within(rowFor('standard')).getByText(en.view)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers Delete only for a locally authored preset', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByText(en.delete)).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByText(en.delete)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides duplication and disables creation when nothing is writable', () => {
|
||||
renderSection({ authorable: false })
|
||||
|
||||
expect(screen.queryByText(en.duplicate)).toBeNull()
|
||||
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('routes the row actions to the controller', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.setDefault))
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.edit))
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.duplicate))
|
||||
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
|
||||
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
expect(actions.open).toHaveBeenCalledWith('mine')
|
||||
expect(actions.createFrom).toHaveBeenCalledWith('mine')
|
||||
// The bare "new" copies the default, which the controller resolves.
|
||||
expect(actions.createFrom).toHaveBeenLastCalledWith()
|
||||
})
|
||||
|
||||
it('shows a page-level failure without hiding the list', () => {
|
||||
renderSection({ error: 'settings are read-only' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('settings are read-only')
|
||||
expect(rowFor('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing when the deployment composes no presets', () => {
|
||||
const { container } = render(<AgentPresetSection {...({
|
||||
useAgentPresetSection: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as AgentPresetSectionProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a retry when the roster could not be read', () => {
|
||||
const actions = renderSection({ status: 'error', error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('roster unavailable')
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
|
||||
expect(actions.load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composition editor', () => {
|
||||
const draft = {
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
|
||||
writable: true, saving: false, error: null,
|
||||
}
|
||||
|
||||
it('opens under the row it belongs to and edits through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
const editor = within(rowFor('mine')).getByRole('textbox')
|
||||
expect(editor).toHaveProperty('value', '- id: tool-read\n')
|
||||
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
|
||||
|
||||
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
|
||||
})
|
||||
|
||||
it('saves and cancels through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
fireEvent.click(screen.getByText(en.save))
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
|
||||
expect(actions.save).toHaveBeenCalledTimes(1)
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a save in flight and blocks a second click', () => {
|
||||
const actions = renderSection({ draft: { ...draft, saving: true } })
|
||||
|
||||
fireEvent.click(screen.getByText(en.saving))
|
||||
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a shipped composition read-only, with no way to save it', () => {
|
||||
renderSection({ draft: { ...draft, id: 'standard', source: 'standard', writable: false } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveProperty('readOnly', true)
|
||||
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
|
||||
expect(screen.queryByText(en.save)).toBeNull()
|
||||
expect(screen.getByText(en.close)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('names a new preset and says what it was copied from', () => {
|
||||
const actions = renderSection({
|
||||
draft: { ...draft, id: '', source: 'standard', creating: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(`${en.copyOf} standard`)).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText(en.presetNamePlaceholder), { target: { value: 'my-agent' } })
|
||||
|
||||
expect(actions.setId).toHaveBeenCalledWith('my-agent')
|
||||
})
|
||||
|
||||
it('blocks a save the host would refuse, and says why', () => {
|
||||
const actions = renderSection({
|
||||
draft: { ...draft, id: 'Upper Case', source: 'standard', creating: true },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.idInvalid)
|
||||
fireEvent.click(screen.getByText(en.save))
|
||||
|
||||
// Disabled rather than round-tripping: the id is a directory name and the
|
||||
// rule is the host's own.
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the host\'s refusal instead of the local blocker', () => {
|
||||
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('asks before deleting', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.delete))
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
|
||||
})
|
||||
|
||||
it('confirms and dismisses through the controller', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByText(en.deleteConfirm))
|
||||
fireEvent.click(within(dialog).getByText(en.cancel))
|
||||
|
||||
expect(actions.remove).toHaveBeenCalledTimes(1)
|
||||
expect(actions.confirmDelete).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('dismisses the confirmation on Escape', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('reports a delete in flight', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine', deleting: true })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting))
|
||||
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Recorded { ns: string; patch: unknown }
|
||||
/** A client whose roster and write outcome the test controls. */
|
||||
function fakeApi(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
|
||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string; failWriteWith?: Error } = {},
|
||||
): IApiClient {
|
||||
return {
|
||||
agentPresets: {
|
||||
@@ -28,6 +28,7 @@ function fakeApi(
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: unknown }) => {
|
||||
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
||||
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
|
||||
if (options.failWrite !== undefined) {
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
|
||||
}
|
||||
@@ -127,12 +128,18 @@ describe('the composer seat controller', () => {
|
||||
function seat(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string } = {},
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
@@ -209,6 +216,89 @@ describe('the composer seat controller', () => {
|
||||
expect(state.error).toBe('already started')
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
// to show something rather than an empty control.
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('standard')
|
||||
})
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi(
|
||||
[{ id: 'standard', trust: 'system', isDefault: true }], { writes }))
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('reads an Error\'s message and stringifies anything else', () => {
|
||||
// A transport rejects with an Error, but a host or a runtime can reject
|
||||
// with anything and the surface still has to say something.
|
||||
expect(messageOf(new Error('boom'))).toBe('boom')
|
||||
expect(messageOf({ code: 7 })).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('reports a transport that rejects rather than answering', async () => {
|
||||
const controller = new AgentPresetSettingsController({
|
||||
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
], { failWriteWith: new Error('socket closed') }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('mine')
|
||||
|
||||
// The value snaps back because the host never took it; a picker still
|
||||
// showing "mine" would be claiming a default that does not exist.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the seat', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { failList: 'host down' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The seat keeps whatever it last showed rather than claiming this session
|
||||
// has no preset; the message says why it could not refresh.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects the roster read', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('restores the previous preset when the switch never reaches the host', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
// Showing `core-web` after a failed switch would claim a composition the
|
||||
// session never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports no options when the session is unknown to the list yet', async () => {
|
||||
const controller = seat([], undefined)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user