control-jobs.host.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
  5. import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
  6. import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
  7. import type { Session } from '@deepseek-ai/dsh-session'
  8. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  9. import { describe, expect, it } from 'vitest'
  10. import { SessionControlController } from '../src/control.ts'
  11. import type { SessionControlFrame } from '../src/types.ts'
  12. type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
  13. type JobFrame = Extract<SessionControlFrame, { type: 'jobs' }>
  14. function producer(label = 'sleep 60') {
  15. let settle!: (outcome: JobOutcome) => void
  16. const reads = { count: 0 }
  17. const spec = {
  18. kind: 'bash' as const,
  19. label,
  20. run: () => ({
  21. cancel: () => {},
  22. done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
  23. readOutput: () => { reads.count += 1; return 'stolen output' },
  24. }),
  25. }
  26. return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
  27. }
  28. async function harness(withJobs: boolean): Promise<{
  29. ctx: Context
  30. session: Session
  31. agent: Agent
  32. control: SessionControlController
  33. }> {
  34. const ctx = new Context()
  35. await ctx.plugin(SessionStore)
  36. await ctx.plugin(AgentRegistry)
  37. await ctx.plugin(SessionProjectionRegistry)
  38. if (withJobs) {
  39. await ctx.plugin(LocalJobRegistry)
  40. ctx.jobs.attachController('session-controller-test')
  41. }
  42. const session = ctx.sessions.create()
  43. const agent = {
  44. id: session.id,
  45. session,
  46. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  47. status: 'idle',
  48. ctx,
  49. } as Agent
  50. ctx.agents.register(agent)
  51. const control = new SessionControlController(ctx)
  52. await new Promise(resolve => setTimeout(resolve, 0))
  53. return { ctx, session, agent, control }
  54. }
  55. async function baseline(control: SessionControlController): Promise<BaselineFrame> {
  56. const abort = new AbortController()
  57. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  58. const first = await iterator.next()
  59. abort.abort()
  60. await iterator.next()
  61. if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline')
  62. return first.value
  63. }
  64. async function collectJobs(
  65. iterable: AsyncIterable<SessionControlFrame>,
  66. count: number,
  67. abort: AbortController,
  68. ): Promise<JobFrame[]> {
  69. const jobs: JobFrame[] = []
  70. for await (const frame of iterable) {
  71. if (frame.type !== 'jobs') continue
  72. jobs.push(frame)
  73. if (jobs.length >= count) abort.abort()
  74. }
  75. return jobs
  76. }
  77. describe('Session control jobs baseline', () => {
  78. it('represents an attached session with no jobs as an empty set', async () => {
  79. const { session, control } = await harness(true)
  80. const frame = await baseline(control)
  81. expect(frame.value.jobs[session.id]).toEqual([])
  82. })
  83. it('carries the visible set when the stream opens', async () => {
  84. const { ctx, session, agent, control } = await harness(true)
  85. ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
  86. const frame = await baseline(control)
  87. const jobs = frame.value.jobs[session.id]
  88. expect(jobs).toHaveLength(1)
  89. const [job] = jobs ?? []
  90. expect(job?.startedAt).toBeTypeOf('number')
  91. expect({ ...job, startedAt: 0 }).toEqual({
  92. id: 'bash-1',
  93. kind: 'bash',
  94. label: 'pnpm run build',
  95. status: 'running',
  96. startedAt: 0,
  97. })
  98. })
  99. })
  100. describe('Session control jobs updates', () => {
  101. it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
  102. const { ctx, control } = await harness(true)
  103. const abort = new AbortController()
  104. const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
  105. await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
  106. const task = producer('already running')
  107. const id = ctx.jobs.start(task.spec)
  108. await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
  109. const created = ctx.sessions.create(SessionId('late-session'))
  110. await expect(iterator.next()).resolves.toMatchObject({
  111. value: {
  112. type: 'jobs',
  113. sessionId: created.id,
  114. jobs: [expect.objectContaining({ id, label: 'already running' })],
  115. },
  116. })
  117. task.settle({ status: 'completed' })
  118. abort.abort()
  119. await iterator.return?.()
  120. })
  121. it('pushes the owner whole set on registration, stopping, and settlement', async () => {
  122. const { ctx, session, agent, control } = await harness(true)
  123. const abort = new AbortController()
  124. const collected = collectJobs(control.control(abort.signal), 3, abort)
  125. const task = producer()
  126. const id = ctx.jobs.start({ ...task.spec, owner: agent })
  127. ctx.jobs.kill(id, agent, 'test')
  128. task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
  129. const frames = await collected
  130. expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
  131. expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
  132. expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
  133. expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
  134. })
  135. it('drops internal registry fields from the browser view', async () => {
  136. const { ctx, agent, control } = await harness(true)
  137. const abort = new AbortController()
  138. const collected = collectJobs(control.control(abort.signal), 1, abort)
  139. ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
  140. const [frame] = await collected
  141. expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([
  142. 'id',
  143. 'kind',
  144. 'label',
  145. 'startedAt',
  146. 'status',
  147. ])
  148. })
  149. it('fans an unowned change out to every attached session', async () => {
  150. const { ctx, control } = await harness(true)
  151. const second = ctx.sessions.create()
  152. const abort = new AbortController()
  153. const collected = collectJobs(control.control(abort.signal), 2, abort)
  154. ctx.jobs.start(producer('open to every caller').spec)
  155. const frames = await collected
  156. expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
  157. expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
  158. for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
  159. })
  160. it('does not resume persisted sessions while projecting an unowned change', async () => {
  161. const { ctx, control } = await harness(true)
  162. const coldId = SessionId('session-cold-tasks')
  163. let loaded = false
  164. ctx.provide('sessionPersistence', {
  165. list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, cwd: '/tmp' }],
  166. locate: () => undefined,
  167. load: () => { loaded = true; throw new Error('job projection must not load a cold log') },
  168. } as never)
  169. const abort = new AbortController()
  170. const collected = collectJobs(control.control(abort.signal), 1, abort)
  171. ctx.jobs.start(producer().spec)
  172. await collected
  173. expect(loaded).toBe(false)
  174. expect(ctx.agents.get(coldId)).toBeUndefined()
  175. })
  176. it('reports empty sets when no jobs registry is composed', async () => {
  177. const { session, control } = await harness(false)
  178. const frame = await baseline(control)
  179. expect(frame.value.jobs[session.id]).toEqual([])
  180. })
  181. it('never consumes model output while projecting a lifecycle', async () => {
  182. const { ctx, agent, control } = await harness(true)
  183. const abort = new AbortController()
  184. const collected = collectJobs(control.control(abort.signal), 3, abort)
  185. const task = producer()
  186. const id = ctx.jobs.start({ ...task.spec, owner: agent })
  187. ctx.jobs.kill(id, agent, 'test')
  188. task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
  189. await collected
  190. expect(task.reads.count).toBe(0)
  191. })
  192. it('never consumes model output while producing a baseline', async () => {
  193. const { ctx, agent, control } = await harness(true)
  194. const task = producer()
  195. ctx.jobs.start({ ...task.spec, owner: agent })
  196. const frame = await baseline(control)
  197. expect(frame.value.jobs[agent.id]).toHaveLength(1)
  198. expect(task.reads.count).toBe(0)
  199. })
  200. })