client-handler.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. /**
  2. * Wire-protocol coverage over the isomorphic point: InProcessApiClient →
  3. * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod
  4. * two-level parse, and rpcId discipline with no network or browser. Each case
  5. * scripts its own minimal ApiProxy.
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import type { SessionId } from '@deepseek-ai/dsh-session'
  9. import type { ApiProxy, GoalRef, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
  10. import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  11. const sid = (id: string): SessionId => id as SessionId
  12. function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
  13. return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
  14. }
  15. /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
  16. function scriptedApi(overrides: {
  17. subagents?: Partial<ApiProxy['subagents']>
  18. host?: Partial<ApiProxy['host']>
  19. skills?: Partial<ApiProxy['skills']>
  20. agentPresets?: Partial<ApiProxy['agentPresets']>
  21. goals?: Partial<ApiProxy['goals']>
  22. settings?: Partial<ApiProxy['settings']>
  23. credentials?: Partial<ApiProxy['credentials']>
  24. llm?: Partial<ApiProxy['llm']>
  25. } = {}): ApiProxy {
  26. const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
  27. Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
  28. return {
  29. subagents: {
  30. list: r => ok(r, { entries: [], parentAvailable: false }),
  31. prompt: r => ok(r, { messageId: 'message-1' as never }),
  32. interrupt: r => ok(r, { accepted: true as const }),
  33. ...overrides.subagents,
  34. },
  35. host: {
  36. describe: r => ok(r, {
  37. version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
  38. }),
  39. pickDirectory: r => ok(r, { path: null }),
  40. listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
  41. createDirectory: r => ok(r, { path: '/t/new' }),
  42. openPath: r => ok(r, { opened: true as const }),
  43. ...overrides.host,
  44. },
  45. skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
  46. agentPresets: {
  47. list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
  48. select: r => ok(r, { agentPreset: r.payload.agentPreset }),
  49. read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
  50. copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
  51. openDocument: r => ok(r, { opened: true as const }),
  52. remove: r => ok(r, {}),
  53. ...overrides.agentPresets,
  54. },
  55. goals: {
  56. create: err,
  57. edit: err,
  58. pause: err,
  59. resume: err,
  60. complete: err,
  61. clear: err,
  62. ...overrides.goals,
  63. },
  64. settings: {
  65. describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
  66. openDocument: r => ok(r, { opened: true as const }),
  67. update: err,
  68. replace: err,
  69. mutate: err,
  70. ...overrides.settings,
  71. },
  72. credentials: {
  73. describe: r => ok(r, { credentials: {} }),
  74. set: err,
  75. unset: err,
  76. ...overrides.credentials,
  77. },
  78. llm: {
  79. providers: r => ok(r, { providers: [] }),
  80. models: r => ok(r, {
  81. default: { provider: 'test', model: 'test' },
  82. routableProviders: [],
  83. groups: [],
  84. failures: [],
  85. }),
  86. discoverModels: err,
  87. ...overrides.llm,
  88. },
  89. downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
  90. }
  91. }
  92. function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
  93. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  94. }
  95. /** Wrap one scripted method to record its invocation into `seen` before responding. */
  96. function recorderInto(seen: { method: string; payload: unknown }[]) {
  97. return <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
  98. (r: RpcRequest<P>): Promise<RpcResponse<V>> => {
  99. seen.push({ method, payload: r.payload })
  100. return respond(r)
  101. }
  102. }
  103. describe('unary round trip', () => {
  104. it('carries payload out and value back through the full wire form', async () => {
  105. let seen: RpcRequest<{}> | undefined
  106. const api = scriptedApi({
  107. host: {
  108. describe: (request) => {
  109. seen = request
  110. return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true })
  111. },
  112. },
  113. })
  114. const response = await client(api).host.describe({})
  115. expect(seen?.payload).toEqual({})
  116. expect(seen?.rpcId).toBeTruthy()
  117. expect(response.rpcId).toBe(seen?.rpcId)
  118. expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
  119. })
  120. it('routes the agent-preset roster and switch through the wire', async () => {
  121. const c = client(scriptedApi())
  122. const listed = await c.agentPresets.list({})
  123. expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
  124. // The switch carries the session it is about: the host refuses one whose
  125. // conversation has started, and it can only know which by id.
  126. const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
  127. expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
  128. })
  129. it('passes business errors through as 200 + err result, not a throw', async () => {
  130. const api = scriptedApi({
  131. host: {
  132. describe: request => Promise.resolve({
  133. rpcId: request.rpcId,
  134. result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } },
  135. }),
  136. },
  137. })
  138. const response = await client(api).host.describe({})
  139. expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } })
  140. })
  141. it('throws on rpcId echo mismatch', async () => {
  142. const api = scriptedApi({
  143. host: {
  144. describe: () => Promise.resolve({
  145. rpcId: RpcId('forged'),
  146. result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } },
  147. }),
  148. },
  149. })
  150. await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/)
  151. })
  152. it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
  153. const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
  154. const api = scriptedApi({ subagents: { interrupt } })
  155. const c = client(api)
  156. const accepted = await c.subagents.interrupt({
  157. parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
  158. })
  159. expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
  160. expect(interrupt).toHaveBeenCalledTimes(1)
  161. // The wire schema owns the mode fence: a one-shot address never reaches the impl.
  162. const oneShot = await c.subagents.interrupt({
  163. parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
  164. } as never)
  165. expect(oneShot.result.ok).toBe(false)
  166. if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
  167. const incomplete = await c.subagents.interrupt({
  168. parentSessionId: sid('parent'), mode: 'continuable',
  169. } as never)
  170. expect(incomplete.result.ok).toBe(false)
  171. if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
  172. expect(interrupt).toHaveBeenCalledTimes(1)
  173. })
  174. it('rejects a method/path mismatch as bad-request', async () => {
  175. const handler = toFetchHandler(scriptedApi())
  176. const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }
  177. const response = await handler.fetch('http://dsh.internal/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
  178. expect(response.status).toBe(200)
  179. const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
  180. expect(parsed.result.ok).toBe(false)
  181. expect(parsed.result.error?.code).toBe('bad-request')
  182. expect(parsed.result.error?.message).toMatch(/does not match path/)
  183. })
  184. it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
  185. const handler = toFetchHandler(scriptedApi())
  186. // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
  187. const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
  188. expect(noId.status).toBe(200)
  189. const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
  190. expect(noIdParsed.result.ok).toBe(false)
  191. expect(noIdParsed.rpcId).toBe('invalid-request')
  192. // A string rpcId in the otherwise-bad body is salvaged for correlation.
  193. const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
  194. const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
  195. expect(withIdParsed.result.ok).toBe(false)
  196. expect(withIdParsed.rpcId).toBe('salvage-me')
  197. })
  198. it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
  199. const handler = toFetchHandler(scriptedApi())
  200. // Unknown method → 404.
  201. const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
  202. expect(notFound.status).toBe(404)
  203. // Non-JSON body → 400.
  204. const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
  205. expect(badBody.status).toBe(400)
  206. // Impl crash → 500, and through the client that is a throw, not an err result.
  207. const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } })
  208. await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/)
  209. })
  210. it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
  211. const describe = vi.fn((request: RpcRequest<{}>) => ok(request, {
  212. version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
  213. }))
  214. const handler = toFetchHandler(scriptedApi({ host: { describe } }))
  215. const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} })
  216. // A "simple" browser POST (text/plain — sent with no CORS preflight) is
  217. // refused at the carrier before the impl runs.
  218. const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
  219. expect(plain.status).toBe(415)
  220. // A string body with no explicit header defaults to text/plain — same fence.
  221. const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body })
  222. expect(unlabelled.status).toBe(415)
  223. expect(describe).not.toHaveBeenCalled()
  224. // Media-type parameters pass: the fence checks the type, not the exact string.
  225. const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
  226. expect(charset.status).toBe(200)
  227. expect(describe).toHaveBeenCalledTimes(1)
  228. })
  229. it('rejects when the transport never resolves within timeoutMs', async () => {
  230. // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
  231. const never = new InProcessApiClient({
  232. fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
  233. init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
  234. }),
  235. }, 25)
  236. await expect(never.host.describe({})).rejects.toThrow()
  237. })
  238. it('aborts a unary call through the caller-supplied external signal', async () => {
  239. // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
  240. // works even when the transport ignores the signal entirely (hung impl).
  241. const gate = new AbortController()
  242. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  243. const call = hung.host.describe({}, gate.signal)
  244. gate.abort(new Error('externally aborted'))
  245. await expect(call).rejects.toThrow(/externally aborted/)
  246. })
  247. it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
  248. let touched = false
  249. const c = new InProcessApiClient({
  250. fetch: () => {
  251. touched = true
  252. return Promise.resolve(new Response('{}'))
  253. },
  254. }, 60_000)
  255. const gate = new AbortController()
  256. gate.abort('gone before start')
  257. await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start')
  258. expect(touched).toBe(false)
  259. })
  260. it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
  261. const gate = new AbortController()
  262. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  263. const call = hung.host.describe({}, gate.signal)
  264. gate.abort(42)
  265. await expect(call).rejects.toThrow('This operation was aborted')
  266. })
  267. it('passes a signal-less doFetch straight through to the handler', async () => {
  268. class Probe extends InProcessApiClient {
  269. direct(url: URL): Promise<Response> {
  270. return this.doFetch(url)
  271. }
  272. }
  273. const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
  274. const response = await probe.direct(new URL('http://dsh.internal/probe'))
  275. expect(await response.text()).toBe('raw')
  276. })
  277. it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
  278. // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
  279. const api = scriptedApi({
  280. host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never },
  281. })
  282. await expect(client(api).host.describe({})).rejects.toThrow()
  283. })
  284. })
  285. describe('goals unary surface', () => {
  286. const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
  287. /** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */
  288. const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } }
  289. it('round-trips every goal method with its own payload and value shape', async () => {
  290. const seen: { method: string; payload: unknown }[] = []
  291. const record = recorderInto(seen)
  292. const api = scriptedApi({
  293. goals: {
  294. create: record('goal.create', r => ok(r, ack)),
  295. edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })),
  296. pause: record('goal.pause', r => ok(r, ack)),
  297. resume: record('goal.resume', r => ok(r, ack)),
  298. complete: record('goal.complete', r => ok(r, ack)),
  299. clear: record('goal.clear', r => ok(r, { cleared: true as const })),
  300. },
  301. })
  302. const c = client(api)
  303. const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
  304. expect(created.result).toEqual({ ok: true, value: ack })
  305. const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
  306. expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } })
  307. expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  308. expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  309. expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  310. const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
  311. expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
  312. // The handler dispatched each call through its own route row: payload parsed per method.
  313. expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
  314. expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
  315. expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
  316. })
  317. it('passes business errors through as results, not throws', async () => {
  318. // Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
  319. const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
  320. expect(failed.result.ok).toBe(false)
  321. if (!failed.result.ok) expect(failed.result.error.code).toBe('internal')
  322. })
  323. it('rejects an invalid goal payload at the handler as bad-request', async () => {
  324. const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' })
  325. expect(response.result.ok).toBe(false)
  326. if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
  327. let editCalls = 0
  328. const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } })
  329. const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref })
  330. expect(emptyEdit.result.ok).toBe(false)
  331. if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request')
  332. expect(editCalls).toBe(0)
  333. })
  334. })
  335. describe('envelope tap', () => {
  336. it('delivers one microtask batch of full forms per unary call', async () => {
  337. const api = scriptedApi()
  338. const tapped = client(api)
  339. const batches: (readonly RpcMessage[])[] = []
  340. tapped.subscribeEnvelopes(batch => batches.push(batch))
  341. await tapped.host.describe({})
  342. await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
  343. const all = batches.flat()
  344. expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
  345. expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
  346. })
  347. it('isolates a throwing listener and keeps serving the call', async () => {
  348. const api = scriptedApi()
  349. const tapped = client(api)
  350. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  351. try {
  352. const good: string[] = []
  353. tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
  354. tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
  355. const response = await tapped.host.describe({})
  356. expect(response.result.ok).toBe(true)
  357. await vi.waitFor(() => { expect(good).toContain('server-response') })
  358. } finally {
  359. errorSpy.mockRestore()
  360. }
  361. })
  362. it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
  363. const api = scriptedApi()
  364. const tapped = client(api)
  365. await tapped.host.describe({}) // no subscribers: must not accumulate
  366. const batches: (readonly RpcMessage[])[] = []
  367. const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
  368. unsubscribe()
  369. await tapped.host.describe({})
  370. await new Promise(resolve => setTimeout(resolve, 0))
  371. expect(batches).toEqual([])
  372. })
  373. })
  374. describe('config unary surface', () => {
  375. it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => {
  376. const seen: { method: string; payload: unknown }[] = []
  377. const record = recorderInto(seen)
  378. const view = {
  379. ns: 'llm-deepseek',
  380. schema: { uid: 1, refs: { 1: { type: 'object' } } },
  381. value: { baseURL: 'https://next' },
  382. user: { baseURL: 'https://next' },
  383. applies: 'live' as const,
  384. secrets: [{ path: ['apiKey'], set: true }],
  385. revision: 0,
  386. }
  387. const providerRow = {
  388. provider: 'openai',
  389. displayName: 'openai',
  390. settingsNs: 'llm-pi-ai',
  391. settingsPath: ['providers', 'openai'],
  392. active: false,
  393. }
  394. const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
  395. const api = scriptedApi({
  396. settings: {
  397. describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
  398. openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
  399. update: record('settings.update', r => ok(r, view)),
  400. replace: record('settings.replace', r => ok(r, view)),
  401. mutate: record('settings.mutate', r => ok(r, view)),
  402. },
  403. credentials: {
  404. describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })),
  405. set: record('credentials.set', r => ok(r, {})),
  406. unset: record('credentials.unset', r => ok(r, {})),
  407. },
  408. llm: {
  409. providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
  410. models: record('llm.models', r => ok(r, {
  411. default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  412. routableProviders: ['deepseek-official'],
  413. groups: [group],
  414. failures: [],
  415. })),
  416. discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
  417. },
  418. })
  419. const c = client(api)
  420. const described = await c.settings.describe({})
  421. expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
  422. expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
  423. const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
  424. expect(updated.result).toEqual({ ok: true, value: view })
  425. const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
  426. expect(replaced.result).toEqual({ ok: true, value: view })
  427. const mutated = await c.settings.mutate({
  428. ns: 'llm-deepseek',
  429. ops: [{ op: 'unset', path: ['baseURL'] }],
  430. expectedRevision: 0,
  431. })
  432. expect(mutated.result).toEqual({ ok: true, value: view })
  433. const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] })
  434. expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } })
  435. expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} })
  436. expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} })
  437. const providers = await c.llm.providers({})
  438. expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
  439. const models = await c.llm.models({})
  440. expect(models.result).toEqual({
  441. ok: true,
  442. value: {
  443. default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  444. routableProviders: ['deepseek-official'],
  445. groups: [group],
  446. failures: [],
  447. },
  448. })
  449. const discovered = await c.llm.discoverModels({
  450. settingsNs: 'llm-pi-ai',
  451. baseURL: 'https://gateway.acme.example/v1',
  452. api: 'openai-completions',
  453. apiKey: 'probe-key',
  454. })
  455. expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
  456. expect(seen.map(call => call.method)).toEqual([
  457. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  458. 'credentials.describe', 'credentials.set', 'credentials.unset',
  459. 'llm.providers', 'llm.models', 'llm.discoverModels',
  460. ])
  461. expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
  462. expect(seen[4]?.payload)
  463. .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
  464. expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
  465. // The draft crosses whole, credential included: the host needs it for this
  466. // one interrogation and stores none of it.
  467. expect(seen[10]?.payload).toEqual({
  468. settingsNs: 'llm-pi-ai',
  469. baseURL: 'https://gateway.acme.example/v1',
  470. api: 'openai-completions',
  471. apiKey: 'probe-key',
  472. })
  473. })
  474. it('rejects an invalid credential reference name at the carrier boundary', async () => {
  475. const api = scriptedApi()
  476. const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' })
  477. expect(response.result.ok).toBe(false)
  478. if (response.result.ok) throw new Error('unreachable')
  479. expect(response.result.error.code).toBe('bad-request')
  480. })
  481. })