| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- import { describe, expect, it, vi } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
- import SessionStore from '@deepseek-ai/dsh-session'
- import UserQuestionService from '@deepseek-ai/dsh-user-questions'
- import { SessionControlController } from '../src/control.ts'
- import type {
- SessionControlFrame,
- SessionQuestionRequest,
- SessionRespondRequest,
- } from '../src/types.ts'
- type QuestionFrame = Extract<SessionControlFrame, { type: 'question/requested' }>
- async function harness(): Promise<{ ctx: Context; control: SessionControlController }> {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(UserQuestionService)
- return { ctx, control: new SessionControlController(ctx) }
- }
- function agent(ctx: Context): Agent {
- const session = ctx.sessions.create()
- const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
- const value = { id: session.id, session, inbox, status: 'idle', ctx } as Agent
- ctx.agents.register(value)
- return value
- }
- function openControl(control: SessionControlController, abort: AbortController): {
- frames: SessionControlFrame[]
- waitForQuestion(): Promise<QuestionFrame>
- } {
- const frames: SessionControlFrame[] = []
- let resolveQuestion!: (value: QuestionFrame) => void
- const question = new Promise<QuestionFrame>((resolve) => {
- resolveQuestion = resolve
- })
- void (async () => {
- for await (const frame of control.control(abort.signal)) {
- frames.push(frame)
- if (frame.type === 'question/requested') resolveQuestion(frame)
- }
- })()
- return { frames, waitForQuestion: () => question }
- }
- function answer(
- request: SessionQuestionRequest,
- selected: string[],
- custom?: string,
- ): SessionRespondRequest {
- const question = request.questions[0]
- if (question === undefined) throw new Error('question request is empty')
- return {
- interactionId: request.interactionId,
- result: {
- ok: true,
- value: {
- sessionId: request.sessionId,
- answer: {
- answers: [{
- id: question.id,
- selected,
- ...custom === undefined ? {} : { custom },
- }],
- },
- },
- },
- }
- }
- describe('question response validation', () => {
- it('rejects questions without an owning Agent', async () => {
- const { ctx } = await harness()
- await expect(ctx.userQuestions.ask({
- questions: [{ id: 'owner', question: 'Who owns this?', options: [{ label: 'Nobody' }] }],
- })).rejects.toMatchObject({ code: 'ASK_MISSING_AGENT' })
- })
- it('accepts selected options with custom text for multi-select questions', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const asked = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{
- id: 'targets',
- question: 'Choose targets and add another',
- multiSelect: true,
- options: [{ label: 'Code' }, { label: 'Docs' }],
- }],
- })
- const request = await stream.waitForQuestion()
- expect(control.respond(answer(request, ['Code', 'Docs'], 'Release notes')))
- .toEqual({ accepted: true })
- await expect(asked).resolves.toEqual({
- answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
- })
- expect(stream.frames.some(item => item.type === 'question/resolved')).toBe(true)
- abort.abort()
- })
- it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const asked = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{
- id: 'target',
- question: 'Choose one target',
- options: [{ label: 'Code' }, { label: 'Docs' }],
- }],
- })
- const request = await stream.waitForQuestion()
- expect(control.respond(answer(request, ['Code'], 'Release notes')))
- .toEqual({ accepted: false, reason: 'bad-response' })
- expect(control.respond(answer(request, [], 'Release notes')))
- .toEqual({ accepted: true })
- await expect(asked).resolves.toEqual({
- answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
- })
- abort.abort()
- })
- it('rejects malformed answers without consuming the pending question', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const asked = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{
- id: 'target',
- question: 'Choose targets',
- multiSelect: true,
- options: [{ label: 'Code' }, { label: 'Docs' }],
- }],
- })
- const request = await stream.waitForQuestion()
- const malformed: unknown[] = [
- null,
- [],
- { sessionId: request.sessionId, answer: null },
- { sessionId: request.sessionId, answer: { answers: 'invalid' } },
- { sessionId: request.sessionId, answer: { answers: [null] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [1] }] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [], custom: 1 }] } },
- { sessionId: 'other', answer: { answers: [{ id: 'target', selected: [] }] } },
- { sessionId: request.sessionId, answer: { answers: [] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'other', selected: [] }] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: ['Code', 'Code'] }] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: [], custom: ' ' }] } },
- { sessionId: request.sessionId, answer: { answers: [{ id: 'target', selected: ['Unknown'] }] } },
- ]
- expect(control.respond({
- interactionId: request.interactionId,
- result: { ok: false, error: { code: 'internal', message: 'bad', details: {} } },
- })).toEqual({ accepted: false, reason: 'bad-response' })
- for (const value of malformed) {
- expect(control.respond({
- interactionId: request.interactionId,
- result: { ok: true, value: value as never },
- })).toEqual({ accepted: false, reason: 'bad-response' })
- }
- expect(control.respond(answer(request, ['Code']))).toEqual({ accepted: true })
- await expect(asked).resolves.toEqual({ answers: [{ id: 'target', selected: ['Code'] }] })
- abort.abort()
- })
- it('accepts free-form answers when a question has no options', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const asked = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{ id: 'detail', question: 'Provide detail' }],
- })
- const request = await stream.waitForQuestion()
- expect(control.respond(answer(request, [], 'details'))).toEqual({ accepted: true })
- await expect(asked).resolves.toEqual({
- answers: [{ id: 'detail', selected: [], custom: 'details' }],
- })
- abort.abort()
- })
- it('handles caller cancellation and races while a response is decoded', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const cancelledAsk = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{ id: 'cancel', question: 'Cancel?', options: [{ label: 'No' }] }],
- })
- const cancelled = await stream.waitForQuestion()
- expect(control.respond({
- interactionId: cancelled.interactionId,
- result: { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } },
- })).toEqual({ accepted: true })
- await expect(cancelledAsk).rejects.toMatchObject({ code: 'ASK_CANCELLED' })
- const raceAbort = new AbortController()
- const racedAsk = ctx.userQuestions.ask({
- agent: agent(ctx),
- signal: raceAbort.signal,
- questions: [{ id: 'race', question: 'Race?', options: [{ label: 'Yes' }] }],
- })
- const raced = await vi.waitFor(() => {
- const found = stream.frames.find(frame => frame.type === 'question/requested'
- && frame.questions[0]?.id === 'race')
- expect(found).toBeDefined()
- return found as QuestionFrame
- })
- const error = {
- get code(): string {
- raceAbort.abort()
- return 'cancelled'
- },
- message: 'cancelled',
- details: {},
- }
- expect(control.respond({
- interactionId: raced.interactionId,
- result: { ok: false, error },
- })).toEqual({ accepted: false, reason: 'not-pending' })
- await expect(racedAsk).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- const answerAbort = new AbortController()
- const answerRace = ctx.userQuestions.ask({
- agent: agent(ctx),
- signal: answerAbort.signal,
- questions: [{ id: 'answer-race', question: 'Race?', options: [{ label: 'Yes' }] }],
- })
- const answerRequest = await vi.waitFor(() => {
- const found = stream.frames.find(frame => frame.type === 'question/requested'
- && frame.questions[0]?.id === 'answer-race')
- expect(found).toBeDefined()
- return found as QuestionFrame
- })
- const result = {
- ok: true as const,
- get value() {
- answerAbort.abort()
- return {
- sessionId: answerRequest.sessionId,
- answer: { answers: [{ id: 'answer-race', selected: ['Yes'] }] },
- }
- },
- }
- expect(control.respond({ interactionId: answerRequest.interactionId, result }))
- .toEqual({ accepted: false, reason: 'not-pending' })
- await expect(answerRace).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- abort.abort()
- })
- it('contains aborts before and immediately after provider registration', async () => {
- const { ctx } = await harness()
- const question = { id: 'race', question: 'Race?', options: [{ label: 'Yes' }] }
- const signalAfter = (abortedAt: number, notifyOnAdd = false): AbortSignal => {
- let reads = 0
- return {
- get aborted() { return ++reads >= abortedAt },
- addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
- if (!notifyOnAdd) return
- if (typeof listener === 'function') listener(new Event('abort'))
- else listener.handleEvent(new Event('abort'))
- },
- removeEventListener: () => {},
- } as unknown as AbortSignal
- }
- await expect(ctx.userQuestions.ask({
- agent: agent(ctx),
- signal: signalAfter(2),
- questions: [question],
- })).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- await expect(ctx.userQuestions.ask({
- agent: agent(ctx),
- signal: signalAfter(3, true),
- questions: [question],
- })).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- })
- it('replays pending questions in baselines and rejects them on controller disposal', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(UserQuestionService)
- let control!: SessionControlController
- const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
- control = new SessionControlController(fiberCtx)
- }, { inject: ['sessions', 'agents', 'userQuestions'] }))
- await fiber.await()
- const firstAbort = new AbortController()
- const first = openControl(control, firstAbort)
- const asked = ctx.userQuestions.ask({
- agent: agent(ctx),
- questions: [{ id: 'pending', question: 'Pending?', options: [{ label: 'Yes' }] }],
- })
- const requested = await first.waitForQuestion()
- const secondAbort = new AbortController()
- const second = openControl(control, secondAbort)
- await vi.waitFor(() => { expect(second.frames[0]?.type).toBe('baseline') })
- const baseline = second.frames[0]
- if (baseline?.type !== 'baseline') throw new Error('missing baseline')
- expect(baseline.value.questions).toContainEqual(expect.objectContaining({
- interactionId: requested.interactionId,
- }))
- await fiber.dispose()
- await expect(asked).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- firstAbort.abort()
- secondAbort.abort()
- })
- it('cancels only questions owned by a disposed Session', async () => {
- const { ctx, control } = await harness()
- const abort = new AbortController()
- const stream = openControl(control, abort)
- const first = agent(ctx)
- const second = agent(ctx)
- const firstAsk = ctx.userQuestions.ask({
- agent: first,
- questions: [{ id: 'first', question: 'First?', options: [{ label: 'Yes' }] }],
- })
- const secondAsk = ctx.userQuestions.ask({
- agent: second,
- questions: [{ id: 'second', question: 'Second?', options: [{ label: 'Yes' }] }],
- })
- await vi.waitFor(() => {
- expect(stream.frames.filter(frame => frame.type === 'question/requested')).toHaveLength(2)
- })
- const requests = stream.frames.filter(
- (frame): frame is QuestionFrame => frame.type === 'question/requested',
- )
- ctx.emit('session/disposed', first.session)
- await expect(firstAsk).rejects.toMatchObject({ code: 'ASK_ABORTED' })
- const remaining = requests.find(request => request.sessionId === second.session.id)
- if (remaining === undefined) throw new Error('missing second question')
- expect(control.respond(answer(remaining, ['Yes']))).toEqual({ accepted: true })
- await expect(secondAsk).resolves.toEqual({
- answers: [{ id: 'second', selected: ['Yes'] }],
- })
- abort.abort()
- })
- })
|