control-jobs.host.spec.ts 8.1 KB

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