control-question.host.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import UserQuestionService from '@deepseek-ai/dsh-user-questions'
  6. import { SessionControlController } from '../src/control.ts'
  7. import type {
  8. SessionControlFrame,
  9. SessionQuestionRequest,
  10. SessionRespondRequest,
  11. } from '../src/types.ts'
  12. type QuestionFrame = Extract<SessionControlFrame, { type: 'question/requested' }>
  13. async function harness(): Promise<{ ctx: Context; control: SessionControlController }> {
  14. const ctx = new Context()
  15. await ctx.plugin(SessionStore)
  16. await ctx.plugin(AgentRegistry)
  17. await ctx.plugin(UserQuestionService)
  18. return { ctx, control: new SessionControlController(ctx) }
  19. }
  20. function agent(ctx: Context): Agent {
  21. const session = ctx.sessions.create()
  22. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  23. const value = { id: session.id, session, inbox, status: 'idle', ctx } as Agent
  24. ctx.agents.register(value)
  25. return value
  26. }
  27. function openControl(control: SessionControlController, abort: AbortController): {
  28. frames: SessionControlFrame[]
  29. waitForQuestion(): Promise<QuestionFrame>
  30. } {
  31. const frames: SessionControlFrame[] = []
  32. let resolveQuestion!: (value: QuestionFrame) => void
  33. const question = new Promise<QuestionFrame>((resolve) => {
  34. resolveQuestion = resolve
  35. })
  36. void (async () => {
  37. for await (const frame of control.control(abort.signal)) {
  38. frames.push(frame)
  39. if (frame.type === 'question/requested') resolveQuestion(frame)
  40. }
  41. })()
  42. return { frames, waitForQuestion: () => question }
  43. }
  44. function answer(
  45. request: SessionQuestionRequest,
  46. selected: string[],
  47. custom?: string,
  48. ): SessionRespondRequest {
  49. const question = request.questions[0]
  50. if (question === undefined) throw new Error('question request is empty')
  51. return {
  52. interactionId: request.interactionId,
  53. result: {
  54. ok: true,
  55. value: {
  56. sessionId: request.sessionId,
  57. answer: {
  58. answers: [{
  59. id: question.id,
  60. selected,
  61. ...custom === undefined ? {} : { custom },
  62. }],
  63. },
  64. },
  65. },
  66. }
  67. }
  68. describe('question response validation', () => {
  69. it('rejects questions without an owning Agent', async () => {
  70. const { ctx } = await harness()
  71. await expect(ctx.userQuestions.ask({
  72. questions: [{ id: 'owner', question: 'Who owns this?', options: [{ label: 'Nobody' }] }],
  73. })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
  74. })
  75. it('accepts selected options with custom text for multi-select questions', async () => {
  76. const { ctx, control } = await harness()
  77. const abort = new AbortController()
  78. const stream = openControl(control, abort)
  79. const asked = ctx.userQuestions.ask({
  80. agent: agent(ctx),
  81. questions: [{
  82. id: 'targets',
  83. question: 'Choose targets and add another',
  84. multiSelect: true,
  85. options: [{ label: 'Code' }, { label: 'Docs' }],
  86. }],
  87. })
  88. const request = await stream.waitForQuestion()
  89. expect(control.respond(answer(request, ['Code', 'Docs'], 'Release notes')))
  90. .toEqual({ accepted: true })
  91. await expect(asked).resolves.toEqual({
  92. answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
  93. })
  94. expect(stream.frames.some(item => item.type === 'question/resolved')).toBe(true)
  95. abort.abort()
  96. })
  97. it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
  98. const { ctx, control } = await harness()
  99. const abort = new AbortController()
  100. const stream = openControl(control, abort)
  101. const asked = ctx.userQuestions.ask({
  102. agent: agent(ctx),
  103. questions: [{
  104. id: 'target',
  105. question: 'Choose one target',
  106. options: [{ label: 'Code' }, { label: 'Docs' }],
  107. }],
  108. })
  109. const request = await stream.waitForQuestion()
  110. expect(control.respond(answer(request, ['Code'], 'Release notes')))
  111. .toEqual({ accepted: false, reason: 'bad-response' })
  112. expect(control.respond(answer(request, [], 'Release notes')))
  113. .toEqual({ accepted: true })
  114. await expect(asked).resolves.toEqual({
  115. answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
  116. })
  117. abort.abort()
  118. })
  119. it('rejects malformed answers without consuming the pending question', async () => {
  120. const { ctx, control } = await harness()
  121. const abort = new AbortController()
  122. const stream = openControl(control, abort)
  123. const asked = ctx.userQuestions.ask({
  124. agent: agent(ctx),
  125. questions: [{
  126. id: 'target',
  127. question: 'Choose targets',
  128. multiSelect: true,
  129. options: [{ label: 'Code' }, { label: 'Docs' }],
  130. }],
  131. })
  132. const request = await stream.waitForQuestion()
  133. const malformed: unknown[] = [
  134. null,
  135. [],
  136. { sessionId: request.sessionId, answer: null },
  137. { sessionId: request.sessionId, answer: { answers: 'invalid' } },
  138. { sessionId: request.sessionId, answer: { answers: [null] } },
  139. { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [1] }] } },
  140. { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [], custom: 1 }] } },
  141. { sessionId: 'other', answer: { answers: [{ id: 'target', selected: [] }] } },
  142. { sessionId: request.sessionId, answer: { answers: [] } },
  143. { sessionId: request.sessionId, answer: { answers: [{ id: 'other', selected: [] }] } },
  144. { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: ['Code', 'Code'] }] } },
  145. { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [], custom: ' ' }] } },
  146. { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: ['Unknown'] }] } },
  147. ]
  148. expect(control.respond({
  149. interactionId: request.interactionId,
  150. result: { ok: false, error: { code: 'internal', message: 'bad', details: {} } },
  151. })).toEqual({ accepted: false, reason: 'bad-response' })
  152. for (const value of malformed) {
  153. expect(control.respond({
  154. interactionId: request.interactionId,
  155. result: { ok: true, value: value as never },
  156. })).toEqual({ accepted: false, reason: 'bad-response' })
  157. }
  158. expect(control.respond(answer(request, ['Code']))).toEqual({ accepted: true })
  159. await expect(asked).resolves.toEqual({ answers: [{ id: 'target', selected: ['Code'] }] })
  160. abort.abort()
  161. })
  162. it('accepts free-form answers when a question has no options', async () => {
  163. const { ctx, control } = await harness()
  164. const abort = new AbortController()
  165. const stream = openControl(control, abort)
  166. const asked = ctx.userQuestions.ask({
  167. agent: agent(ctx),
  168. questions: [{ id: 'detail', question: 'Provide detail' }],
  169. })
  170. const request = await stream.waitForQuestion()
  171. expect(control.respond(answer(request, [], 'details'))).toEqual({ accepted: true })
  172. await expect(asked).resolves.toEqual({
  173. answers: [{ id: 'detail', selected: [], custom: 'details' }],
  174. })
  175. abort.abort()
  176. })
  177. it('handles caller cancellation and races while a response is decoded', async () => {
  178. const { ctx, control } = await harness()
  179. const abort = new AbortController()
  180. const stream = openControl(control, abort)
  181. const cancelledAsk = ctx.userQuestions.ask({
  182. agent: agent(ctx),
  183. questions: [{ id: 'cancel', question: 'Cancel?', options: [{ label: 'No' }] }],
  184. })
  185. const cancelled = await stream.waitForQuestion()
  186. expect(control.respond({
  187. interactionId: cancelled.interactionId,
  188. result: { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } },
  189. })).toEqual({ accepted: true })
  190. await expect(cancelledAsk).rejects.toMatchObject({ code: 'ASK_CANCELLED' })
  191. const raceAbort = new AbortController()
  192. const racedAsk = ctx.userQuestions.ask({
  193. agent: agent(ctx),
  194. signal: raceAbort.signal,
  195. questions: [{ id: 'race', question: 'Race?', options: [{ label: 'Yes' }] }],
  196. })
  197. const raced = await vi.waitFor(() => {
  198. const found = stream.frames.find(frame => frame.type === 'question/requested'
  199. && frame.questions[0]?.id === 'race')
  200. expect(found).toBeDefined()
  201. return found as QuestionFrame
  202. })
  203. const error = {
  204. get code(): string {
  205. raceAbort.abort()
  206. return 'cancelled'
  207. },
  208. message: 'cancelled',
  209. details: {},
  210. }
  211. expect(control.respond({
  212. interactionId: raced.interactionId,
  213. result: { ok: false, error },
  214. })).toEqual({ accepted: false, reason: 'not-pending' })
  215. await expect(racedAsk).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  216. const answerAbort = new AbortController()
  217. const answerRace = ctx.userQuestions.ask({
  218. agent: agent(ctx),
  219. signal: answerAbort.signal,
  220. questions: [{ id: 'answer-race', question: 'Race?', options: [{ label: 'Yes' }] }],
  221. })
  222. const answerRequest = await vi.waitFor(() => {
  223. const found = stream.frames.find(frame => frame.type === 'question/requested'
  224. && frame.questions[0]?.id === 'answer-race')
  225. expect(found).toBeDefined()
  226. return found as QuestionFrame
  227. })
  228. const result = {
  229. ok: true as const,
  230. get value() {
  231. answerAbort.abort()
  232. return {
  233. sessionId: answerRequest.sessionId,
  234. answer: { answers: [{ id: 'answer-race', selected: ['Yes'] }] },
  235. }
  236. },
  237. }
  238. expect(control.respond({ interactionId: answerRequest.interactionId, result }))
  239. .toEqual({ accepted: false, reason: 'not-pending' })
  240. await expect(answerRace).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  241. abort.abort()
  242. })
  243. it('contains aborts before and immediately after provider registration', async () => {
  244. const { ctx } = await harness()
  245. const question = { id: 'race', question: 'Race?', options: [{ label: 'Yes' }] }
  246. const signalAfter = (abortedAt: number, notifyOnAdd = false): AbortSignal => {
  247. let reads = 0
  248. return {
  249. get aborted() { return ++reads >= abortedAt },
  250. addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
  251. if (!notifyOnAdd) return
  252. if (typeof listener === 'function') listener(new Event('abort'))
  253. else listener.handleEvent(new Event('abort'))
  254. },
  255. removeEventListener: () => {},
  256. } as unknown as AbortSignal
  257. }
  258. await expect(ctx.userQuestions.ask({
  259. agent: agent(ctx),
  260. signal: signalAfter(2),
  261. questions: [question],
  262. })).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  263. await expect(ctx.userQuestions.ask({
  264. agent: agent(ctx),
  265. signal: signalAfter(3, true),
  266. questions: [question],
  267. })).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  268. })
  269. it('replays pending questions in baselines and rejects them on controller disposal', async () => {
  270. const ctx = new Context()
  271. await ctx.plugin(SessionStore)
  272. await ctx.plugin(AgentRegistry)
  273. await ctx.plugin(UserQuestionService)
  274. let control!: SessionControlController
  275. const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
  276. control = new SessionControlController(fiberCtx)
  277. }, { inject: ['sessions', 'agents', 'userQuestions'] }))
  278. await fiber.await()
  279. const firstAbort = new AbortController()
  280. const first = openControl(control, firstAbort)
  281. const asked = ctx.userQuestions.ask({
  282. agent: agent(ctx),
  283. questions: [{ id: 'pending', question: 'Pending?', options: [{ label: 'Yes' }] }],
  284. })
  285. const requested = await first.waitForQuestion()
  286. const secondAbort = new AbortController()
  287. const second = openControl(control, secondAbort)
  288. await vi.waitFor(() => { expect(second.frames[0]?.type).toBe('baseline') })
  289. const baseline = second.frames[0]
  290. if (baseline?.type !== 'baseline') throw new Error('missing baseline')
  291. expect(baseline.value.questions).toContainEqual(expect.objectContaining({
  292. interactionId: requested.interactionId,
  293. }))
  294. await fiber.dispose()
  295. await expect(asked).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  296. firstAbort.abort()
  297. secondAbort.abort()
  298. })
  299. it('cancels only questions owned by a disposed Session', async () => {
  300. const { ctx, control } = await harness()
  301. const abort = new AbortController()
  302. const stream = openControl(control, abort)
  303. const first = agent(ctx)
  304. const second = agent(ctx)
  305. const firstAsk = ctx.userQuestions.ask({
  306. agent: first,
  307. questions: [{ id: 'first', question: 'First?', options: [{ label: 'Yes' }] }],
  308. })
  309. const secondAsk = ctx.userQuestions.ask({
  310. agent: second,
  311. questions: [{ id: 'second', question: 'Second?', options: [{ label: 'Yes' }] }],
  312. })
  313. await vi.waitFor(() => {
  314. expect(stream.frames.filter(frame => frame.type === 'question/requested')).toHaveLength(2)
  315. })
  316. const requests = stream.frames.filter(
  317. (frame): frame is QuestionFrame => frame.type === 'question/requested',
  318. )
  319. ctx.emit('session/disposed', first.session)
  320. await expect(firstAsk).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  321. const remaining = requests.find(request => request.sessionId === second.session.id)
  322. if (remaining === undefined) throw new Error('missing second question')
  323. expect(control.respond(answer(remaining, ['Yes']))).toEqual({ accepted: true })
  324. await expect(secondAsk).resolves.toEqual({
  325. answers: [{ id: 'second', selected: ['Yes'] }],
  326. })
  327. abort.abort()
  328. })
  329. })