| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160 |
- /**
- * The staged card form: what a draft shows before it is written, which wire
- * call a save reaches, and what happens to drafts the Host did not accept.
- */
- import { describe, expect, it, vi } from 'vitest'
- import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
- import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
- import { CardForm, numberField, textField } from '../src/client/card-form.ts'
- import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
- import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
- import {
- SettingsDescribeMirror, type SettingsMirrorSnapshot,
- } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
- import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
- import {
- SubagentModelSelectionCardController,
- subagentModelCandidates,
- type SubagentModelSelectionSettings,
- } from '../src/client/subagent-model-selection-card-controller.ts'
- import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
- /** Make the stub behave like a Host that accepts every write. */
- function acceptWrites<T>(host: StubSettingsScope<T>): void {
- const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
- const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
- host.set.mockImplementation((field: string, value: unknown) => {
- host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
- })
- host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => {
- const value = { ...section() }
- const user = { ...layer() }
- for (const op of ops) {
- const field = op.path[0]!
- if (op.op === 'set') {
- value[field] = op.value
- user[field] = op.value
- }
- }
- host.publish({ value: value as T, user })
- })
- host.unset.mockImplementation((field: string) => {
- const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
- const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
- host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
- })
- }
- /** The card plugin's context, scripted down to the namespaces a card reaches. */
- function ctxWith(namespaces: object) {
- return { remote: namespaces } as never
- }
- function credentialsApi(configured: boolean) {
- const describe = vi.fn(() => Promise.resolve({
- ok: true as const,
- value: { DEEPSEEK_API_KEY: { configured, writable: true } },
- }))
- const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined }))
- return { ctx: ctxWith({ credentials: { describe, set } }), describe, set }
- }
- function modelsApi(options: {
- groups?: readonly {
- id: string
- name: string
- models: readonly { id: string; name: string }[]
- }[]
- failures?: readonly { id: string; name: string; message: string }[]
- error?: string
- } = {}) {
- const models = vi.fn(() => Promise.resolve({
- ...(options.error === undefined
- ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } }
- : { ok: false as const, error: new RemoteError('gateway/internal', options.error, {}) }),
- }))
- return { ctx: ctxWith({ session: { modelCatalog: models } }), models }
- }
- function deferred<T>() {
- let resolve!: (value: T) => void
- let reject!: (error: unknown) => void
- const promise = new Promise<T>((accept, fail) => {
- resolve = accept
- reject = fail
- })
- return { promise, resolve, reject }
- }
- describe('CardForm', () => {
- function form() {
- const host = stubSettingsScope<Record<string, unknown>>()
- const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
- host.publish({
- status: 'ready',
- writable: true,
- value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
- base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
- user: {},
- })
- return { host, subject }
- }
- it('shows the effective value and stays clean until something is staged', () => {
- const { subject } = form()
- expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
- expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
- })
- it('marks a field the user layer carries as overridden', () => {
- const { host, subject } = form()
- host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
- // An override equal to the composition default is still an override.
- expect(subject.field('timeoutMs').overridden).toBe(true)
- })
- it('writes nothing until the form is saved', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- subject.actions().edit('timeoutMs', '9000')
- expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
- expect(subject.shell().dirty).toBe(true)
- expect(host.set).not.toHaveBeenCalled()
- await subject.save()
- expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
- expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
- })
- it('drops a draft that settles back on the value already shown', async () => {
- const { host, subject } = form()
- subject.actions().edit('timeoutMs', '9000')
- subject.actions().edit('timeoutMs', '60000')
- expect(subject.shell().dirty).toBe(false)
- await subject.save()
- expect(host.set).not.toHaveBeenCalled()
- })
- it('refuses to save while a draft is not a value the field accepts', async () => {
- const { host, subject } = form()
- subject.actions().edit('timeoutMs', 'soon')
- expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
- expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
- await subject.save()
- expect(host.set).not.toHaveBeenCalled()
- expect(subject.field('timeoutMs').text).toBe('soon')
- })
- it('stages a reset that clears the field only once saved', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
- subject.actions().resetField('timeoutMs')
- // The badge previews the save: the field will no longer be overridden.
- expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
- expect(host.unset).not.toHaveBeenCalled()
- await subject.save()
- expect(host.unset.mock.calls).toEqual([['timeoutMs']])
- expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
- })
- it('treats resetting an inherited field as no change at all', async () => {
- const { host, subject } = form()
- subject.actions().resetField('timeoutMs')
- expect(subject.shell().dirty).toBe(false)
- await subject.save()
- expect(host.unset).not.toHaveBeenCalled()
- })
- it('clears a number field by emptying it', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- host.publish({ user: { timeoutMs: 9_000 } })
- subject.actions().edit('timeoutMs', '')
- expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
- await subject.save()
- expect(host.unset.mock.calls).toEqual([['timeoutMs']])
- })
- it('clears a text field by emptying it', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- host.publish({ user: { baseURL: 'https://search.test/v1' } })
- subject.actions().edit('baseURL', ' ')
- await subject.save()
- expect(host.unset.mock.calls).toEqual([['baseURL']])
- })
- it('writes the trimmed text of a text field', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- subject.actions().edit('baseURL', ' https://other.test ')
- await subject.save()
- expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
- })
- it('keeps the drafts a save did not land, and reports the failure', async () => {
- const { host, subject } = form()
- subject.actions().edit('timeoutMs', '9000')
- await subject.save()
- // The stub Host accepted the call without storing it, exactly as a
- // validator that refuses the value does.
- expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
- expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
- expect(subject.field('timeoutMs').text).toBe('9000')
- })
- it('reports a reset the Host did not apply as a failure', async () => {
- const { host, subject } = form()
- host.publish({ user: { timeoutMs: 9_000 } })
- subject.actions().resetField('timeoutMs')
- await subject.save()
- expect(host.unset).toHaveBeenCalledWith('timeoutMs')
- expect(subject.shell().failed).toBe(true)
- })
- it('clears the failure as soon as the user edits again', async () => {
- const { subject } = form()
- subject.actions().edit('timeoutMs', '9000')
- await subject.save()
- expect(subject.shell().failed).toBe(true)
- subject.actions().edit('timeoutMs', '9001')
- expect(subject.shell().failed).toBe(false)
- })
- it('discards every staged edit', async () => {
- const { host, subject } = form()
- subject.actions().edit('timeoutMs', '9000')
- subject.actions().discard()
- expect(subject.field('timeoutMs').text).toBe('60000')
- expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
- // A discard with nothing staged publishes nothing.
- const before = subject.shell()
- subject.actions().discard()
- expect(subject.shell()).toEqual(before)
- await subject.save()
- expect(host.set).not.toHaveBeenCalled()
- })
- it('refuses a second save while one is in flight', async () => {
- const { host, subject } = form()
- acceptWrites(host)
- subject.actions().edit('timeoutMs', '9000')
- const first = subject.save()
- expect(subject.shell().saving).toBe(true)
- const second = subject.save()
- await Promise.all([first, second])
- expect(host.set).toHaveBeenCalledTimes(1)
- })
- it('publishes a projection whenever the scope or a draft changes', () => {
- const { host, subject } = form()
- const store = subject.bind(() => subject.field('timeoutMs').text)
- expect(store.getSnapshot()).toBe('60000')
- host.publish({ value: { timeoutMs: 1_000 } })
- expect(store.getSnapshot()).toBe('1000')
- subject.actions().edit('timeoutMs', '2000')
- expect(store.getSnapshot()).toBe('2000')
- })
- it('refuses to address a field the card never declared', () => {
- const { subject } = form()
- expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
- })
- it('renders an absent section value as an empty draft', () => {
- const host = stubSettingsScope<Record<string, unknown>>()
- const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
- host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
- expect(subject.field('timeoutMs').text).toBe('')
- expect(subject.field('baseURL').text).toBe('')
- expect(subject.shell().available).toBe(true)
- })
- it('stays unavailable while the namespace is not served', () => {
- const host = stubSettingsScope<Record<string, unknown>>()
- const subject = new CardForm(host.scope, [numberField('timeoutMs')])
- host.publish({ status: 'unavailable' })
- expect(subject.shell()).toMatchObject({ available: false, writable: false })
- })
- })
- describe('BashCardController', () => {
- it('projects both fields and saves them in one write pass', async () => {
- const host = stubSettingsScope<BashSettings>()
- acceptWrites(host)
- const controller = new BashCardController(host.scope)
- host.publish({
- status: 'ready',
- writable: true,
- value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
- base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
- user: { timeoutMs: 5_000 },
- })
- const face = controller.inject()
- expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
- available: true,
- writable: true,
- dirty: false,
- timeoutMs: { text: '5000', overridden: true },
- maxOutputBytes: { text: '64000', overridden: false },
- })
- face.edit('timeoutMs', '9000')
- face.edit('maxOutputBytes', '1024')
- expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
- face.save()
- await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
- expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
- expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
- })
- it('stages a reset and applies it on save', async () => {
- const host = stubSettingsScope<BashSettings>()
- acceptWrites(host)
- const controller = new BashCardController(host.scope)
- host.publish({
- status: 'ready',
- writable: true,
- value: { timeoutMs: 5_000 },
- base: { timeoutMs: 60_000 },
- user: { timeoutMs: 5_000 },
- })
- const face = controller.inject()
- face.resetField('timeoutMs')
- expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
- face.save()
- await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
- expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
- dirty: false,
- timeoutMs: { text: '60000', overridden: false },
- })
- })
- it('discards staged edits without writing', () => {
- const host = stubSettingsScope<BashSettings>()
- const controller = new BashCardController(host.scope)
- host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
- const face = controller.inject()
- face.edit('timeoutMs', '9000')
- face.discard()
- expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
- expect(host.set).not.toHaveBeenCalled()
- })
- })
- describe('AgentLoopCardController', () => {
- it('saves the only field it owns', async () => {
- const host = stubSettingsScope<AgentLoopSettings>()
- acceptWrites(host)
- const controller = new AgentLoopCardController(host.scope)
- host.publish({
- status: 'ready',
- writable: true,
- value: { maxParallelToolCalls: 10 },
- base: { maxParallelToolCalls: 10 },
- user: {},
- })
- const face = controller.inject()
- face.edit('maxParallelToolCalls', '4')
- face.save()
- await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
- expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
- dirty: false,
- maxParallelToolCalls: { text: '4', overridden: true },
- })
- })
- it('reports a read-only document so the card can disable its controls', () => {
- const host = stubSettingsScope<AgentLoopSettings>()
- const controller = new AgentLoopCardController(host.scope)
- host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
- expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
- })
- })
- describe('SubagentModelSelectionCardController', () => {
- it('joins stored routes with the live catalog without dropping unavailable choices', () => {
- const candidates = subagentModelCandidates(
- [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- [{ provider: 'legacy', model: 'old' }],
- new Set(['legacy\0old']),
- )
- expect(candidates).toEqual([
- {
- key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API',
- modelName: 'Fast', available: true, selected: false,
- },
- {
- key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy',
- modelName: 'old', available: false, selected: true,
- },
- ])
- })
- it('loads adapter models and saves the switch and routes atomically', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- acceptWrites(host)
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({
- status: 'ready', writable: true, revision: 3,
- value: { enabled: false, allowedModels: [] }, user: {},
- })
- const face = controller.inject()
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
- face.toggleEnabled()
- await vi.waitFor(() => {
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
- })
- face.toggleModel('alpha\0fast')
- face.save()
- await vi.waitFor(() => {
- expect(host.mutate).toHaveBeenCalledWith([
- { op: 'set', path: ['enabled'], value: true },
- { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
- ], 3)
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- enabled: true,
- dirty: false,
- saving: false,
- failed: false,
- })
- })
- it('starts an empty draft when a ready test scope has no decoded value', () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
- host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
- const face = controller.inject()
- face.toggleEnabled()
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- enabled: true, dirty: true, invalid: true,
- })
- })
- it('keeps the Host value and reports a rejected write', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
- const face = controller.inject()
- face.toggleEnabled()
- await vi.waitFor(() => {
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
- })
- face.toggleModel('alpha\0fast')
- face.save()
- await vi.waitFor(() => {
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- enabled: true,
- dirty: true,
- saving: false,
- })
- })
- it('loads stored routes, stages removal and disablement, and discards both', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- failures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({
- status: 'ready', writable: true, revision: 5,
- value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
- })
- const face = controller.inject()
- const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
- await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
- expect(state().catalogPartial).toBe(true)
- face.toggleModel('missing')
- expect(state().dirty).toBe(false)
- face.toggleModel('alpha\0fast')
- expect(state()).toMatchObject({ dirty: true, invalid: true })
- face.discard()
- expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true })
- face.toggleEnabled()
- expect(state()).toMatchObject({ dirty: true, enabled: false })
- face.toggleEnabled()
- expect(state()).toMatchObject({ dirty: false, enabled: true })
- })
- it('retains selected routes when disabling and loads an already-ready enabled card', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- acceptWrites(host)
- host.publish({
- status: 'ready', writable: true, revision: 5,
- value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
- })
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- const face = controller.inject()
- await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
- face.toggleEnabled()
- face.save()
- await vi.waitFor(() => {
- expect(host.mutate).toHaveBeenCalledWith([
- { op: 'set', path: ['enabled'], value: false },
- { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
- ], 5)
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- enabled: false, dirty: false,
- })
- })
- it('reports a directory error and retries it', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({ error: 'offline' })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
- const face = controller.inject()
- const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
- face.toggleEnabled()
- await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') })
- face.retryCatalog()
- await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) })
- })
- it('rejects a draft after the Host revision changes', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({
- status: 'ready', writable: true, revision: 4,
- value: { enabled: false, allowedModels: [] }, user: {},
- })
- const face = controller.inject()
- face.toggleEnabled()
- await vi.waitFor(() => {
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
- })
- face.toggleModel('alpha\0fast')
- host.publish({
- revision: 5,
- value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- conflicted: true, failed: false, dirty: true,
- })
- face.save()
- await Promise.resolve()
- expect(host.mutate).not.toHaveBeenCalled()
- face.discard()
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- conflicted: false, failed: false, dirty: false, enabled: true,
- })
- })
- it('settles a draft when a newer Host revision already contains it', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- host.publish({
- status: 'ready', writable: true, revision: 4,
- value: { enabled: false, allowedModels: [] }, user: {},
- })
- const face = controller.inject()
- face.toggleEnabled()
- await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
- face.toggleModel('alpha\0fast')
- host.publish({
- revision: 5,
- value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] },
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- conflicted: false, dirty: false, enabled: true,
- })
- })
- it('retains unsaved routes across a catalog refresh', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- acceptWrites(host)
- host.publish({
- status: 'ready', writable: true, revision: 2,
- value: { enabled: false, allowedModels: [] }, user: {},
- })
- const refreshed = deferred<never>()
- const models = vi.fn()
- .mockResolvedValueOnce({
- ok: true, value: {
- groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
- failures: [],
- },
- })
- .mockImplementationOnce(() => refreshed.promise)
- const controller = new SubagentModelSelectionCardController(
- host.scope, ctxWith({ session: { modelCatalog: models } }),
- )
- const face = controller.inject()
- const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
- face.toggleEnabled()
- await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) })
- face.toggleModel('alpha\0fast')
- controller.refreshCatalog()
- expect(state()).toMatchObject({
- catalogStatus: 'loading',
- candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })],
- })
- refreshed.resolve({
- ok: true, value: { groups: [], failures: [] },
- } as never)
- await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
- expect(state().candidates).toEqual([
- expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }),
- ])
- face.save()
- await vi.waitFor(() => {
- expect(host.mutate).toHaveBeenCalledWith([
- { op: 'set', path: ['enabled'], value: true },
- { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
- ], 2)
- })
- })
- it('drops a draft when the connection generation changes', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const models = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- host.publish({
- status: 'ready', writable: true, revision: 4,
- value: { enabled: false, allowedModels: [] }, user: {},
- })
- const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
- const face = controller.inject()
- face.toggleEnabled()
- await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
- face.toggleModel('alpha\0fast')
- controller.resetConnection()
- host.publish({
- revision: 4,
- value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
- })
- expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
- conflicted: false, dirty: false, enabled: true,
- })
- face.save()
- await Promise.resolve()
- expect(host.mutate).not.toHaveBeenCalled()
- })
- it('reloads the model catalog after invalidation', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- host.publish({
- status: 'ready', writable: true, revision: 1,
- value: { enabled: true, allowedModels: [] }, user: {},
- })
- const models = vi.fn()
- .mockResolvedValueOnce({
- ok: true, value: {
- groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
- failures: [],
- },
- })
- .mockResolvedValueOnce({
- ok: true, value: {
- groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
- failures: [],
- },
- })
- const controller = new SubagentModelSelectionCardController(
- host.scope, ctxWith({ session: { modelCatalog: models } }),
- )
- const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
- await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
- controller.refreshCatalog()
- await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
- expect(models).toHaveBeenCalledTimes(2)
- })
- it('suppresses duplicate actions and late save settlements', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const catalog = modelsApi({
- groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
- })
- const write = deferred<undefined>()
- const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => {
- await write.promise
- const enabled = ops.find(op => op.path[0] === 'enabled')
- const allowedModels = ops.find(op => op.path[0] === 'allowedModels')
- host.publish({ value: {
- enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
- allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
- } })
- })
- const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
- const face = controller.inject()
- face.save()
- face.toggleModel('alpha\0fast')
- host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
- face.save()
- face.toggleEnabled()
- await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') })
- face.save()
- face.toggleModel('alpha\0fast')
- face.save()
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true)
- face.toggleEnabled()
- face.toggleModel('alpha\0fast')
- face.save()
- face.discard()
- controller.dispose()
- write.resolve(undefined)
- await write.promise
- expect(mutate).toHaveBeenCalledOnce()
- })
- it('suppresses duplicate directory loads and late settlements', async () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
- const pending = deferred<never>()
- const models = vi.fn(() => pending.promise)
- const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } }))
- const face = controller.inject()
- face.toggleEnabled()
- face.retryCatalog()
- expect(models).toHaveBeenCalledOnce()
- controller.dispose()
- pending.resolve({ ok: false, error: new RemoteError('gateway/internal', 'late failure', {}) } as never)
- await pending.promise
- const pendingResolve = deferred<never>()
- const resolving = new SubagentModelSelectionCardController(
- host.scope,
- ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }),
- )
- const resolvingFace = resolving.inject()
- resolvingFace.toggleEnabled()
- resolving.dispose()
- pendingResolve.resolve({
- ok: true, value: { groups: [], failures: [] },
- } as never)
- await pendingResolve.promise
- })
- it('ignores writes while read-only and scope notifications after disposal', () => {
- const host = stubSettingsScope<SubagentModelSelectionSettings>()
- const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
- host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} })
- const face = controller.inject()
- face.toggleEnabled()
- face.toggleModel('alpha\0fast')
- face.save()
- expect(host.mutate).not.toHaveBeenCalled()
- controller.dispose()
- controller.refreshCatalog()
- controller.resetConnection()
- face.toggleEnabled()
- face.retryCatalog()
- face.save()
- host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } })
- expect(host.mutate).not.toHaveBeenCalled()
- expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
- })
- })
- describe('WebSearchCardController', () => {
- it('reads the credential state for the reference the tab names', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(true)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
- await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
- host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
- await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
- expect(state()).toMatchObject({
- baseURL: { text: 'https://search.test/v1', overridden: false },
- apiKey: { text: '', overridden: false },
- })
- })
- it('writes the staged key through the credentials domain, never the settings section', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(false)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: {}, user: {} })
- const face = controller.inject()
- face.edit('apiKey', ' ds-secret ')
- expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
- expect(credentials.set).not.toHaveBeenCalled()
- credentials.describe.mockImplementation(() => Promise.resolve({
- ok: true as const,
- value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
- }))
- face.save()
- await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
- expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret')
- expect(host.set).not.toHaveBeenCalled()
- await vi.waitFor(() => {
- expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
- })
- })
- it('keeps the stored key when the draft is left blank', () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(true)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: {}, user: {} })
- const face = controller.inject()
- face.edit('apiKey', ' ')
- expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
- face.save()
- expect(credentials.set).not.toHaveBeenCalled()
- })
- it('re-reads when the Host reports the watched reference changed', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(false)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: {}, user: {} })
- await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
- credentials.describe.mockClear()
- // Another reference is not this card's business.
- controller.refreshCredential('OTHER_KEY')
- expect(credentials.describe).not.toHaveBeenCalled()
- // A key written on another surface reaches this card only through this signal.
- credentials.describe.mockImplementation(() => Promise.resolve({
- ok: true as const,
- value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
- }))
- controller.refreshCredential('DEEPSEEK_API_KEY')
- await vi.waitFor(() => {
- expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
- })
- })
- it('addresses the reference the tab declares rather than the default', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(false)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
- const face = controller.inject()
- face.edit('apiKey', 'ds-secret')
- face.save()
- await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
- expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret')
- })
- it('reports a key the Host did not store as a failed save', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const credentials = credentialsApi(false)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: {}, user: {} })
- const face = controller.inject()
- face.edit('apiKey', 'ds-secret')
- face.save()
- await vi.waitFor(() => {
- expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
- })
- })
- it('keeps the card usable when the credential read is refused', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const refusal = () => Promise.resolve({
- ok: false as const,
- error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }),
- })
- const describe = vi.fn(refusal)
- const set = vi.fn(refusal)
- const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } }))
- const face = controller.inject()
- await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
- host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
- face.edit('apiKey', 'ds-secret')
- face.save()
- await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
- expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
- available: true,
- apiKeyConfigured: false,
- baseURL: { text: 'https://search.test/v1' },
- })
- })
- it('ignores a credential read the Host refused', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- const describe = vi.fn(() => Promise.resolve({
- ok: false as const,
- error: new RemoteError('gateway/internal', 'no credential provider', {}),
- }))
- const controller = new WebSearchCardController(host.scope, ctxWith({
- credentials: { describe, set: vi.fn() },
- }))
- await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
- expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
- })
- it('saves the endpoint and the search budget together', async () => {
- const host = stubSettingsScope<WebSearchSettings>()
- acceptWrites(host)
- const credentials = credentialsApi(true)
- const controller = new WebSearchCardController(host.scope, credentials.ctx)
- host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
- const face = controller.inject()
- face.edit('baseURL', 'https://other.test')
- face.edit('maxUses', '3')
- face.save()
- await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
- expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
- expect(credentials.set).not.toHaveBeenCalled()
- })
- })
- describe('ConfigurablePluginsTabController', () => {
- function settingsApi(namespaces: string[]) {
- const describe = vi.fn(() => Promise.resolve({
- ok: true as const,
- value: {
- writable: true,
- hasDocument: true,
- namespaces: namespaces.map(ns => ({
- ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
- })),
- },
- }))
- return { mirror: new SettingsDescribeMirror(ctxWith({ settings: { describe } })), describe }
- }
- /** Slot ledger stand-in: one stored entry per registered card key. */
- function ledger(...keys: string[]) {
- return keys.map(key => ({ component: null, options: { key } }))
- }
- it('dispatches the served namespaces a card claims, in card registration order', async () => {
- const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('agent-loop', 'bash'))
- await settings.mirror.ensure()
- // ui-theme is served but claimed by no card here — another surface owns
- // it. The order is the cards', not the Host's: plugin activation can
- // reorder the description between boots.
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces)
- .toEqual(['agent-loop', 'bash'])
- })
- it('never dispatches a card whose namespace this deployment does not serve', async () => {
- const settings = settingsApi(['bash'])
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash', 'web-search-deepseek'))
- await settings.mirror.ensure()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
- })
- it('takes a card registered after the read without asking the Host again', async () => {
- const settings = settingsApi(['bash'])
- let entries = ledger()
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
- await settings.mirror.ensure()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
- entries = ledger('bash')
- controller.refresh()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
- expect(settings.describe).toHaveBeenCalledOnce()
- })
- it('keeps the namespaces it knew when a refresh fails', async () => {
- const settings = settingsApi(['bash'])
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
- await settings.mirror.ensure()
- settings.describe.mockRejectedValueOnce(new Error('offline'))
- await settings.mirror.load()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual(['bash'])
- })
- it('stops following the mirror once disposed, and never claims it was answered', async () => {
- const settings = settingsApi(['bash'])
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
- controller.dispose()
- await settings.mirror.load()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot())
- .toEqual({ loaded: false, namespaces: [] })
- })
- it('ignores a slot-ledger change that arrives after disposal', async () => {
- const settings = settingsApi(['bash'])
- let entries = ledger()
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => entries)
- await settings.mirror.ensure()
- controller.dispose()
- entries = ledger('bash')
- controller.refresh()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot().namespaces).toEqual([])
- })
- it('ignores a mirror notification already queued when disposal starts', () => {
- let notify = (): void => {}
- let snapshot: SettingsMirrorSnapshot = {
- status: 'ready' as const,
- view: { writable: true, hasDocument: true, namespaces: [] },
- error: null,
- }
- const describeFace = {
- getSnapshot: () => snapshot,
- subscribe: (listener: () => void) => {
- notify = listener
- return () => {}
- },
- ensure: () => Promise.resolve(),
- acceptView: vi.fn(),
- } as never
- const controller = new ConfigurablePluginsTabController(describeFace, () => ledger('bash'))
- expect(controller.inject().hooks.configurablePlugins.getSnapshot())
- .toEqual({ loaded: true, namespaces: [] })
- controller.dispose()
- snapshot = {
- status: 'ready',
- view: {
- writable: true,
- hasDocument: true,
- namespaces: [{
- ns: 'bash', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
- }],
- },
- error: null,
- }
- notify()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot())
- .toEqual({ loaded: true, namespaces: [] })
- })
- it('reports the Host answered even when it serves nothing this tab shows', async () => {
- const settings = settingsApi(['ui-theme'])
- const controller = new ConfigurablePluginsTabController(settings.mirror, () => ledger('bash'))
- await settings.mirror.ensure()
- expect(controller.inject().hooks.configurablePlugins.getSnapshot())
- .toEqual({ loaded: true, namespaces: [] })
- })
- })
|