Files
deepseek-harness/packages/client/ui-agent-preset/tests/apply.spec.ts
Yichen Jiang 7281615d44 feat(agent-presets): give a preset a name and a description
A picker showed directory names, so the settings page could only ever list
`standard` / `core-web` / `cordis` and hope the reader knew what they meant.
A preset may now publish display text in an optional `preset.yml` beside
its composition, and the section renders cards — name, description, and the
one in use — instead of rows.

The file carries display text ONLY. `id` is the directory name and `trust`
comes from the root a preset was discovered under, so neither is writable
there: otherwise a locally authored preset could name itself into the
shipped set. It is a separate file because a composition is a top-level list
of plugin rows — YAML cannot carry sibling keys beside it, and a fake
metadata row would hand the Loader something to load.

Every read failure degrades to no metadata; absent, malformed, wrongly
typed, and blank all mean the same thing and the picker falls back to the
id. Presentation is not capability: a preset whose name is broken still
mounts.

The editor gained name and description fields above the YAML, and clearing
both removes the file rather than storing a blank name.
2026-08-07 00:41:50 +08:00

244 lines
11 KiB
TypeScript

/**
* 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 预设')
})
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.setName('我的模式')
section.setDescription('只做检索。')
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')
})
})