9fc1430b79
Authoring writes a FILE, not a settings field, so nothing on the wire announces it — `settings/changed` covers the default moving, never the directory. The chip therefore kept the roster it read when it first mounted, and a preset authored to be used was missing from the one screen that starts sessions. The page that changes the directory now says so, and every surface reading the same roster re-reads it. The chip subscribes rather than being reached from the outer scope, because it is registered later, under `conversation`/`sessions`.
432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
/**
|
|
* Registration: the General row, the settings section, the new-session chip,
|
|
* and the header label 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 { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
|
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
|
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_ONE = {
|
|
rpcId: 'r',
|
|
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
|
}
|
|
|
|
/** The roster after this browser authored one preset of its own. */
|
|
const ROSTER_AUTHORED = {
|
|
rpcId: 'r',
|
|
result: {
|
|
ok: true as const,
|
|
value: {
|
|
presets: [
|
|
{ id: 'standard', trust: 'system', isDefault: true },
|
|
{ id: 'mine', trust: 'user', isDefault: false },
|
|
],
|
|
authorable: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
/** The same roster with a second preset carrying the default. */
|
|
const ROSTER_MOVED = {
|
|
rpcId: 'r',
|
|
result: {
|
|
ok: true as const,
|
|
value: {
|
|
presets: [
|
|
{ id: 'standard', trust: 'system', isDefault: false },
|
|
{ id: 'minimal', trust: 'system', isDefault: true },
|
|
],
|
|
authorable: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
async function bench() {
|
|
const ctx = new Context()
|
|
// The host's answer, mutable so a spec can move the default the way the
|
|
// settings surface does and watch who re-reads it.
|
|
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
|
|
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
|
|
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: (payload: { agentPreset: string }) => {
|
|
calls.push(`write:${payload.agentPreset}`)
|
|
// The host's roster now contains it, which is the whole point of the
|
|
// write and what every surface must converge on.
|
|
ROSTER = ROSTER_AUTHORED
|
|
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
|
},
|
|
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
|
select: (payload: { agentPreset: string }) => {
|
|
calls.push(`select:${payload.agentPreset}`)
|
|
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
|
},
|
|
},
|
|
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, moveDefault }
|
|
}
|
|
|
|
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 conversation's own declarations, which the chip and label wait for. */
|
|
function declareConversation(slots: SlotsService): () => void {
|
|
return slots.register({
|
|
name: 'conversation',
|
|
children: {
|
|
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
|
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
|
},
|
|
} as never, () => null)
|
|
}
|
|
|
|
/** A sessions double whose list can be moved and whose changes are pushed. */
|
|
function sessionsDouble(state: {
|
|
current?: string
|
|
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
|
}) {
|
|
const listeners = new Set<() => void>()
|
|
return {
|
|
list: {
|
|
getSnapshot: () => state,
|
|
subscribe: (fn: () => void) => {
|
|
listeners.add(fn)
|
|
return () => listeners.delete(fn)
|
|
},
|
|
},
|
|
/** Push a list change the way the runtime's store does. */
|
|
notify: () => { for (const fn of listeners) fn() },
|
|
}
|
|
}
|
|
|
|
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('registers the new-session chip and the header label, and drops both on disposal', async () => {
|
|
const { ctx, slots } = await bench()
|
|
declareRoot(slots)
|
|
const conversation = declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
|
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
|
await fiber.await()
|
|
|
|
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
|
expect(chip.component).toBe(AgentPresetSeat)
|
|
const label = slots.entries('conversation.session.header.actions')[0]!
|
|
expect(label.component).toBe(AgentPresetLabel)
|
|
expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 })
|
|
await fiber.dispose()
|
|
expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
|
|
expect(slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
|
expect(slots.entries('settings.section')).toHaveLength(0)
|
|
conversation()
|
|
})
|
|
|
|
it('moves the chip when the default changes on the settings surface', async () => {
|
|
const { ctx, slots, moveDefault } = await bench()
|
|
declareRoot(slots)
|
|
const conversation = declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
|
|
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
|
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
|
await seat.load()
|
|
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
|
|
|
// The chip opens on the deployment default, and the setting it comes from
|
|
// lives on another screen: without this the next session — the very one
|
|
// the setting governs — would be composed from the previous default until
|
|
// a reload.
|
|
moveDefault()
|
|
ctx.emit('settings/changed', 'agent-presets')
|
|
await vi.waitFor(() => {
|
|
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
|
})
|
|
conversation()
|
|
})
|
|
|
|
it('offers a just-authored preset on the new-session chip', async () => {
|
|
const { ctx, slots } = await bench()
|
|
declareRoot(slots)
|
|
const conversation = declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
|
|
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
|
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
|
await seat.load()
|
|
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
|
|
|
|
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
|
await section.load()
|
|
await section.createFrom()
|
|
section.setId('mine')
|
|
section.setName('我的模式')
|
|
await section.save()
|
|
|
|
// Authoring writes a file rather than a setting, so nothing on the wire
|
|
// announces it: a preset authored to be used must appear on the one screen
|
|
// that starts sessions, without a reload.
|
|
await vi.waitFor(() => {
|
|
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
|
|
})
|
|
conversation()
|
|
})
|
|
|
|
it('applies the staged choice to the blank session the flow lands on', async () => {
|
|
const { ctx, slots, calls } = await bench()
|
|
declareRoot(slots)
|
|
declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
const state: {
|
|
current?: string
|
|
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
|
} = { byId: {} }
|
|
const sessions = sessionsDouble(state)
|
|
ctx.provide('sessions', sessions as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
|
.inject as unknown as () => AgentPresetSeatInjected)()
|
|
|
|
await chip.load()
|
|
// Picked on the hero screen, where there is no session yet.
|
|
await chip.select('minimal')
|
|
expect(calls).not.toContain('select:minimal')
|
|
|
|
state.current = 's1'
|
|
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
|
|
sessions.notify()
|
|
|
|
// Connecting a workspace produced the session; the stage reaches it there.
|
|
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
|
|
})
|
|
|
|
it('applies the stage to a session that records no preset of its own', async () => {
|
|
const { ctx, slots, calls } = await bench()
|
|
declareRoot(slots)
|
|
declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
const sessions = sessionsDouble({
|
|
current: 's1',
|
|
byId: { s1: { id: 's1', blank: true } },
|
|
})
|
|
ctx.provide('sessions', sessions as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
|
.inject as unknown as () => AgentPresetSeatInjected)()
|
|
|
|
await chip.load()
|
|
await chip.select('minimal')
|
|
|
|
// A session created before the deployment composed presets records none;
|
|
// reading that as "already runs it" would drop the pick on the floor.
|
|
expect(calls).toContain('select:minimal')
|
|
})
|
|
|
|
it('forgets the stage once it has been spent', async () => {
|
|
const { ctx, slots, calls } = await bench()
|
|
declareRoot(slots)
|
|
declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
const state = {
|
|
current: 's1',
|
|
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
|
}
|
|
const sessions = sessionsDouble(state)
|
|
ctx.provide('sessions', sessions as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
|
.inject as unknown as () => AgentPresetSeatInjected)()
|
|
|
|
await chip.load()
|
|
await chip.select('minimal')
|
|
const spent = calls.filter(call => call === 'select:minimal').length
|
|
sessions.notify()
|
|
sessions.notify()
|
|
|
|
// Every later list movement would re-apply a stage that was not cleared,
|
|
// switching sessions the user never picked for.
|
|
await Promise.resolve()
|
|
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
|
|
})
|
|
|
|
it('gives the header label the same roster the General row reads', async () => {
|
|
const { ctx, slots } = await bench()
|
|
declareRoot(slots)
|
|
declareConversation(slots)
|
|
ctx.provide('conversation', {} as never)
|
|
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
|
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
|
const label = (slots.entries('conversation.session.header.actions')[0]!
|
|
.inject as unknown as () => AgentPresetLabelInjected)()
|
|
const row = (slots.entries('settings.general.item')[0]!
|
|
.inject as unknown as () => AgentPresetRowInjected)()
|
|
|
|
await label.load()
|
|
|
|
// One roster behind both: the label resolves a name the settings row's own
|
|
// load already fetched, rather than issuing a second read per session.
|
|
expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset)
|
|
expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
|
})
|
|
})
|