1
0

command-feedback.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  5. import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
  6. import CommandRuntime from '@deepseek-ai/dsh-commands'
  7. import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
  9. const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
  10. const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
  11. return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) }
  12. })
  13. vi.mock('@deepseek-ai/dsh-anonymous-user-id', () => ({
  14. getOrCreateAnonymousUserId,
  15. }))
  16. beforeEach(() => getOrCreateAnonymousUserId.mockClear())
  17. interface Harness {
  18. readonly ctx: Context
  19. readonly agent: Agent
  20. readonly session: Session
  21. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  22. }
  23. /** Build a live idle agent over a store-owned session, as an app's spine does. */
  24. function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
  25. const session = ctx.sessions.create(SessionId(id))
  26. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  27. let status: AgentStatus = 'idle'
  28. const agent: Agent = {
  29. id: session.id,
  30. options: {},
  31. session,
  32. inbox,
  33. ctx: new Context(),
  34. get status() { return status },
  35. send: () => {},
  36. followup: () => {},
  37. steer: () => {},
  38. inject: () => {},
  39. cancel() { status = 'idle' },
  40. runMaintenance: task => task(new AbortController().signal),
  41. whenIdle() { return Promise.resolve() },
  42. }
  43. return { agent, session }
  44. }
  45. async function harness(): Promise<Harness> {
  46. const ctx = new Context()
  47. await ctx.plugin(CommandRuntime)
  48. await ctx.plugin(AgentRegistry)
  49. await ctx.plugin(SessionStore)
  50. const plugin = await ctx.plugin(commandFeedback)
  51. const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
  52. ctx.agents.register(agent)
  53. return { ctx, agent, session, plugin }
  54. }
  55. /** Execute `/feedback` through the same registry boundary as a UI adapter. */
  56. async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
  57. const settled = await test.ctx.commands.execute(
  58. test.agent,
  59. `/feedback${suffix}`,
  60. [],
  61. new AbortController().signal,
  62. )
  63. if (settled === undefined) throw new Error('feedback command was not registered')
  64. return settled.result
  65. }
  66. /** Authoritative feedback payloads in log order. */
  67. function feedbackTexts(session: Session): string[] {
  68. return session.snapshotEvents()
  69. .filter(event => event.type === 'feedback/record')
  70. .map(event => event.data.text)
  71. }
  72. describe('@deepseek-ai/dsh-command-feedback registration', () => {
  73. it('registers one global command with Loader-safe exports and disposes it', async () => {
  74. const test = await harness()
  75. expect(commandFeedback.name).toBe('command-feedback')
  76. expect(commandFeedback.inject).toEqual(['commands'])
  77. expect('default' in commandFeedback).toBe(false)
  78. const loader = Object.create(Loader.prototype) as Loader
  79. expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
  80. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  81. name: 'feedback',
  82. description: 'record feedback about this session',
  83. input: { hint: '<text>' },
  84. })
  85. expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
  86. await test.plugin.dispose()
  87. expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
  88. })
  89. })
  90. describe('/feedback human command', () => {
  91. it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
  92. const test = await harness()
  93. await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
  94. kind: 'success',
  95. text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.`,
  96. })
  97. expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
  98. const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run')
  99. expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false)
  100. expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1)
  101. })
  102. it('exports a command-independent feedback producer', async () => {
  103. const test = await harness()
  104. commandFeedback.recordFeedback(test.session, ' recorded outside a command ')
  105. expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record'])
  106. expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
  107. expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') })
  108. .toThrow('feedback text must not be empty')
  109. expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
  110. })
  111. it('keeps command bookkeeping around the authoritative feedback event', async () => {
  112. const test = await harness()
  113. await run(test, ' nothing else happens')
  114. expect(test.session.snapshotEvents().map(event => event.type)).toEqual([
  115. 'command/run', 'feedback/record', 'command/done',
  116. ])
  117. })
  118. it('normalizes surrounding whitespace without parsing command-like content', async () => {
  119. const test = await harness()
  120. await run(test, ' /plan felt SLOW\n\ttwice today ')
  121. expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today'])
  122. })
  123. it('records each entry separately without replacing earlier ones', async () => {
  124. const test = await harness()
  125. await run(test, ' first')
  126. await run(test, ' second')
  127. expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
  128. })
  129. it('records concurrent submissions in dispatch order', async () => {
  130. const test = await harness()
  131. const signal = new AbortController().signal
  132. // Command adapters may dispatch concurrent requests without awaiting one another.
  133. const settled = await Promise.all([
  134. test.ctx.commands.execute(test.agent, '/feedback first', [], signal),
  135. test.ctx.commands.execute(test.agent, '/feedback second', [], signal),
  136. ])
  137. expect(settled.map(item => item?.result)).toEqual([
  138. { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
  139. { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
  140. ])
  141. expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
  142. })
  143. it('keeps every recorded event out of model context and derived history', async () => {
  144. const test = await harness()
  145. await run(test, ' invisible to the model')
  146. for (const event of test.session.snapshotEvents()) {
  147. expect('surfaceOp' in event).toBe(false)
  148. expect(test.session.deriveEventMessage(event)).toBeNull()
  149. }
  150. expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([])
  151. expect(test.session.surface.nodes).toEqual([])
  152. expect(test.session.deriveMessages()).toEqual([])
  153. })
  154. it('rejects empty and whitespace-only input as a failed command record', async () => {
  155. const test = await harness()
  156. const expected = {
  157. kind: 'error',
  158. text: 'Feedback text is required. Usage: /feedback <text>',
  159. }
  160. await expect(run(test)).resolves.toEqual(expected)
  161. await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
  162. expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
  163. expect(feedbackTexts(test.session)).toEqual([])
  164. const done = test.session.snapshotEvents().filter(event => event.type === 'command/done')
  165. expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])
  166. for (const event of test.session.snapshotEvents()) {
  167. if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false)
  168. }
  169. })
  170. it('records nothing when dispatch rejects an already-cancelled request', async () => {
  171. const test = await harness()
  172. const controller = new AbortController()
  173. controller.abort(new Error('user cancelled the command'))
  174. await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal))
  175. .rejects.toThrow('user cancelled the command')
  176. expect(test.session.snapshotEvents()).toEqual([])
  177. })
  178. })