api-proxy-subagents.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
  4. import { SubagentError } from '@deepseek-ai/dsh-subagent'
  5. import { RpcId } from '../src/api/rpc.ts'
  6. import type { RpcRequest } from '../src/api/rpc.ts'
  7. import { createApiProxy } from '../src/api-proxy.ts'
  8. const sid = (value: string): SessionId => value as SessionId
  9. const PARENT = sid('parent')
  10. const CHILD = sid('child')
  11. function request<P>(payload: P): RpcRequest<P> {
  12. return { rpcId: RpcId('subagent-rpc'), payload }
  13. }
  14. function bench(options: {
  15. parentLive?: boolean
  16. childStatus?: 'idle' | 'running'
  17. entries?: object[]
  18. followupError?: Error
  19. listError?: Error
  20. /** Persistence forgets the child entirely (the vanished-mid-read race). */
  21. storedChild?: false
  22. /** Attach the child to the live session store instead of persistence only. */
  23. liveChild?: true
  24. /** Every registered projection unit throws on this child's payloads. */
  25. projectionsThrow?: true
  26. historyParent?: SessionId
  27. } = {}) {
  28. const parent = { id: PARENT }
  29. const child = options.childStatus === undefined
  30. ? undefined
  31. : { id: CHILD, status: options.childStatus }
  32. const getAgent = vi.fn((id: SessionId) => {
  33. if (options.parentLive !== false && id === PARENT) return parent
  34. if (id === CHILD) return child
  35. return undefined
  36. })
  37. const listChildren = vi.fn(() => options.listError === undefined
  38. ? Promise.resolve(options.entries ?? [
  39. {
  40. kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
  41. activity: 'inactive', hasChildren: false,
  42. },
  43. ])
  44. : Promise.reject(options.listError))
  45. const followup = vi.fn((
  46. _parent: unknown,
  47. _childId: SessionId,
  48. _content: unknown,
  49. _delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
  50. ) => options.followupError === undefined
  51. ? Promise.resolve('message-1')
  52. : Promise.reject(options.followupError))
  53. const childHeader = {
  54. version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
  55. } satisfies SessionHeader
  56. const childEvents = [
  57. { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
  58. ] as unknown as SessionEvent[]
  59. const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
  60. const liveBlock = { values: {}, asOfSeq: 3 }
  61. const coldBlock = { values: {}, asOfSeq: 0 }
  62. const snapshot = vi.fn(() => {
  63. if (options.projectionsThrow === true) throw new Error('hostile unit')
  64. return liveBlock
  65. })
  66. const restore = vi.fn(() => {
  67. if (options.projectionsThrow === true) throw new Error('hostile unit')
  68. return { snapshot: coldBlock }
  69. })
  70. const ctx = new Context()
  71. ctx.provide('agents', { get: getAgent })
  72. ctx.provide('subagents', { listChildren, followup })
  73. ctx.provide('sessions', {
  74. get: (id: SessionId) => options.liveChild === true && id === CHILD
  75. ? { id: CHILD, header: childHeader, events: childEvents }
  76. : undefined,
  77. })
  78. ctx.provide('sessionPersistence', {
  79. list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]),
  80. inspect,
  81. locate: () => undefined,
  82. })
  83. // The gateway's own projection push feed subscribes at construction; the
  84. // no-op disposer keeps that seam quiet while these tests pin history reads.
  85. ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
  86. ctx.provide('userInteraction', { registerProvider: () => () => {} })
  87. const api = createApiProxy(ctx, {
  88. defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
  89. })
  90. return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
  91. }
  92. describe('subagent gateway', () => {
  93. it('lists the complete catalog and reports exact live-parent availability', async () => {
  94. const { api, listChildren } = bench({ parentLive: false, entries: [
  95. {
  96. kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
  97. activity: 'inactive', hasChildren: true,
  98. },
  99. {
  100. kind: 'child', id: sid('one-shot'), mode: 'one-shot',
  101. activity: 'inactive', hasChildren: false,
  102. },
  103. { kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
  104. ] })
  105. const response = await api.subagents.list(request({ parentSessionId: PARENT }))
  106. expect(response.rpcId).toBe('subagent-rpc')
  107. expect(response.result).toMatchObject({
  108. ok: true,
  109. value: {
  110. parentAvailable: false,
  111. entries: [
  112. { kind: 'child', mode: 'continuable' },
  113. { kind: 'child', mode: 'one-shot' },
  114. { kind: 'diagnostic' },
  115. ],
  116. },
  117. })
  118. expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
  119. })
  120. it('derives catalog activity from the live child Agent rather than Session residency', async () => {
  121. const residentIdle = bench({ childStatus: 'idle', entries: [{
  122. kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
  123. activity: 'running', hasChildren: false,
  124. }] })
  125. expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
  126. .toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
  127. const running = bench({ childStatus: 'running' })
  128. expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
  129. .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
  130. })
  131. it('reads a healthy direct child without looking up or activating any Agent', async () => {
  132. const { api, getAgent, inspect, restore } = bench()
  133. const response = await api.subagents.history(request({
  134. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
  135. }))
  136. expect(response.result).toMatchObject({
  137. ok: true,
  138. value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
  139. })
  140. expect(inspect).toHaveBeenCalledWith(CHILD)
  141. expect(restore).toHaveBeenCalledTimes(1)
  142. expect(getAgent).not.toHaveBeenCalled()
  143. })
  144. it('serves a live child from the in-memory snapshot and the watermark projections', async () => {
  145. const { api, inspect, snapshot, restore } = bench({ liveChild: true })
  146. const response = await api.subagents.history(request({
  147. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  148. }))
  149. expect(response.result).toMatchObject({
  150. ok: true,
  151. value: { hasMore: false, projections: { asOfSeq: 3 } },
  152. })
  153. expect(snapshot).toHaveBeenCalledTimes(1)
  154. expect(restore).not.toHaveBeenCalled()
  155. expect(inspect).not.toHaveBeenCalled()
  156. })
  157. it('serves the page without projections when a hostile unit breaks the fold', async () => {
  158. const cold = bench({ projectionsThrow: true })
  159. const coldResponse = await cold.api.subagents.history(request({
  160. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  161. }))
  162. expect(coldResponse.result).toMatchObject({
  163. ok: true,
  164. value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
  165. })
  166. if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
  167. const live = bench({ projectionsThrow: true, liveChild: true })
  168. const liveResponse = await live.api.subagents.history(request({
  169. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  170. }))
  171. expect(liveResponse.result).toMatchObject({
  172. ok: true,
  173. value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
  174. })
  175. if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
  176. expect(live.snapshot).toHaveBeenCalledTimes(1)
  177. })
  178. it('reads one-shot history and rejects an address with the wrong mode', async () => {
  179. const oneShot = {
  180. kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
  181. activity: 'inactive', hasChildren: false,
  182. }
  183. const { api, inspect } = bench({ entries: [oneShot] })
  184. expect((await api.subagents.history(request({
  185. parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
  186. }))).result).toMatchObject({ ok: true })
  187. expect((await api.subagents.history(request({
  188. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  189. }))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
  190. expect(inspect).toHaveBeenCalledTimes(1)
  191. })
  192. it('rejects a diagnostic address before reading history', async () => {
  193. const { api, inspect } = bench({ entries: [
  194. { kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
  195. ] })
  196. const response = await api.subagents.history(request({
  197. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  198. }))
  199. expect(response.result).toMatchObject({
  200. ok: false,
  201. error: {
  202. code: 'subagent-catalog-diagnostic',
  203. details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
  204. },
  205. })
  206. expect(inspect).not.toHaveBeenCalled()
  207. })
  208. it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
  209. const listError = () => new SubagentError(
  210. 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
  211. 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
  212. )
  213. const expected = {
  214. code: 'internal',
  215. message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
  216. }
  217. const list = bench({ listError: listError() })
  218. expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
  219. .toMatchObject({ ok: false, error: expected })
  220. const history = bench({ listError: listError() })
  221. expect((await history.api.subagents.history(request({
  222. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  223. }))).result).toMatchObject({ ok: false, error: expected })
  224. expect(history.inspect).not.toHaveBeenCalled()
  225. const prompt = bench({ listError: listError() })
  226. expect((await prompt.api.subagents.prompt(request({
  227. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
  228. }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
  229. expect(prompt.followup).not.toHaveBeenCalled()
  230. })
  231. it('routes human content through the exact live parent with rpc attribution', async () => {
  232. const { api, parent, followup } = bench()
  233. const content = [{ type: 'text' as const, text: '继续' }]
  234. const signal = new AbortController().signal
  235. const response = await api.subagents.prompt(request({
  236. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
  237. }), signal)
  238. expect(response.result).toMatchObject({
  239. ok: true, value: { messageId: 'message-1' },
  240. })
  241. expect(followup).toHaveBeenCalledWith(
  242. parent,
  243. CHILD,
  244. content,
  245. { source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
  246. )
  247. })
  248. it('fails before delivery when the parent is absent and maps continuation failures', async () => {
  249. const absent = bench({ parentLive: false })
  250. expect((await absent.api.subagents.prompt(request({
  251. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
  252. }), new AbortController().signal)).result).toMatchObject({
  253. ok: false, error: { code: 'subagent-parent-unavailable' },
  254. })
  255. expect(absent.listChildren).not.toHaveBeenCalled()
  256. const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
  257. expect((await failed.api.subagents.prompt(request({
  258. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
  259. }), new AbortController().signal)).result).toMatchObject({
  260. ok: false, error: { code: 'subagent-delivery-unavailable' },
  261. })
  262. })
  263. it('maps history disappearance and hides unexpected backend details', async () => {
  264. const disappeared = bench({ storedChild: false })
  265. expect((await disappeared.api.subagents.history(request({
  266. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
  267. }))).result).toMatchObject({
  268. ok: false,
  269. error: {
  270. code: 'subagent-not-found',
  271. message: 'subagent disappeared during history read',
  272. details: { parentSessionId: PARENT, childSessionId: CHILD },
  273. },
  274. })
  275. const catalog = bench({ listError: new Error('secret descriptor') })
  276. expect((await catalog.api.subagents.list(request({
  277. parentSessionId: PARENT,
  278. }))).result).toMatchObject({
  279. ok: false,
  280. error: { code: 'internal', message: 'subagent catalog read failed' },
  281. })
  282. const prompt = bench({ followupError: new Error('secret provider') })
  283. expect((await prompt.api.subagents.prompt(request({
  284. parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
  285. }), new AbortController().signal)).result).toMatchObject({
  286. ok: false,
  287. error: { code: 'internal', message: 'subagent prompt failed' },
  288. })
  289. })
  290. })