stores.client.spec.ts 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150
  1. /**
  2. * The staged card form: what a draft shows before it is written, which wire
  3. * call a save reaches, and what happens to drafts the Host did not accept.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
  7. import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  8. import { CardForm, numberField, textField } from '../src/client/card-form.ts'
  9. import { SubagentLimitsCardController, type SubagentLimitsSettings } from '../src/client/subagent-limits-card-controller.ts'
  10. import { subagentCardFace, subagentCardShell } from '../src/client/subagent-card-controller.ts'
  11. import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
  12. import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
  13. import {
  14. SubagentModelSelectionCardController,
  15. subagentModelCandidates,
  16. type SubagentModelSelectionSettings,
  17. } from '../src/client/subagent-model-selection-card-controller.ts'
  18. import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
  19. /** Make the stub behave like a Host that accepts every write. */
  20. function acceptWrites<T>(host: StubSettingsScope<T>): void {
  21. const section = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().value as object })
  22. const layer = (): Record<string, unknown> => ({ ...host.scope.getSnapshot().user as object })
  23. host.set.mockImplementation((field: string, value: unknown) => {
  24. host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } })
  25. })
  26. host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => {
  27. const value = { ...section() }
  28. const user = { ...layer() }
  29. for (const op of ops) {
  30. const field = op.path[0]!
  31. if (op.op === 'set') {
  32. value[field] = op.value
  33. user[field] = op.value
  34. }
  35. }
  36. host.publish({ value: value as T, user })
  37. })
  38. host.unset.mockImplementation((field: string) => {
  39. const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field))
  40. const base = host.scope.getSnapshot().base as Record<string, unknown> | undefined
  41. host.publish({ value: { ...section(), [field]: base?.[field] } as T, user })
  42. })
  43. }
  44. /** The card plugin's context, scripted down to the namespaces a card reaches. */
  45. function ctxWith(namespaces: object) {
  46. return { remote: namespaces } as never
  47. }
  48. function credentialsApi(configured: boolean) {
  49. const describe = vi.fn(() => Promise.resolve({
  50. ok: true as const,
  51. value: { DEEPSEEK_API_KEY: { configured, writable: true } },
  52. }))
  53. const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined }))
  54. return { ctx: ctxWith({ credentials: { describe, set } }), describe, set }
  55. }
  56. function modelsApi(options: {
  57. groups?: readonly {
  58. id: string
  59. name: string
  60. models: readonly { id: string; name: string }[]
  61. }[]
  62. failures?: readonly { id: string; name: string; message: string }[]
  63. error?: string
  64. } = {}) {
  65. const models = vi.fn(() => Promise.resolve({
  66. ...(options.error === undefined
  67. ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } }
  68. : { ok: false as const, error: new RemoteError('gateway/internal', options.error, {}) }),
  69. }))
  70. return { ctx: ctxWith({ session: { modelCatalog: models } }), models }
  71. }
  72. function deferred<T>() {
  73. let resolve!: (value: T) => void
  74. let reject!: (error: unknown) => void
  75. const promise = new Promise<T>((accept, fail) => {
  76. resolve = accept
  77. reject = fail
  78. })
  79. return { promise, resolve, reject }
  80. }
  81. describe('CardForm', () => {
  82. function form() {
  83. const host = stubSettingsScope<Record<string, unknown>>()
  84. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  85. host.publish({
  86. status: 'ready',
  87. writable: true,
  88. value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  89. base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' },
  90. user: {},
  91. })
  92. return { host, subject }
  93. }
  94. it('shows the effective value and stays clean until something is staged', () => {
  95. const { subject } = form()
  96. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  97. expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
  98. })
  99. it('marks a field the user layer carries as overridden', () => {
  100. const { host, subject } = form()
  101. host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } })
  102. // An override equal to the composition default is still an override.
  103. expect(subject.field('timeoutMs').overridden).toBe(true)
  104. })
  105. it('writes nothing until the form is saved', async () => {
  106. const { host, subject } = form()
  107. acceptWrites(host)
  108. subject.actions().edit('timeoutMs', '9000')
  109. expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
  110. expect(subject.shell().dirty).toBe(true)
  111. expect(host.set).not.toHaveBeenCalled()
  112. await subject.save()
  113. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]])
  114. expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false })
  115. })
  116. it('drops a draft that settles back on the value already shown', async () => {
  117. const { host, subject } = form()
  118. subject.actions().edit('timeoutMs', '9000')
  119. subject.actions().edit('timeoutMs', '60000')
  120. expect(subject.shell().dirty).toBe(false)
  121. await subject.save()
  122. expect(host.set).not.toHaveBeenCalled()
  123. })
  124. it('refuses to save while a draft is not a value the field accepts', async () => {
  125. const { host, subject } = form()
  126. subject.actions().edit('timeoutMs', 'soon')
  127. expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
  128. expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
  129. await subject.save()
  130. expect(host.set).not.toHaveBeenCalled()
  131. expect(subject.field('timeoutMs').text).toBe('soon')
  132. })
  133. it('stages a reset that clears the field only once saved', async () => {
  134. const { host, subject } = form()
  135. acceptWrites(host)
  136. host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } })
  137. subject.actions().resetField('timeoutMs')
  138. // The badge previews the save: the field will no longer be overridden.
  139. expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
  140. expect(host.unset).not.toHaveBeenCalled()
  141. await subject.save()
  142. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  143. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  144. })
  145. it('treats resetting an inherited field as no change at all', async () => {
  146. const { host, subject } = form()
  147. subject.actions().resetField('timeoutMs')
  148. expect(subject.shell().dirty).toBe(false)
  149. await subject.save()
  150. expect(host.unset).not.toHaveBeenCalled()
  151. })
  152. it('clears a number field by emptying it', async () => {
  153. const { host, subject } = form()
  154. acceptWrites(host)
  155. host.publish({ user: { timeoutMs: 9_000 } })
  156. subject.actions().edit('timeoutMs', '')
  157. expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
  158. await subject.save()
  159. expect(host.unset.mock.calls).toEqual([['timeoutMs']])
  160. })
  161. it('clears a text field by emptying it', async () => {
  162. const { host, subject } = form()
  163. acceptWrites(host)
  164. host.publish({ user: { baseURL: 'https://search.test/v1' } })
  165. subject.actions().edit('baseURL', ' ')
  166. await subject.save()
  167. expect(host.unset.mock.calls).toEqual([['baseURL']])
  168. })
  169. it('writes the trimmed text of a text field', async () => {
  170. const { host, subject } = form()
  171. acceptWrites(host)
  172. subject.actions().edit('baseURL', ' https://other.test ')
  173. await subject.save()
  174. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']])
  175. })
  176. it('keeps the drafts a save did not land, and reports the failure', async () => {
  177. const { host, subject } = form()
  178. subject.actions().edit('timeoutMs', '9000')
  179. await subject.save()
  180. // The stub Host accepted the call without storing it, exactly as a
  181. // validator that refuses the value does.
  182. expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000)
  183. expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false })
  184. expect(subject.field('timeoutMs').text).toBe('9000')
  185. })
  186. it('reports a reset the Host did not apply as a failure', async () => {
  187. const { host, subject } = form()
  188. host.publish({ user: { timeoutMs: 9_000 } })
  189. subject.actions().resetField('timeoutMs')
  190. await subject.save()
  191. expect(host.unset).toHaveBeenCalledWith('timeoutMs')
  192. expect(subject.shell().failed).toBe(true)
  193. })
  194. it('clears the failure as soon as the user edits again', async () => {
  195. const { subject } = form()
  196. subject.actions().edit('timeoutMs', '9000')
  197. await subject.save()
  198. expect(subject.shell().failed).toBe(true)
  199. subject.actions().edit('timeoutMs', '9001')
  200. expect(subject.shell().failed).toBe(false)
  201. })
  202. it('discards every staged edit', async () => {
  203. const { host, subject } = form()
  204. subject.actions().edit('timeoutMs', '9000')
  205. subject.actions().discard()
  206. expect(subject.field('timeoutMs').text).toBe('60000')
  207. expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
  208. // A discard with nothing staged publishes nothing.
  209. const before = subject.shell()
  210. subject.actions().discard()
  211. expect(subject.shell()).toEqual(before)
  212. await subject.save()
  213. expect(host.set).not.toHaveBeenCalled()
  214. })
  215. it('refuses a second save while one is in flight', async () => {
  216. const { host, subject } = form()
  217. acceptWrites(host)
  218. subject.actions().edit('timeoutMs', '9000')
  219. const first = subject.save()
  220. expect(subject.shell().saving).toBe(true)
  221. const second = subject.save()
  222. await Promise.all([first, second])
  223. expect(host.set).toHaveBeenCalledTimes(1)
  224. })
  225. it('publishes a projection whenever the scope or a draft changes', () => {
  226. const { host, subject } = form()
  227. const store = subject.bind(() => subject.field('timeoutMs').text)
  228. expect(store.getSnapshot()).toBe('60000')
  229. host.publish({ value: { timeoutMs: 1_000 } })
  230. expect(store.getSnapshot()).toBe('1000')
  231. subject.actions().edit('timeoutMs', '2000')
  232. expect(store.getSnapshot()).toBe('2000')
  233. })
  234. it('refuses to address a field the card never declared', () => {
  235. const { subject } = form()
  236. expect(() => subject.field('nope')).toThrow('plugin card has no field nope')
  237. })
  238. it('renders an absent section value as an empty draft', () => {
  239. const host = stubSettingsScope<Record<string, unknown>>()
  240. const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')])
  241. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined })
  242. expect(subject.field('timeoutMs').text).toBe('')
  243. expect(subject.field('baseURL').text).toBe('')
  244. expect(subject.shell().available).toBe(true)
  245. })
  246. it('stays unavailable while the namespace is not served', () => {
  247. const host = stubSettingsScope<Record<string, unknown>>()
  248. const subject = new CardForm(host.scope, [numberField('timeoutMs')])
  249. host.publish({ status: 'unavailable' })
  250. expect(subject.shell()).toMatchObject({ available: false, writable: false })
  251. })
  252. })
  253. describe('BashCardController', () => {
  254. it('projects both fields and saves them in one write pass', async () => {
  255. const host = stubSettingsScope<BashSettings>()
  256. acceptWrites(host)
  257. const controller = new BashCardController(host.scope)
  258. host.publish({
  259. status: 'ready',
  260. writable: true,
  261. value: { timeoutMs: 5_000, maxOutputBytes: 64_000 },
  262. base: { timeoutMs: 60_000, maxOutputBytes: 64_000 },
  263. user: { timeoutMs: 5_000 },
  264. })
  265. const face = controller.inject()
  266. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  267. available: true,
  268. writable: true,
  269. dirty: false,
  270. timeoutMs: { text: '5000', overridden: true },
  271. maxOutputBytes: { text: '64000', overridden: false },
  272. })
  273. face.edit('timeoutMs', '9000')
  274. face.edit('maxOutputBytes', '1024')
  275. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true)
  276. face.save()
  277. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  278. expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]])
  279. expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false)
  280. })
  281. it('stages a reset and applies it on save', async () => {
  282. const host = stubSettingsScope<BashSettings>()
  283. acceptWrites(host)
  284. const controller = new BashCardController(host.scope)
  285. host.publish({
  286. status: 'ready',
  287. writable: true,
  288. value: { timeoutMs: 5_000 },
  289. base: { timeoutMs: 60_000 },
  290. user: { timeoutMs: 5_000 },
  291. })
  292. const face = controller.inject()
  293. face.resetField('timeoutMs')
  294. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000')
  295. face.save()
  296. await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') })
  297. expect(face.hooks.bashCard.getSnapshot()).toMatchObject({
  298. dirty: false,
  299. timeoutMs: { text: '60000', overridden: false },
  300. })
  301. })
  302. it('discards staged edits without writing', () => {
  303. const host = stubSettingsScope<BashSettings>()
  304. const controller = new BashCardController(host.scope)
  305. host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
  306. const face = controller.inject()
  307. face.edit('timeoutMs', '9000')
  308. face.discard()
  309. expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000')
  310. expect(host.set).not.toHaveBeenCalled()
  311. })
  312. })
  313. describe('AgentLoopCardController', () => {
  314. it('saves the only field it owns', async () => {
  315. const host = stubSettingsScope<AgentLoopSettings>()
  316. acceptWrites(host)
  317. const controller = new AgentLoopCardController(host.scope)
  318. host.publish({
  319. status: 'ready',
  320. writable: true,
  321. value: { maxParallelToolCalls: 10 },
  322. base: { maxParallelToolCalls: 10 },
  323. user: {},
  324. })
  325. const face = controller.inject()
  326. face.edit('maxParallelToolCalls', '4')
  327. face.save()
  328. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) })
  329. expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({
  330. dirty: false,
  331. maxParallelToolCalls: { text: '4', overridden: true },
  332. })
  333. })
  334. it('reports a read-only document so the card can disable its controls', () => {
  335. const host = stubSettingsScope<AgentLoopSettings>()
  336. const controller = new AgentLoopCardController(host.scope)
  337. host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
  338. expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false)
  339. })
  340. })
  341. describe('SubagentModelSelectionCardController', () => {
  342. it('joins stored routes with the live catalog without dropping unavailable choices', () => {
  343. const candidates = subagentModelCandidates(
  344. [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  345. [{ provider: 'legacy', model: 'old' }],
  346. new Set(['legacy\0old']),
  347. )
  348. expect(candidates).toEqual([
  349. {
  350. key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API',
  351. modelName: 'Fast', available: true, selected: false,
  352. },
  353. {
  354. key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy',
  355. modelName: 'old', available: false, selected: true,
  356. },
  357. ])
  358. })
  359. it('loads adapter models and saves the switch and routes atomically', async () => {
  360. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  361. acceptWrites(host)
  362. const models = modelsApi({
  363. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  364. })
  365. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  366. host.publish({
  367. status: 'ready', writable: true, revision: 3,
  368. value: { enabled: false, allowedModels: [] }, user: {},
  369. })
  370. const face = controller.inject()
  371. expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
  372. face.toggleEnabled()
  373. await vi.waitFor(() => {
  374. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  375. })
  376. face.toggleModel('alpha\0fast')
  377. face.save()
  378. await vi.waitFor(() => {
  379. expect(host.mutate).toHaveBeenCalledWith([
  380. { op: 'set', path: ['enabled'], value: true },
  381. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  382. ], 3)
  383. })
  384. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  385. enabled: true,
  386. dirty: false,
  387. saving: false,
  388. failed: false,
  389. })
  390. })
  391. it('starts an empty draft when a ready test scope has no decoded value', () => {
  392. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  393. const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
  394. host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
  395. const face = controller.inject()
  396. face.toggleEnabled()
  397. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  398. enabled: true, dirty: true, invalid: true,
  399. })
  400. })
  401. it('keeps the Host value and reports a rejected write', async () => {
  402. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  403. const models = modelsApi({
  404. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  405. })
  406. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  407. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  408. const face = controller.inject()
  409. face.toggleEnabled()
  410. await vi.waitFor(() => {
  411. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  412. })
  413. face.toggleModel('alpha\0fast')
  414. face.save()
  415. await vi.waitFor(() => {
  416. expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
  417. })
  418. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  419. enabled: true,
  420. dirty: true,
  421. saving: false,
  422. })
  423. })
  424. it('loads stored routes, stages removal and disablement, and discards both', async () => {
  425. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  426. const models = modelsApi({
  427. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  428. failures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
  429. })
  430. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  431. host.publish({
  432. status: 'ready', writable: true, revision: 5,
  433. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
  434. })
  435. const face = controller.inject()
  436. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  437. await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
  438. expect(state().catalogPartial).toBe(true)
  439. face.toggleModel('missing')
  440. expect(state().dirty).toBe(false)
  441. face.toggleModel('alpha\0fast')
  442. expect(state()).toMatchObject({ dirty: true, invalid: true })
  443. face.discard()
  444. expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true })
  445. face.toggleEnabled()
  446. expect(state()).toMatchObject({ dirty: true, enabled: false })
  447. face.toggleEnabled()
  448. expect(state()).toMatchObject({ dirty: false, enabled: true })
  449. })
  450. it('retains selected routes when disabling and loads an already-ready enabled card', async () => {
  451. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  452. acceptWrites(host)
  453. host.publish({
  454. status: 'ready', writable: true, revision: 5,
  455. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
  456. })
  457. const models = modelsApi({
  458. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  459. })
  460. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  461. const face = controller.inject()
  462. await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
  463. face.toggleEnabled()
  464. face.save()
  465. await vi.waitFor(() => {
  466. expect(host.mutate).toHaveBeenCalledWith([
  467. { op: 'set', path: ['enabled'], value: false },
  468. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  469. ], 5)
  470. })
  471. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  472. enabled: false, dirty: false,
  473. })
  474. })
  475. it('reports a directory error and retries it', async () => {
  476. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  477. const models = modelsApi({ error: 'offline' })
  478. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  479. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  480. const face = controller.inject()
  481. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  482. face.toggleEnabled()
  483. await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') })
  484. face.retryCatalog()
  485. await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) })
  486. })
  487. it('rejects a draft after the Host revision changes', async () => {
  488. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  489. const models = modelsApi({
  490. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  491. })
  492. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  493. host.publish({
  494. status: 'ready', writable: true, revision: 4,
  495. value: { enabled: false, allowedModels: [] }, user: {},
  496. })
  497. const face = controller.inject()
  498. face.toggleEnabled()
  499. await vi.waitFor(() => {
  500. expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
  501. })
  502. face.toggleModel('alpha\0fast')
  503. host.publish({
  504. revision: 5,
  505. value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
  506. })
  507. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  508. conflicted: true, failed: false, dirty: true,
  509. })
  510. face.save()
  511. await Promise.resolve()
  512. expect(host.mutate).not.toHaveBeenCalled()
  513. face.discard()
  514. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  515. conflicted: false, failed: false, dirty: false, enabled: true,
  516. })
  517. })
  518. it('settles a draft when a newer Host revision already contains it', async () => {
  519. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  520. const models = modelsApi({
  521. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  522. })
  523. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  524. host.publish({
  525. status: 'ready', writable: true, revision: 4,
  526. value: { enabled: false, allowedModels: [] }, user: {},
  527. })
  528. const face = controller.inject()
  529. face.toggleEnabled()
  530. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
  531. face.toggleModel('alpha\0fast')
  532. host.publish({
  533. revision: 5,
  534. value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] },
  535. })
  536. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  537. conflicted: false, dirty: false, enabled: true,
  538. })
  539. })
  540. it('retains unsaved routes across a catalog refresh', async () => {
  541. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  542. acceptWrites(host)
  543. host.publish({
  544. status: 'ready', writable: true, revision: 2,
  545. value: { enabled: false, allowedModels: [] }, user: {},
  546. })
  547. const refreshed = deferred<never>()
  548. const models = vi.fn()
  549. .mockResolvedValueOnce({
  550. ok: true, value: {
  551. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  552. failures: [],
  553. },
  554. })
  555. .mockImplementationOnce(() => refreshed.promise)
  556. const controller = new SubagentModelSelectionCardController(
  557. host.scope, ctxWith({ session: { modelCatalog: models } }),
  558. )
  559. const face = controller.inject()
  560. const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
  561. face.toggleEnabled()
  562. await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) })
  563. face.toggleModel('alpha\0fast')
  564. controller.refreshCatalog()
  565. expect(state()).toMatchObject({
  566. catalogStatus: 'loading',
  567. candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })],
  568. })
  569. refreshed.resolve({
  570. ok: true, value: { groups: [], failures: [] },
  571. } as never)
  572. await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') })
  573. expect(state().candidates).toEqual([
  574. expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }),
  575. ])
  576. face.save()
  577. await vi.waitFor(() => {
  578. expect(host.mutate).toHaveBeenCalledWith([
  579. { op: 'set', path: ['enabled'], value: true },
  580. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  581. ], 2)
  582. })
  583. })
  584. it('drops a draft when the connection generation changes', async () => {
  585. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  586. const models = modelsApi({
  587. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  588. })
  589. host.publish({
  590. status: 'ready', writable: true, revision: 4,
  591. value: { enabled: false, allowedModels: [] }, user: {},
  592. })
  593. const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
  594. const face = controller.inject()
  595. face.toggleEnabled()
  596. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
  597. face.toggleModel('alpha\0fast')
  598. controller.resetConnection()
  599. host.publish({
  600. revision: 4,
  601. value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
  602. })
  603. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
  604. conflicted: false, dirty: false, enabled: true,
  605. })
  606. face.save()
  607. await Promise.resolve()
  608. expect(host.mutate).not.toHaveBeenCalled()
  609. })
  610. it('reloads the model catalog after invalidation', async () => {
  611. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  612. host.publish({
  613. status: 'ready', writable: true, revision: 1,
  614. value: { enabled: true, allowedModels: [] }, user: {},
  615. })
  616. const models = vi.fn()
  617. .mockResolvedValueOnce({
  618. ok: true, value: {
  619. groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
  620. failures: [],
  621. },
  622. })
  623. .mockResolvedValueOnce({
  624. ok: true, value: {
  625. groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
  626. failures: [],
  627. },
  628. })
  629. const controller = new SubagentModelSelectionCardController(
  630. host.scope, ctxWith({ session: { modelCatalog: models } }),
  631. )
  632. const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
  633. await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
  634. controller.refreshCatalog()
  635. await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
  636. expect(models).toHaveBeenCalledTimes(2)
  637. })
  638. it('suppresses duplicate actions and late save settlements', async () => {
  639. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  640. const catalog = modelsApi({
  641. groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
  642. })
  643. const write = deferred<undefined>()
  644. const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => {
  645. await write.promise
  646. const enabled = ops.find(op => op.path[0] === 'enabled')
  647. const allowedModels = ops.find(op => op.path[0] === 'allowedModels')
  648. host.publish({ value: {
  649. enabled: enabled?.op === 'set' ? enabled.value as boolean : false,
  650. allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
  651. } })
  652. })
  653. const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
  654. const face = controller.inject()
  655. face.save()
  656. face.toggleModel('alpha\0fast')
  657. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  658. face.save()
  659. face.toggleEnabled()
  660. await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') })
  661. face.save()
  662. face.toggleModel('alpha\0fast')
  663. face.save()
  664. expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true)
  665. face.toggleEnabled()
  666. face.toggleModel('alpha\0fast')
  667. face.save()
  668. face.discard()
  669. controller.dispose()
  670. write.resolve(undefined)
  671. await write.promise
  672. expect(mutate).toHaveBeenCalledOnce()
  673. })
  674. it('suppresses duplicate directory loads and late settlements', async () => {
  675. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  676. host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
  677. const pending = deferred<never>()
  678. const models = vi.fn(() => pending.promise)
  679. const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } }))
  680. const face = controller.inject()
  681. face.toggleEnabled()
  682. face.retryCatalog()
  683. expect(models).toHaveBeenCalledOnce()
  684. controller.dispose()
  685. pending.resolve({ ok: false, error: new RemoteError('gateway/internal', 'late failure', {}) } as never)
  686. await pending.promise
  687. const pendingResolve = deferred<never>()
  688. const resolving = new SubagentModelSelectionCardController(
  689. host.scope,
  690. ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }),
  691. )
  692. const resolvingFace = resolving.inject()
  693. resolvingFace.toggleEnabled()
  694. resolving.dispose()
  695. pendingResolve.resolve({
  696. ok: true, value: { groups: [], failures: [] },
  697. } as never)
  698. await pendingResolve.promise
  699. })
  700. it('ignores writes while read-only and scope notifications after disposal', () => {
  701. const host = stubSettingsScope<SubagentModelSelectionSettings>()
  702. const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
  703. host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} })
  704. const face = controller.inject()
  705. face.toggleEnabled()
  706. face.toggleModel('alpha\0fast')
  707. face.save()
  708. expect(host.mutate).not.toHaveBeenCalled()
  709. controller.dispose()
  710. controller.refreshCatalog()
  711. controller.resetConnection()
  712. face.toggleEnabled()
  713. face.retryCatalog()
  714. face.save()
  715. host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } })
  716. expect(host.mutate).not.toHaveBeenCalled()
  717. expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
  718. })
  719. })
  720. describe('WebSearchCardController', () => {
  721. it('reads the credential state for the reference the tab names', async () => {
  722. const host = stubSettingsScope<WebSearchSettings>()
  723. const credentials = credentialsApi(true)
  724. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  725. const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
  726. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  727. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  728. await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) })
  729. expect(state()).toMatchObject({
  730. baseURL: { text: 'https://search.test/v1', overridden: false },
  731. apiKey: { text: '', overridden: false },
  732. })
  733. })
  734. it('writes the staged key through the credentials domain, never the settings section', async () => {
  735. const host = stubSettingsScope<WebSearchSettings>()
  736. const credentials = credentialsApi(false)
  737. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  738. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  739. const face = controller.inject()
  740. face.edit('apiKey', ' ds-secret ')
  741. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true)
  742. expect(credentials.set).not.toHaveBeenCalled()
  743. credentials.describe.mockImplementation(() => Promise.resolve({
  744. ok: true as const,
  745. value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
  746. }))
  747. face.save()
  748. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  749. expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret')
  750. expect(host.set).not.toHaveBeenCalled()
  751. await vi.waitFor(() => {
  752. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true })
  753. })
  754. })
  755. it('keeps the stored key when the draft is left blank', () => {
  756. const host = stubSettingsScope<WebSearchSettings>()
  757. const credentials = credentialsApi(true)
  758. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  759. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  760. const face = controller.inject()
  761. face.edit('apiKey', ' ')
  762. expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false)
  763. face.save()
  764. expect(credentials.set).not.toHaveBeenCalled()
  765. })
  766. it('re-reads when the Host reports the watched reference changed', async () => {
  767. const host = stubSettingsScope<WebSearchSettings>()
  768. const credentials = credentialsApi(false)
  769. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  770. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  771. await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
  772. credentials.describe.mockClear()
  773. // Another reference is not this card's business.
  774. controller.refreshCredential('OTHER_KEY')
  775. expect(credentials.describe).not.toHaveBeenCalled()
  776. // A key written on another surface reaches this card only through this signal.
  777. credentials.describe.mockImplementation(() => Promise.resolve({
  778. ok: true as const,
  779. value: { DEEPSEEK_API_KEY: { configured: true, writable: true } },
  780. }))
  781. controller.refreshCredential('DEEPSEEK_API_KEY')
  782. await vi.waitFor(() => {
  783. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true)
  784. })
  785. })
  786. it('addresses the reference the tab declares rather than the default', async () => {
  787. const host = stubSettingsScope<WebSearchSettings>()
  788. const credentials = credentialsApi(false)
  789. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  790. host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
  791. const face = controller.inject()
  792. face.edit('apiKey', 'ds-secret')
  793. face.save()
  794. await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() })
  795. expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret')
  796. })
  797. it('reports a key the Host did not store as a failed save', async () => {
  798. const host = stubSettingsScope<WebSearchSettings>()
  799. const credentials = credentialsApi(false)
  800. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  801. host.publish({ status: 'ready', writable: true, value: {}, user: {} })
  802. const face = controller.inject()
  803. face.edit('apiKey', 'ds-secret')
  804. face.save()
  805. await vi.waitFor(() => {
  806. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
  807. })
  808. })
  809. it('keeps the card usable when the credential read is refused', async () => {
  810. const host = stubSettingsScope<WebSearchSettings>()
  811. const refusal = () => Promise.resolve({
  812. ok: false as const,
  813. error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }),
  814. })
  815. const describe = vi.fn(refusal)
  816. const set = vi.fn(refusal)
  817. const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } }))
  818. const face = controller.inject()
  819. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  820. host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} })
  821. face.edit('apiKey', 'ds-secret')
  822. face.save()
  823. await vi.waitFor(() => { expect(set).toHaveBeenCalled() })
  824. expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({
  825. available: true,
  826. apiKeyConfigured: false,
  827. baseURL: { text: 'https://search.test/v1' },
  828. })
  829. })
  830. it('ignores a credential read the Host refused', async () => {
  831. const host = stubSettingsScope<WebSearchSettings>()
  832. const describe = vi.fn(() => Promise.resolve({
  833. ok: false as const,
  834. error: new RemoteError('gateway/internal', 'no credential provider', {}),
  835. }))
  836. const controller = new WebSearchCardController(host.scope, ctxWith({
  837. credentials: { describe, set: vi.fn() },
  838. }))
  839. await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
  840. expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false)
  841. })
  842. it('saves the endpoint and the search budget together', async () => {
  843. const host = stubSettingsScope<WebSearchSettings>()
  844. acceptWrites(host)
  845. const credentials = credentialsApi(true)
  846. const controller = new WebSearchCardController(host.scope, credentials.ctx)
  847. host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
  848. const face = controller.inject()
  849. face.edit('baseURL', 'https://other.test')
  850. face.edit('maxUses', '3')
  851. face.save()
  852. await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) })
  853. expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]])
  854. expect(credentials.set).not.toHaveBeenCalled()
  855. })
  856. })
  857. describe('SubagentLimitsCardController', () => {
  858. it('validates staged limits, saves them, and restores composed defaults', async () => {
  859. const host = stubSettingsScope<SubagentLimitsSettings>()
  860. const face = new SubagentLimitsCardController(host.scope).inject()
  861. const state = () => face.hooks.subagentLimitsCard.getSnapshot()
  862. host.publish({ status: 'ready', writable: true, value: { maxDepth: 3, maxActiveSubagents: 8 }, base: { maxDepth: 3, maxActiveSubagents: 8 }, user: {} })
  863. acceptWrites(host)
  864. expect(state().maxActiveSubagents.text).toBe('8')
  865. for (const draft of ['-1', '1.5', '9007199254740992', 'wat', '-0']) {
  866. face.edit('maxDepth', draft)
  867. expect(state().invalid).toBe(true)
  868. }
  869. face.edit('maxDepth', '0')
  870. face.edit('maxActiveSubagents', '0')
  871. expect(state().invalid).toBe(true)
  872. face.edit('maxActiveSubagents', '12')
  873. expect(host.set).not.toHaveBeenCalled()
  874. face.save()
  875. await vi.waitFor(() => { expect(state().saving).toBe(false) })
  876. expect(host.scope.getSnapshot().value).toEqual({ maxDepth: 0, maxActiveSubagents: 12 })
  877. face.resetField('maxDepth')
  878. face.edit('maxActiveSubagents', '')
  879. expect(state().invalid).toBe(false)
  880. face.save()
  881. await vi.waitFor(() => { expect(state().saving).toBe(false) })
  882. expect(host.scope.getSnapshot().value).toEqual({ maxDepth: 3, maxActiveSubagents: 8 })
  883. })
  884. })
  885. describe('shared Subagent card actions', () => {
  886. function card() {
  887. const limits = stubSettingsScope<SubagentLimitsSettings>()
  888. const models = stubSettingsScope<SubagentModelSelectionSettings>()
  889. const limitFace = new SubagentLimitsCardController(limits.scope).inject()
  890. const modelFace = new SubagentModelSelectionCardController(models.scope, modelsApi().ctx).inject()
  891. limits.publish({
  892. status: 'ready', writable: true, revision: 2,
  893. value: { maxDepth: 3, maxActiveSubagents: 8 },
  894. base: { maxDepth: 3, maxActiveSubagents: 8 }, user: {},
  895. })
  896. models.publish({
  897. status: 'ready', writable: true, revision: 5,
  898. value: { enabled: false, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
  899. })
  900. acceptWrites(limits)
  901. acceptWrites(models)
  902. const face = subagentCardFace(limitFace, modelFace)
  903. const state = () => subagentCardShell(
  904. face.hooks.subagentLimitsCard.getSnapshot(),
  905. face.hooks.subagentModelSelectionCard.getSnapshot(),
  906. )
  907. return { limits, models, face, state }
  908. }
  909. it('saves both drafts through their existing namespaces from one action', async () => {
  910. const { limits, models, face, state } = card()
  911. face.editLimit('maxDepth', '2')
  912. face.toggleEnabled()
  913. face.save()
  914. await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
  915. expect(limits.set).toHaveBeenCalledWith('maxDepth', 2)
  916. expect(models.mutate).toHaveBeenCalledWith([
  917. { op: 'set', path: ['enabled'], value: true },
  918. { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
  919. ], 5)
  920. })
  921. it('saves a limit-only draft without rewriting model authorization', async () => {
  922. const { limits, models, face, state } = card()
  923. face.editLimit('maxDepth', '2')
  924. face.save()
  925. await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
  926. expect(limits.scope.getSnapshot().value?.maxDepth).toBe(2)
  927. expect(models.mutate).not.toHaveBeenCalled()
  928. expect(models.scope.getSnapshot().value?.enabled).toBe(false)
  929. })
  930. it('retains the pending draft when discard is requested before both writes finish', async () => {
  931. const { limits, face, state } = card()
  932. const pending = deferred<undefined>()
  933. const set = vi.spyOn(limits.scope, 'set').mockImplementationOnce(async () => {
  934. await pending.promise
  935. limits.publish({ value: { maxDepth: 2, maxActiveSubagents: 8 }, user: { maxDepth: 2 } })
  936. })
  937. face.editLimit('maxDepth', '2')
  938. face.toggleEnabled()
  939. face.save()
  940. try {
  941. await vi.waitFor(() => {
  942. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ saving: false, dirty: false })
  943. })
  944. expect(state().saving).toBe(true)
  945. expect(set).toHaveBeenCalledWith('maxDepth', 2)
  946. face.discard()
  947. expect(face.hooks.subagentLimitsCard.getSnapshot()).toMatchObject({ dirty: true, maxDepth: { text: '2' } })
  948. } finally {
  949. pending.resolve(undefined)
  950. await vi.waitFor(() => { expect(state().saving).toBe(false) })
  951. }
  952. expect(state()).toMatchObject({ dirty: false, failed: false })
  953. expect(limits.scope.getSnapshot().value?.maxDepth).toBe(2)
  954. })
  955. it('writes neither namespace when either draft is invalid and discards both', () => {
  956. const { limits, models, face, state } = card()
  957. face.editLimit('maxDepth', '1.5')
  958. face.toggleEnabled()
  959. face.save()
  960. expect(limits.set).not.toHaveBeenCalled()
  961. expect(models.mutate).not.toHaveBeenCalled()
  962. face.discard()
  963. expect(state()).toMatchObject({ dirty: false, invalid: false })
  964. expect(face.hooks.subagentLimitsCard.getSnapshot().maxDepth.text).toBe('3')
  965. expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
  966. })
  967. it('retains a rejected model draft after limits save, and retries only that draft', async () => {
  968. const { limits, models, face, state } = card()
  969. models.mutate.mockImplementationOnce(() => {})
  970. face.editLimit('maxDepth', '2')
  971. face.toggleEnabled()
  972. face.save()
  973. await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: true, failed: true }) })
  974. expect(face.hooks.subagentLimitsCard.getSnapshot().dirty).toBe(false)
  975. expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: true, dirty: true })
  976. face.save()
  977. await vi.waitFor(() => { expect(state()).toMatchObject({ saving: false, dirty: false, failed: false }) })
  978. expect(limits.set).toHaveBeenCalledOnce()
  979. expect(models.mutate).toHaveBeenCalledTimes(2)
  980. })
  981. })