api-proxy-tasks.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. /**
  2. * Background-task carrier paths of the host ApiProxy: the subscription
  3. * baseline is sent only for a session that has tasks, every registry change
  4. * pushes that owner's whole set, an unowned change fans out to every
  5. * subscribed session, the projection drops the three internal snapshot
  6. * fields, a composition without `ctx.tasks` emits nothing, and listing never
  7. * resumes a cold session.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { Context } from '@deepseek-ai/cordis'
  11. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  14. import type { Session } from '@deepseek-ai/dsh-session'
  15. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  16. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  17. import type { TaskOutcome } from '@deepseek-ai/dsh-tasks'
  18. import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
  19. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  20. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  21. type TaskFrame = Extract<MuxFrame, { type: 'session/tasks' }>
  22. /**
  23. * A producer whose settlement the test drives. `cancel` deliberately does not
  24. * settle, so a kill is observable as the distinct `stopping` step before the
  25. * test supplies the terminal outcome and its detail.
  26. */
  27. function producer(label = 'sleep 60') {
  28. let settle!: (outcome: TaskOutcome) => void
  29. // A stream producer, so the carrier CAN consume the cursor if it ever calls
  30. // `read()`; `reads` is what proves it never does.
  31. const reads = { count: 0 }
  32. const spec = {
  33. kind: 'bash' as const,
  34. label,
  35. run: () => ({
  36. cancel: () => {},
  37. done: new Promise<TaskOutcome>((resolve) => { settle = resolve }),
  38. readOutput: () => { reads.count += 1; return 'stolen output' },
  39. }),
  40. }
  41. return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } }
  42. }
  43. async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> {
  44. const ctx = new Context()
  45. await ctx.plugin(SessionStore)
  46. await ctx.plugin(UserInteractionService)
  47. await ctx.plugin(AgentRegistry)
  48. if (withRegistry) {
  49. await ctx.plugin(LocalTaskService)
  50. ctx.tasks.attachController('api-proxy-test')
  51. }
  52. const session = ctx.sessions.create()
  53. const agent = {
  54. id: session.id,
  55. session,
  56. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  57. status: 'idle',
  58. ctx,
  59. } as Agent
  60. ctx.agents.register(agent)
  61. return { ctx, session, agent }
  62. }
  63. const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  64. /** Drain the mux until `count` session/tasks frames arrived, then abort. */
  65. async function collect(
  66. iterable: AsyncIterable<RpcRequest<MuxFrame>>,
  67. count: number,
  68. abort: AbortController,
  69. ): Promise<TaskFrame[]> {
  70. const frames: MuxFrame[] = []
  71. for await (const envelope of iterable) {
  72. frames.push(envelope.payload)
  73. if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort()
  74. }
  75. return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks')
  76. }
  77. describe('session/tasks subscription baseline', () => {
  78. it('is omitted for a session with no tasks — absence is the empty set', async () => {
  79. const { ctx, session } = await harness(true)
  80. const abort = new AbortController()
  81. const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal)
  82. const frames: MuxFrame[] = []
  83. const drained = (async () => {
  84. for await (const envelope of stream) {
  85. frames.push(envelope.payload)
  86. if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort()
  87. }
  88. })()
  89. await drained
  90. expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
  91. expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true)
  92. void session
  93. })
  94. it('carries the live set for a session that already has tasks when the stream opens', async () => {
  95. const { ctx, session, agent } = await harness(true)
  96. ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent })
  97. const abort = new AbortController()
  98. const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal)
  99. const [baseline] = await collect(stream, 1, abort)
  100. expect(baseline?.sessionId).toBe(session.id)
  101. expect(baseline?.tasks).toHaveLength(1)
  102. const [task] = baseline?.tasks ?? []
  103. expect(task?.startedAt).toBeTypeOf('number')
  104. expect({ ...task, startedAt: 0 }).toEqual({
  105. id: 'bash-1',
  106. kind: 'bash',
  107. label: 'pnpm run build',
  108. status: 'running',
  109. startedAt: 0,
  110. })
  111. })
  112. })
  113. describe('session/tasks change pushes', () => {
  114. it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => {
  115. const { ctx, session, agent } = await harness(true)
  116. const proxy = api(ctx)
  117. const abort = new AbortController()
  118. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal)
  119. const collected = collect(stream, 3, abort)
  120. const p = producer()
  121. const id = ctx.tasks.start({ ...p.spec, owner: agent })
  122. ctx.tasks.kill(id, agent, 'test')
  123. p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
  124. const frames = await collected
  125. expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
  126. expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed'])
  127. // Terminal detail rides the same whole-set push; no separate signal.
  128. expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM')
  129. expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number')
  130. })
  131. it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => {
  132. const { ctx, agent } = await harness(true)
  133. const proxy = api(ctx)
  134. const abort = new AbortController()
  135. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal)
  136. const collected = collect(stream, 1, abort)
  137. ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
  138. const [frame] = await collected
  139. const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {})
  140. expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status'])
  141. })
  142. it('fans an unowned change out to every subscribed session', async () => {
  143. const { ctx } = await harness(true)
  144. const second = ctx.sessions.create()
  145. const proxy = api(ctx)
  146. const abort = new AbortController()
  147. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal)
  148. const collected = collect(stream, 2, abort)
  149. ctx.tasks.start(producer('open to every caller').spec)
  150. const frames = await collected
  151. expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
  152. expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
  153. for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller')
  154. })
  155. it('serves a cold session the unowned set without resuming it', async () => {
  156. const { ctx } = await harness(true)
  157. const coldId = SessionId('session-cold-tasks')
  158. let loaded = false
  159. ctx.provide('sessionPersistence', {
  160. list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
  161. locate: () => undefined,
  162. load: () => { loaded = true; throw new Error('task listing must not load a cold log') },
  163. } as never)
  164. const proxy = api(ctx)
  165. const abort = new AbortController()
  166. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal)
  167. const collected = collect(stream, 1, abort)
  168. ctx.tasks.start(producer().spec)
  169. await collected
  170. expect(loaded).toBe(false)
  171. expect(ctx.agents.get(coldId)).toBeUndefined()
  172. })
  173. })
  174. describe('session/tasks without the registry', () => {
  175. it('emits no frames at all, so the client renders no entry point', async () => {
  176. const { ctx, session } = await harness(false)
  177. const proxy = api(ctx)
  178. const abort = new AbortController()
  179. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal)
  180. const frames: MuxFrame[] = []
  181. const drained = (async () => {
  182. for await (const envelope of stream) {
  183. frames.push(envelope.payload)
  184. if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort()
  185. }
  186. })()
  187. session.append('turn/start', { turn: 1 })
  188. await drained
  189. expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false)
  190. })
  191. })
  192. describe('session/tasks never consumes model output', () => {
  193. it('drives the whole lifecycle without calling the single consuming cursor', async () => {
  194. // `ctx.tasks.read()` consumes the one output cursor, so a carrier read
  195. // silently takes bytes the model's `task_output` will never see. The
  196. // failure is invisible at the call site, which is why this asserts the
  197. // count rather than trusting review.
  198. const { ctx, agent } = await harness(true)
  199. const proxy = api(ctx)
  200. const abort = new AbortController()
  201. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal)
  202. const collected = collect(stream, 3, abort)
  203. const p = producer()
  204. const id = ctx.tasks.start({ ...p.spec, owner: agent })
  205. ctx.tasks.kill(id, agent, 'test')
  206. p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
  207. await collected
  208. expect(p.reads.count).toBe(0)
  209. })
  210. it('reads nothing while minting the subscription baseline either', async () => {
  211. const { ctx, agent } = await harness(true)
  212. const p = producer()
  213. ctx.tasks.start({ ...p.spec, owner: agent })
  214. const abort = new AbortController()
  215. const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal)
  216. const [baseline] = await collect(stream, 1, abort)
  217. expect(baseline?.tasks).toHaveLength(1)
  218. expect(p.reads.count).toBe(0)
  219. })
  220. })
  221. describe('session/tasks baseline for a session born after the stream opened', () => {
  222. it('carries the already-visible unowned set to the new session', async () => {
  223. const { ctx } = await harness(true)
  224. const proxy = api(ctx)
  225. const abort = new AbortController()
  226. const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal)
  227. // One unowned task exists before the new session is created; the subscribe
  228. // frame clears the client mirror, so the baseline has to follow it.
  229. ctx.tasks.start(producer('visible to every caller').spec)
  230. const created = ctx.sessions.create()
  231. const frames = await collect(stream, 2, abort)
  232. const forNew = frames.filter(frame => frame.sessionId === created.id)
  233. expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller')
  234. })
  235. })