api-proxy-question.spec.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  6. import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
  7. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  8. import { createApiProxy } from '../src/api-proxy.ts'
  9. async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
  10. const ctx = new Context()
  11. await ctx.plugin(SessionStore)
  12. await ctx.plugin(AgentRegistry)
  13. await ctx.plugin(UserInteractionService)
  14. return {
  15. ctx,
  16. api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
  17. }
  18. }
  19. function agent(ctx: Context): Agent {
  20. const session = ctx.sessions.create()
  21. const value = { id: session.id, session, status: 'idle', ctx } as Agent
  22. ctx.agents.register(value)
  23. return value
  24. }
  25. function openMux(api: ApiProxy, abort: AbortController): {
  26. envelopes: RpcRequest<MuxFrame>[]
  27. waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
  28. } {
  29. const envelopes: RpcRequest<MuxFrame>[] = []
  30. let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
  31. const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((resolve) => {
  32. resolveQuestion = resolve
  33. })
  34. void (async () => {
  35. for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) {
  36. envelopes.push(envelope)
  37. if (envelope.payload.type === 'question/requested') {
  38. resolveQuestion(envelope as RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>)
  39. }
  40. }
  41. })()
  42. return { envelopes, waitForQuestion: () => question }
  43. }
  44. function answer(
  45. envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
  46. selected: string[],
  47. custom?: string,
  48. ): Parameters<ApiProxy['respond']>[0] {
  49. return {
  50. type: 'client-response',
  51. rpcId: envelope.rpcId,
  52. result: {
  53. ok: true,
  54. value: {
  55. sessionId: envelope.payload.sessionId,
  56. answer: {
  57. answers: [{
  58. id: envelope.payload.questions[0]?.id,
  59. selected,
  60. ...custom === undefined ? {} : { custom },
  61. }],
  62. },
  63. },
  64. },
  65. }
  66. }
  67. describe('question response validation', () => {
  68. it('accepts selected options with custom text for multi-select questions', async () => {
  69. const { ctx, api } = await harness()
  70. const abort = new AbortController()
  71. const mux = openMux(api, abort)
  72. const asked = ctx.userInteraction.ask({
  73. agent: agent(ctx),
  74. questions: [{
  75. id: 'targets',
  76. question: 'Choose targets and add another',
  77. multiSelect: true,
  78. options: [{ label: 'Code' }, { label: 'Docs' }],
  79. }],
  80. })
  81. const envelope = await mux.waitForQuestion()
  82. expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes')))
  83. .toEqual({ accepted: true })
  84. await expect(asked).resolves.toEqual({
  85. answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
  86. })
  87. expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true)
  88. abort.abort()
  89. })
  90. it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
  91. const { ctx, api } = await harness()
  92. const abort = new AbortController()
  93. const mux = openMux(api, abort)
  94. const asked = ctx.userInteraction.ask({
  95. agent: agent(ctx),
  96. questions: [{
  97. id: 'target',
  98. question: 'Choose one target',
  99. options: [{ label: 'Code' }, { label: 'Docs' }],
  100. }],
  101. })
  102. const envelope = await mux.waitForQuestion()
  103. expect(await api.respond(answer(envelope, ['Code'], 'Release notes')))
  104. .toEqual({ accepted: false, reason: 'bad-response' })
  105. expect(await api.respond(answer(envelope, [], 'Release notes')))
  106. .toEqual({ accepted: true })
  107. await expect(asked).resolves.toEqual({
  108. answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
  109. })
  110. abort.abort()
  111. })
  112. })