client-handler.spec.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  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, rpcId discipline, and SSE framing with no network and no
  5. * browser. Each case 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, HostFrame, MuxFrame, 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. sessions?: Partial<ApiProxy['sessions']>
  18. subagents?: Partial<ApiProxy['subagents']>
  19. host?: Partial<ApiProxy['host']>
  20. commands?: Partial<ApiProxy['commands']>
  21. skills?: Partial<ApiProxy['skills']>
  22. agentPresets?: Partial<ApiProxy['agentPresets']>
  23. events?: Partial<ApiProxy['events']>
  24. goals?: Partial<ApiProxy['goals']>
  25. settings?: Partial<ApiProxy['settings']>
  26. credentials?: Partial<ApiProxy['credentials']>
  27. llm?: Partial<ApiProxy['llm']>
  28. respond?: ApiProxy['respond']
  29. } = {}): ApiProxy {
  30. async function *empty<F>(): AsyncGenerator<RpcRequest<F>> { /* no frames */ }
  31. const err = <T>(r: RpcRequest<unknown>): Promise<RpcResponse<T>> =>
  32. Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } })
  33. return {
  34. sessions: {
  35. list: r => ok(r, { items: [] }),
  36. search: r => ok(r, { items: [], hasMore: false }),
  37. create: r => ok(r, { sessionId: sid('s-new') }),
  38. history: r => ok(r, {
  39. events: [],
  40. hasMore: false,
  41. modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  42. }),
  43. models: r => ok(r, {
  44. current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  45. routable: true,
  46. groups: [],
  47. failures: [],
  48. }),
  49. selectModel: r => ok(r, {
  50. selected: { provider: r.payload.provider, model: r.payload.model },
  51. }),
  52. rename: r => ok(r, { title: 'renamed', seq: 0 }),
  53. fork: r => ok(r, { sessionId: sid('s-fork') }),
  54. prompt: r => ok(r, { accepted: true as const }),
  55. attachment: r => ok(r, {
  56. attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
  57. data: 'AA==',
  58. }),
  59. updateQueue: r => ok(r, { accepted: true as const }),
  60. cancel: r => ok(r, { accepted: true as const }),
  61. ...overrides.sessions,
  62. },
  63. subagents: {
  64. list: r => ok(r, { entries: [], parentAvailable: false }),
  65. history: r => ok(r, { events: [], hasMore: false }),
  66. prompt: r => ok(r, { messageId: 'message-1' as never }),
  67. interrupt: r => ok(r, { accepted: true as const }),
  68. ...overrides.subagents,
  69. },
  70. host: {
  71. describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
  72. pickDirectory: r => ok(r, { path: null }),
  73. listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
  74. createDirectory: r => ok(r, { path: '/t/new' }),
  75. openPath: r => ok(r, { opened: true as const }),
  76. ...overrides.host,
  77. },
  78. workspace: {
  79. list: r => ok(r, { items: [], archivedSessionIds: [] }),
  80. create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
  81. rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
  82. delete: r => ok(r, { deleted: true as const }),
  83. insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }),
  84. insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
  85. archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
  86. },
  87. commands: {
  88. list: r => ok(r, { commands: [] }),
  89. execute: r => ok(r, { matched: false }),
  90. ...overrides.commands,
  91. },
  92. skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
  93. agentPresets: {
  94. list: r => ok(r, { presets: [], authorable: false, hasDocument: false }),
  95. select: r => ok(r, { agentPreset: r.payload.agentPreset }),
  96. read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
  97. copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
  98. openDocument: r => ok(r, { opened: true as const }),
  99. remove: r => ok(r, {}),
  100. ...overrides.agentPresets,
  101. },
  102. goals: {
  103. create: err,
  104. edit: err,
  105. pause: err,
  106. resume: err,
  107. complete: err,
  108. clear: err,
  109. ...overrides.goals,
  110. },
  111. settings: {
  112. describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
  113. openDocument: r => ok(r, { opened: true as const }),
  114. update: err,
  115. replace: err,
  116. mutate: err,
  117. ...overrides.settings,
  118. },
  119. credentials: {
  120. describe: r => ok(r, { credentials: {} }),
  121. set: err,
  122. unset: err,
  123. ...overrides.credentials,
  124. },
  125. llm: {
  126. providers: r => ok(r, { providers: [] }),
  127. models: r => ok(r, { groups: [], failures: [] }),
  128. discoverModels: err,
  129. ...overrides.llm,
  130. },
  131. events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
  132. respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
  133. downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
  134. }
  135. }
  136. function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
  137. return new InProcessApiClient(toFetchHandler(api), timeoutMs)
  138. }
  139. /** Wrap one scripted method to record its invocation into `seen` before responding. */
  140. function recorderInto(seen: { method: string; payload: unknown }[]) {
  141. return <P, V>(method: string, respond: (r: RpcRequest<P>) => Promise<RpcResponse<V>>) =>
  142. (r: RpcRequest<P>): Promise<RpcResponse<V>> => {
  143. seen.push({ method, payload: r.payload })
  144. return respond(r)
  145. }
  146. }
  147. describe('unary round trip', () => {
  148. it('carries payload out and value back through the full wire form', async () => {
  149. let seen: RpcRequest<{ cursor?: string }> | undefined
  150. const api = scriptedApi({
  151. sessions: {
  152. list: (r) => {
  153. seen = r
  154. return ok(r, { items: [{ sessionId: sid('s1'), updatedAt: 7, running: false, blank: false }] })
  155. },
  156. },
  157. })
  158. const response = await client(api).sessions.list({ cursor: 'c1' })
  159. // Impl received the narrow form with a minted id; client returned the same id and value.
  160. expect(seen?.payload).toEqual({ cursor: 'c1' })
  161. expect(seen?.rpcId).toBeTruthy()
  162. expect(response.rpcId).toBe(seen?.rpcId)
  163. expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false, blank: false }] } })
  164. })
  165. it('round-trips a trimmed session search query and its bounded result metadata', async () => {
  166. let seen: RpcRequest<{ query: string }> | undefined
  167. const api = scriptedApi({
  168. sessions: {
  169. search: (request) => {
  170. seen = request
  171. return ok(request, {
  172. items: [{ sessionId: sid('s1'), snippet: 'matching message text' }],
  173. hasMore: true,
  174. })
  175. },
  176. },
  177. })
  178. const response = await client(api).sessions.search({ query: ' message text ' })
  179. expect(seen?.payload).toEqual({ query: 'message text' })
  180. expect(response.result).toEqual({
  181. ok: true,
  182. value: {
  183. items: [{ sessionId: 's1', snippet: 'matching message text' }],
  184. hasMore: true,
  185. },
  186. })
  187. })
  188. it('rejects an overlong session-search snippet at the client value boundary', async () => {
  189. const api = scriptedApi({
  190. sessions: {
  191. search: request => ok(request, {
  192. items: [{ sessionId: sid('s1'), snippet: '😀'.repeat(241) }],
  193. hasMore: false,
  194. }),
  195. },
  196. })
  197. await expect(client(api).sessions.search({ query: 'message' }))
  198. .rejects.toThrow(/240 Unicode code points/)
  199. })
  200. it('routes session fork with its optional cut anchor through the wire', async () => {
  201. let seen: RpcRequest<{ sessionId: SessionId; atSeq?: number }> | undefined
  202. const api = scriptedApi({
  203. sessions: {
  204. fork: (request) => {
  205. seen = request
  206. return ok(request, { sessionId: sid('s-child') })
  207. },
  208. },
  209. })
  210. const response = await client(api).sessions.fork({ sessionId: sid('s-parent'), atSeq: 7 })
  211. expect(seen?.payload).toEqual({ sessionId: 's-parent', atSeq: 7 })
  212. expect(response.result).toEqual({ ok: true, value: { sessionId: 's-child' } })
  213. })
  214. it('routes workspace rename, delete, and ordering through the wire', async () => {
  215. const api = scriptedApi()
  216. const c = client(api)
  217. const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
  218. expect(renamed.result.ok).toBe(true)
  219. const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
  220. expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
  221. const deleted = await c.workspace.delete({ workspaceId: 'w1' as never })
  222. expect(deleted.result).toEqual({ ok: true, value: { deleted: true } })
  223. const workspaceOrder = await c.workspace.insertBefore({
  224. workspaceId: 'w1' as never,
  225. beforeWorkspaceId: 'w2' as never,
  226. })
  227. expect(workspaceOrder.result).toEqual({ ok: true, value: { workspaceIds: ['w1'] } })
  228. const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
  229. expect(anchored.result.ok).toBe(true)
  230. const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
  231. expect(appended.result.ok).toBe(true)
  232. })
  233. it('routes the agent-preset roster and switch through the wire', async () => {
  234. const c = client(scriptedApi())
  235. const listed = await c.agentPresets.list({})
  236. expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
  237. // The switch carries the session it is about: the host refuses one whose
  238. // conversation has started, and it can only know which by id.
  239. const selected = await c.agentPresets.select({ sessionId: sid('s1'), agentPreset: 'standard' })
  240. expect(selected.result).toEqual({ ok: true, value: { agentPreset: 'standard' } })
  241. })
  242. it('passes business errors through as 200 + err result, not a throw', async () => {
  243. const api = scriptedApi({
  244. sessions: {
  245. cancel: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: sid('sx') } } } }),
  246. },
  247. })
  248. const response = await client(api).sessions.cancel({ sessionId: sid('sx') })
  249. expect(response.result).toEqual({ ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: 'sx' } } })
  250. })
  251. it('throws on rpcId echo mismatch', async () => {
  252. const api = scriptedApi({
  253. sessions: { list: () => Promise.resolve({ rpcId: RpcId('forged'), result: { ok: true, value: { items: [] } } }) },
  254. })
  255. await expect(client(api).sessions.list({})).rejects.toThrow(/rpcId mismatch/)
  256. })
  257. it('rejects an invalid payload at the handler as 200 + bad-request with issues', async () => {
  258. const api = scriptedApi()
  259. const response = await client(api).sessions.history({ sessionId: 123 as unknown as SessionId })
  260. expect(response.result.ok).toBe(false)
  261. if (!response.result.ok) {
  262. expect(response.result.error.code).toBe('bad-request')
  263. expect((response.result.error.details as { issues: unknown[] }).issues.length).toBeGreaterThan(0)
  264. }
  265. })
  266. it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
  267. const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
  268. const api = scriptedApi({ subagents: { interrupt } })
  269. const c = client(api)
  270. const accepted = await c.subagents.interrupt({
  271. parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
  272. })
  273. expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
  274. expect(interrupt).toHaveBeenCalledTimes(1)
  275. // The wire schema owns the mode fence: a one-shot address never reaches the impl.
  276. const oneShot = await c.subagents.interrupt({
  277. parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
  278. } as never)
  279. expect(oneShot.result.ok).toBe(false)
  280. if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
  281. const incomplete = await c.subagents.interrupt({
  282. parentSessionId: sid('parent'), mode: 'continuable',
  283. } as never)
  284. expect(incomplete.result.ok).toBe(false)
  285. if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
  286. expect(interrupt).toHaveBeenCalledTimes(1)
  287. })
  288. it('rejects a method/path mismatch as bad-request', async () => {
  289. const handler = toFetchHandler(scriptedApi())
  290. const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
  291. const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
  292. expect(response.status).toBe(200)
  293. const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
  294. expect(parsed.result.ok).toBe(false)
  295. expect(parsed.result.error?.code).toBe('bad-request')
  296. expect(parsed.result.error?.message).toMatch(/does not match path/)
  297. })
  298. it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
  299. const handler = toFetchHandler(scriptedApi())
  300. // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
  301. const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
  302. expect(noId.status).toBe(200)
  303. const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
  304. expect(noIdParsed.result.ok).toBe(false)
  305. expect(noIdParsed.rpcId).toBe('invalid-request')
  306. // A string rpcId in the otherwise-bad body is salvaged for correlation.
  307. const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
  308. const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
  309. expect(withIdParsed.result.ok).toBe(false)
  310. expect(withIdParsed.rpcId).toBe('salvage-me')
  311. })
  312. it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
  313. const handler = toFetchHandler(scriptedApi())
  314. // Unknown method → 404.
  315. const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
  316. expect(notFound.status).toBe(404)
  317. // Non-JSON body → 400.
  318. const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
  319. expect(badBody.status).toBe(400)
  320. // Impl crash → 500, and through the client that is a throw, not an err result.
  321. const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
  322. await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
  323. })
  324. it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
  325. const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
  326. const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
  327. const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
  328. // A "simple" browser POST (text/plain — sent with no CORS preflight) is
  329. // refused at the carrier before the impl runs.
  330. const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
  331. expect(plain.status).toBe(415)
  332. // A string body with no explicit header defaults to text/plain — same fence.
  333. const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
  334. expect(unlabelled.status).toBe(415)
  335. expect(list).not.toHaveBeenCalled()
  336. // Media-type parameters pass: the fence checks the type, not the exact string.
  337. const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
  338. expect(charset.status).toBe(200)
  339. expect(list).toHaveBeenCalledTimes(1)
  340. })
  341. it('rejects when the transport never resolves within timeoutMs', async () => {
  342. // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
  343. const never = new InProcessApiClient({
  344. fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
  345. init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
  346. }),
  347. }, 25)
  348. await expect(never.sessions.list({})).rejects.toThrow()
  349. })
  350. it('aborts a unary call through the caller-supplied external signal', async () => {
  351. // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
  352. // works even when the transport ignores the signal entirely (hung impl).
  353. const gate = new AbortController()
  354. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  355. const call = hung.sessions.list({}, gate.signal)
  356. gate.abort(new Error('externally aborted'))
  357. await expect(call).rejects.toThrow(/externally aborted/)
  358. })
  359. it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
  360. let touched = false
  361. const c = new InProcessApiClient({
  362. fetch: () => {
  363. touched = true
  364. return Promise.resolve(new Response('{}'))
  365. },
  366. }, 60_000)
  367. const gate = new AbortController()
  368. gate.abort('gone before start')
  369. await expect(c.sessions.list({}, gate.signal)).rejects.toThrow('gone before start')
  370. expect(touched).toBe(false)
  371. })
  372. it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
  373. const gate = new AbortController()
  374. const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
  375. const call = hung.sessions.list({}, gate.signal)
  376. gate.abort(42)
  377. await expect(call).rejects.toThrow('This operation was aborted')
  378. })
  379. it('passes a signal-less doFetch straight through to the handler', async () => {
  380. class Probe extends InProcessApiClient {
  381. direct(url: URL): Promise<Response> {
  382. return this.doFetch(url)
  383. }
  384. }
  385. const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
  386. const response = await probe.direct(new URL('http://dsh.internal/probe'))
  387. expect(await response.text()).toBe('raw')
  388. })
  389. it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
  390. // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
  391. const api = scriptedApi({
  392. sessions: { list: r => Promise.resolve({ rpcId: r.rpcId, result: { ok: true, value: { items: 'not-an-array' } } }) as never },
  393. })
  394. await expect(client(api).sessions.list({})).rejects.toThrow()
  395. })
  396. })
  397. describe('workspace domain round trip', () => {
  398. it('routes both workspace methods through their handler rows and value schemas', async () => {
  399. const c = client(scriptedApi())
  400. const list = await c.workspace.list({})
  401. expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } })
  402. const created = await c.workspace.create({ path: '/t' })
  403. expect(created.result.ok).toBe(true)
  404. if (created.result.ok) expect(created.result.value.created).toBe(true)
  405. const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never })
  406. expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
  407. })
  408. it('rejects a pathless create payload at the handler schema', async () => {
  409. const response = await client(scriptedApi()).workspace.create({} as never)
  410. expect(response.result.ok).toBe(false)
  411. if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
  412. })
  413. })
  414. describe('SSE stream path', () => {
  415. it('yields frames in order and skips the comment preamble', async () => {
  416. const frames: MuxFrame[] = [
  417. { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 3 },
  418. { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } },
  419. ]
  420. const api = scriptedApi({
  421. events: {
  422. async *mux(request) {
  423. let n = 0
  424. for (const frame of frames) yield { rpcId: RpcId(`push-${n++}-${request.rpcId}`), payload: frame }
  425. },
  426. },
  427. })
  428. const seen: MuxFrame[] = []
  429. for await (const envelope of client(api).events.mux({}, new AbortController().signal)) {
  430. seen.push(envelope.payload)
  431. }
  432. expect(seen).toEqual(frames)
  433. })
  434. it('reassembles frames across arbitrary chunk boundaries', async () => {
  435. // Two SSE frames split so one frame spans chunks and one chunk carries parts of both.
  436. const f1 = { type: 'server-request', rpcId: 'a', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
  437. const f2 = { type: 'server-request', rpcId: 'b', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's2', lastSeq: 2 } }
  438. const wire = `: connected\n\ndata: ${JSON.stringify(f1)}\n\ndata: ${JSON.stringify(f2)}\n\n`
  439. const cuts = [5, 40, wire.indexOf('data: ', 40) + 3]
  440. const encoder = new TextEncoder()
  441. const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
  442. start(controller) {
  443. let prev = 0
  444. for (const cut of [...cuts, wire.length]) {
  445. controller.enqueue(encoder.encode(wire.slice(prev, cut)))
  446. prev = cut
  447. }
  448. controller.close()
  449. },
  450. }), { status: 200 }))
  451. const chopped = new InProcessApiClient({ fetch: doFetch })
  452. const seen: string[] = []
  453. for await (const envelope of chopped.events.mux({}, new AbortController().signal)) {
  454. seen.push((envelope.payload as { sessionId: string }).sessionId)
  455. expect(envelope.rpcId).toBe(seen.length === 1 ? 'a' : 'b')
  456. }
  457. expect(seen).toEqual(['s1', 's2'])
  458. })
  459. it('emits a stream/error frame then closes when the impl throws mid-stream', async () => {
  460. const api = scriptedApi({
  461. events: {
  462. async *host(request): AsyncGenerator<RpcRequest<HostFrame>> {
  463. yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'host/session-added', sessionId: sid('s1'), blank: true } }
  464. throw new Error('impl died mid-stream')
  465. },
  466. },
  467. })
  468. const seen: HostFrame[] = []
  469. for await (const envelope of client(api).events.host({}, new AbortController().signal)) {
  470. seen.push(envelope.payload)
  471. }
  472. expect(seen.map(f => f.type)).toEqual(['host/session-added', 'stream/error'])
  473. const last = seen.at(-1)
  474. if (last?.type === 'stream/error') expect(last.error.message).toMatch(/impl died mid-stream/)
  475. })
  476. it('drops a malformed SSE frame and keeps the stream alive (S→C two-level parse)', async () => {
  477. const good = { type: 'server-request', rpcId: 'g1', method: 'session/subscribed', payload: { type: 'session/subscribed', sessionId: 's1', lastSeq: 1 } }
  478. const badEnvelope = { type: 'server-response', rpcId: 'x' } // wrong quadrant for a stream
  479. const badFrame = { type: 'server-request', rpcId: 'b1', method: 'nope', payload: { type: 'no/such-frame' } }
  480. const wire = [
  481. 'data: {oops', // not JSON
  482. `data: ${JSON.stringify(badEnvelope)}`,
  483. `data: ${JSON.stringify(badFrame)}`,
  484. `data: ${JSON.stringify(good)}`,
  485. ].map(l => `${l}\n\n`).join('')
  486. const doFetch = (): Promise<Response> => Promise.resolve(new Response(new ReadableStream<Uint8Array>({
  487. start(controller) {
  488. controller.enqueue(new TextEncoder().encode(wire))
  489. controller.close()
  490. },
  491. }), { status: 200 }))
  492. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  493. try {
  494. const seen: MuxFrame[] = []
  495. for await (const envelope of new InProcessApiClient({ fetch: doFetch }).events.mux({}, new AbortController().signal)) {
  496. seen.push(envelope.payload)
  497. }
  498. // The three corrupt frames are reported and skipped; the good one still arrives.
  499. expect(seen).toEqual([{ type: 'session/subscribed', sessionId: 's1', lastSeq: 1 }])
  500. expect(errorSpy.mock.calls.length).toBe(3)
  501. } finally {
  502. errorSpy.mockRestore()
  503. }
  504. })
  505. it('fires onOpen once headers are in, before the first frame, and not on transport failure', async () => {
  506. const api = scriptedApi({
  507. events: {
  508. async *mux(request): AsyncGenerator<RpcRequest<MuxFrame>> {
  509. yield { rpcId: RpcId(`p-${request.rpcId}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 0 } }
  510. },
  511. },
  512. })
  513. const order: string[] = []
  514. const iterator = client(api).events.mux({}, new AbortController().signal, () => order.push('open'))
  515. expect(order).toEqual([]) // lazy generator: no fetch (and no onOpen) before iteration
  516. for await (const _ of iterator) order.push('frame')
  517. expect(order).toEqual(['open', 'frame'])
  518. // Transport failure path: onOpen must not fire.
  519. const failing = new InProcessApiClient({ fetch: () => Promise.resolve(new Response('down', { status: 503 })) })
  520. const failOrder: string[] = []
  521. await expect((async () => {
  522. for await (const _ of failing.events.mux({}, new AbortController().signal, () => failOrder.push('open'))) { /* unreachable */ }
  523. })()).rejects.toThrow(/transport failure/)
  524. expect(failOrder).toEqual([])
  525. })
  526. it('stops consuming when the caller aborts', async () => {
  527. let implSawAbort = false
  528. const api = scriptedApi({
  529. events: {
  530. async *mux(_request, signal): AsyncGenerator<RpcRequest<MuxFrame>> {
  531. try {
  532. let n = 0
  533. while (true) {
  534. yield { rpcId: RpcId(`p${n}`), payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: n++ } }
  535. await new Promise(resolve => setTimeout(resolve, 5))
  536. if (signal.aborted) return
  537. }
  538. } finally {
  539. implSawAbort = true
  540. }
  541. },
  542. },
  543. })
  544. const abort = new AbortController()
  545. let count = 0
  546. // In-process abort ends the stream (impl returns on signal.aborted); over a real
  547. // network fetch the same abort surfaces as a rejection — both stop the loop.
  548. await (async () => {
  549. for await (const _ of client(api).events.mux({}, abort.signal)) {
  550. if (++count === 2) abort.abort()
  551. }
  552. })().catch(() => undefined)
  553. expect(count).toBe(2)
  554. // Generator teardown may lag the abort by a microtask; poll briefly.
  555. await vi.waitFor(() => { expect(implSawAbort).toBe(true) })
  556. })
  557. })
  558. describe('goals unary surface', () => {
  559. const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
  560. /** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */
  561. const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } }
  562. it('round-trips every goal method with its own payload and value shape', async () => {
  563. const seen: { method: string; payload: unknown }[] = []
  564. const record = recorderInto(seen)
  565. const api = scriptedApi({
  566. goals: {
  567. create: record('goal.create', r => ok(r, ack)),
  568. edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })),
  569. pause: record('goal.pause', r => ok(r, ack)),
  570. resume: record('goal.resume', r => ok(r, ack)),
  571. complete: record('goal.complete', r => ok(r, ack)),
  572. clear: record('goal.clear', r => ok(r, { cleared: true as const })),
  573. },
  574. })
  575. const c = client(api)
  576. const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
  577. expect(created.result).toEqual({ ok: true, value: ack })
  578. const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
  579. expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } })
  580. expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  581. expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  582. expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
  583. const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
  584. expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
  585. // The handler dispatched each call through its own route row: payload parsed per method.
  586. expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
  587. expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
  588. expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
  589. })
  590. it('passes business errors through as results, not throws', async () => {
  591. // Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
  592. const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
  593. expect(failed.result.ok).toBe(false)
  594. if (!failed.result.ok) expect(failed.result.error.code).toBe('internal')
  595. })
  596. it('rejects an invalid goal payload at the handler as bad-request', async () => {
  597. const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' })
  598. expect(response.result.ok).toBe(false)
  599. if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
  600. let editCalls = 0
  601. const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } })
  602. const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref })
  603. expect(emptyEdit.result.ok).toBe(false)
  604. if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request')
  605. expect(editCalls).toBe(0)
  606. })
  607. })
  608. describe('respond path', () => {
  609. it('round-trips a client-response to a receipt', async () => {
  610. const seen: unknown[] = []
  611. const api = scriptedApi({
  612. respond: (message) => {
  613. seen.push(message)
  614. return Promise.resolve({ accepted: true as const })
  615. },
  616. })
  617. const receipt = await client(api).respond({ type: 'client-response', rpcId: RpcId('req-1'), result: { ok: true, value: { behavior: 'allow' } } })
  618. expect(receipt).toEqual({ accepted: true })
  619. expect(seen).toEqual([{ type: 'client-response', rpcId: 'req-1', result: { ok: true, value: { behavior: 'allow' } } }])
  620. })
  621. it('returns bad-response for a malformed client-response without reaching the impl', async () => {
  622. const respond = vi.fn()
  623. const handler = toFetchHandler(scriptedApi({ respond }))
  624. const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
  625. expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
  626. expect(respond).not.toHaveBeenCalled()
  627. })
  628. })
  629. describe('envelope tap', () => {
  630. it('delivers one microtask batch of full forms per unary call', async () => {
  631. const api = scriptedApi()
  632. const tapped = client(api)
  633. const batches: (readonly RpcMessage[])[] = []
  634. tapped.subscribeEnvelopes(batch => batches.push(batch))
  635. await tapped.sessions.list({})
  636. await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
  637. const all = batches.flat()
  638. expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
  639. expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
  640. })
  641. it('isolates a throwing listener and keeps serving the call', async () => {
  642. const api = scriptedApi()
  643. const tapped = client(api)
  644. const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
  645. try {
  646. const good: string[] = []
  647. tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
  648. tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
  649. const response = await tapped.sessions.list({})
  650. expect(response.result.ok).toBe(true)
  651. await vi.waitFor(() => { expect(good).toContain('server-response') })
  652. } finally {
  653. errorSpy.mockRestore()
  654. }
  655. })
  656. it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
  657. const api = scriptedApi()
  658. const tapped = client(api)
  659. await tapped.sessions.list({}) // no subscribers: must not accumulate
  660. const batches: (readonly RpcMessage[])[] = []
  661. const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
  662. unsubscribe()
  663. await tapped.sessions.list({})
  664. await new Promise(resolve => setTimeout(resolve, 0))
  665. expect(batches).toEqual([])
  666. })
  667. })
  668. describe('config unary surface', () => {
  669. it('round-trips every settings/credentials/llm method with its own payload and value shape', async () => {
  670. const seen: { method: string; payload: unknown }[] = []
  671. const record = recorderInto(seen)
  672. const view = {
  673. ns: 'llm-deepseek',
  674. schema: { uid: 1, refs: { 1: { type: 'object' } } },
  675. value: { baseURL: 'https://next' },
  676. user: { baseURL: 'https://next' },
  677. applies: 'live' as const,
  678. secrets: [{ path: ['apiKey'], set: true }],
  679. revision: 0,
  680. }
  681. const providerRow = {
  682. provider: 'openai',
  683. displayName: 'openai',
  684. settingsNs: 'llm-pi-ai',
  685. settingsPath: ['providers', 'openai'],
  686. active: false,
  687. }
  688. const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
  689. const api = scriptedApi({
  690. settings: {
  691. describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
  692. openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
  693. update: record('settings.update', r => ok(r, view)),
  694. replace: record('settings.replace', r => ok(r, view)),
  695. mutate: record('settings.mutate', r => ok(r, view)),
  696. },
  697. credentials: {
  698. describe: record('credentials.describe', r => ok(r, { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } })),
  699. set: record('credentials.set', r => ok(r, {})),
  700. unset: record('credentials.unset', r => ok(r, {})),
  701. },
  702. llm: {
  703. providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
  704. models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
  705. discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
  706. },
  707. })
  708. const c = client(api)
  709. const described = await c.settings.describe({})
  710. expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
  711. expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
  712. const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
  713. expect(updated.result).toEqual({ ok: true, value: view })
  714. const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
  715. expect(replaced.result).toEqual({ ok: true, value: view })
  716. const mutated = await c.settings.mutate({
  717. ns: 'llm-deepseek',
  718. ops: [{ op: 'unset', path: ['baseURL'] }],
  719. expectedRevision: 0,
  720. })
  721. expect(mutated.result).toEqual({ ok: true, value: view })
  722. const creds = await c.credentials.describe({ refs: ['OPENAI_API_KEY'] })
  723. expect(creds.result).toEqual({ ok: true, value: { credentials: { OPENAI_API_KEY: { configured: true, source: 'file', writable: true } } } })
  724. expect((await c.credentials.set({ ref: 'OPENAI_API_KEY', value: 'sk-x' })).result).toEqual({ ok: true, value: {} })
  725. expect((await c.credentials.unset({ ref: 'OPENAI_API_KEY' })).result).toEqual({ ok: true, value: {} })
  726. const providers = await c.llm.providers({})
  727. expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
  728. const models = await c.llm.models({})
  729. expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
  730. const discovered = await c.llm.discoverModels({
  731. settingsNs: 'llm-pi-ai',
  732. baseURL: 'https://gateway.acme.example/v1',
  733. api: 'openai-completions',
  734. apiKey: 'probe-key',
  735. })
  736. expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
  737. expect(seen.map(call => call.method)).toEqual([
  738. 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
  739. 'credentials.describe', 'credentials.set', 'credentials.unset',
  740. 'llm.providers', 'llm.models', 'llm.discoverModels',
  741. ])
  742. expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
  743. expect(seen[4]?.payload)
  744. .toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
  745. expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
  746. // The draft crosses whole, credential included: the host needs it for this
  747. // one interrogation and stores none of it.
  748. expect(seen[10]?.payload).toEqual({
  749. settingsNs: 'llm-pi-ai',
  750. baseURL: 'https://gateway.acme.example/v1',
  751. api: 'openai-completions',
  752. apiKey: 'probe-key',
  753. })
  754. })
  755. it('rejects an invalid credential reference name at the carrier boundary', async () => {
  756. const api = scriptedApi()
  757. const response = await client(api).credentials.set({ ref: 'not a var', value: 'x' })
  758. expect(response.result.ok).toBe(false)
  759. if (response.result.ok) throw new Error('unreachable')
  760. expect(response.result.error.code).toBe('bad-request')
  761. })
  762. })