control-jobs.host.spec.ts 8.5 KB

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