command-feedback.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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 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. import type { FeedbackRecord } from '@deepseek-ai/dsh-command-feedback/types'
  10. import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  11. import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => {
  13. const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd'
  14. return { USER_ID, getOrCreateAnonymousUserId: vi.fn(() => USER_ID) }
  15. })
  16. vi.mock('@deepseek-ai/dsh-anonymous-user-id', () => ({
  17. getOrCreateAnonymousUserId,
  18. }))
  19. beforeEach(() => getOrCreateAnonymousUserId.mockClear())
  20. interface Harness {
  21. readonly ctx: Context
  22. readonly agent: Agent
  23. readonly session: Session
  24. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  25. }
  26. /** Build a live idle agent over a store-owned session, as an app's spine does. */
  27. function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
  28. const session = ctx.sessions.create(SessionId(id))
  29. let status: AgentStatus = 'idle'
  30. const agent: Agent = {
  31. id: session.id,
  32. options: {},
  33. session,
  34. inbox: unsupportedInbox(),
  35. ctx: new Context(),
  36. get status() { return status },
  37. send: () => {},
  38. followup: () => {},
  39. steer: () => {},
  40. inject: () => {},
  41. cancel() { status = 'idle' },
  42. runMaintenance: task => task(new AbortController().signal),
  43. whenIdle() { return Promise.resolve() },
  44. }
  45. return { agent, session }
  46. }
  47. async function harness(): Promise<Harness> {
  48. const ctx = new Context()
  49. await ctx.plugin(CommandRuntime)
  50. await ctx.plugin(AgentRegistry)
  51. await ctx.plugin(SessionStore)
  52. const plugin = await ctx.plugin(commandFeedback)
  53. const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
  54. ctx.agents.register(agent)
  55. return { ctx, agent, session, plugin }
  56. }
  57. /** Execute `/feedback` through the same registry boundary as a UI adapter. */
  58. async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
  59. const settled = await test.ctx.commands.execute(
  60. test.agent,
  61. `/feedback${suffix}`,
  62. [],
  63. new AbortController().signal,
  64. )
  65. if (settled === undefined) throw new Error('feedback command was not registered')
  66. return settled.result
  67. }
  68. /** Authoritative feedback payloads in log order. */
  69. function feedbackRecords(session: Session): FeedbackRecord[] {
  70. return session.snapshotEvents()
  71. .filter(event => event.type === 'feedback/record')
  72. .map(event => event.data)
  73. }
  74. /** The text of each authoritative feedback payload in log order. */
  75. function feedbackTexts(session: Session): (string | undefined)[] {
  76. return feedbackRecords(session).map(record => record.text)
  77. }
  78. describe('@deepseek-ai/dsh-command-feedback registration', () => {
  79. it('registers one global command with Loader-safe exports and disposes it', async () => {
  80. const test = await harness()
  81. expect(commandFeedback.name).toBe('command-feedback')
  82. expect(commandFeedback.inject).toEqual(['commands'])
  83. expect('default' in commandFeedback).toBe(false)
  84. const loader = Object.create(Loader.prototype) as Loader
  85. expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
  86. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  87. definitionId: '@deepseek-ai/dsh-command-feedback',
  88. name: 'feedback',
  89. description: 'Record feedback about this session',
  90. input: { hint: '<text>' },
  91. })
  92. expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
  93. await test.plugin.dispose()
  94. expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
  95. })
  96. })
  97. describe('sessionFeedback Host Remote', () => {
  98. it('publishes the exact Gateway namespace and Remote method name', async () => {
  99. const test = await harness()
  100. const binding = test.ctx.sessionFeedback.typertRemote
  101. expect(binding.serviceKey).toBe('sessionFeedback')
  102. expect(binding.namespace).toBe('sessionFeedback')
  103. expect(remoteMethods(test.ctx.sessionFeedback)).toEqual([
  104. { method: 'record', invocation: { kind: 'direct' } },
  105. ])
  106. })
  107. it('records a remark on the live Session without command bookkeeping', async () => {
  108. const test = await harness()
  109. await expect(test.ctx.sessionFeedback.record({
  110. sessionId: test.session.id, text: ' the diff view is unreadable ', category: 'product-interaction',
  111. })).resolves.toEqual({ ok: true, value: { recorded: true } })
  112. await expect(test.ctx.sessionFeedback.record({ sessionId: test.session.id }))
  113. .resolves.toEqual({ ok: true, value: { recorded: true } })
  114. expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record', 'feedback/record'])
  115. expect(feedbackRecords(test.session)).toEqual([
  116. { text: 'the diff view is unreadable', category: 'product-interaction' },
  117. {},
  118. ])
  119. expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
  120. })
  121. it('reports session-not-found for a Session no live owner carries', async () => {
  122. const test = await harness()
  123. const missing = SessionId('no-such-session')
  124. await expect(test.ctx.sessionFeedback.record({ sessionId: missing, text: 'lost' }))
  125. .resolves.toEqual({ ok: false, error: { code: 'session-not-found', sessionId: missing } })
  126. expect(test.session.snapshotEvents()).toEqual([])
  127. })
  128. it('is mounted and unmounted with the plugin', async () => {
  129. const test = await harness()
  130. expect(test.ctx.get('sessionFeedback')).toBeDefined()
  131. await test.plugin.dispose()
  132. expect(test.ctx.get('sessionFeedback')).toBeUndefined()
  133. })
  134. })
  135. describe('/feedback human command', () => {
  136. it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
  137. const test = await harness()
  138. await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
  139. kind: 'success',
  140. text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.`,
  141. })
  142. expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
  143. const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run')
  144. expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false)
  145. expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1)
  146. })
  147. it('exports a command-independent feedback producer', async () => {
  148. const test = await harness()
  149. commandFeedback.recordFeedback(test.session, { text: ' recorded outside a command ' })
  150. commandFeedback.recordFeedback(test.session, { text: ' \n\t ', category: 'service-stability' })
  151. commandFeedback.recordFeedback(test.session, {})
  152. expect(test.session.snapshotEvents().map(event => event.type))
  153. .toEqual(['feedback/record', 'feedback/record', 'feedback/record'])
  154. // Blank text is recorded as absent; an entry with neither member still records.
  155. expect(feedbackRecords(test.session)).toEqual([
  156. { text: 'recorded outside a command' },
  157. { category: 'service-stability' },
  158. {},
  159. ])
  160. })
  161. it('publishes the fixed category taxonomy in presentation order', () => {
  162. expect(commandFeedback.FEEDBACK_CATEGORIES).toEqual([
  163. 'task-result', 'instruction-following', 'product-interaction', 'service-stability',
  164. 'resource-cost', 'security-privacy-permission', 'other',
  165. ])
  166. })
  167. it('keeps command bookkeeping around the authoritative feedback event', async () => {
  168. const test = await harness()
  169. await run(test, ' nothing else happens')
  170. expect(test.session.snapshotEvents().map(event => event.type)).toEqual([
  171. 'command/run', 'feedback/record', 'command/done',
  172. ])
  173. })
  174. it('normalizes surrounding whitespace without parsing command-like content', async () => {
  175. const test = await harness()
  176. await run(test, ' /plan felt SLOW\n\ttwice today ')
  177. expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today'])
  178. })
  179. it('records each entry separately without replacing earlier ones', async () => {
  180. const test = await harness()
  181. await run(test, ' first')
  182. await run(test, ' second')
  183. expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
  184. })
  185. it('records concurrent submissions in dispatch order', async () => {
  186. const test = await harness()
  187. const signal = new AbortController().signal
  188. // Command adapters may dispatch concurrent requests without awaiting one another.
  189. const settled = await Promise.all([
  190. test.ctx.commands.execute(test.agent, '/feedback first', [], signal),
  191. test.ctx.commands.execute(test.agent, '/feedback second', [], signal),
  192. ])
  193. expect(settled.map(item => item?.result)).toEqual([
  194. { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
  195. { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}.` },
  196. ])
  197. expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
  198. })
  199. it('keeps every recorded event out of model context and derived history', async () => {
  200. const test = await harness()
  201. await run(test, ' invisible to the model')
  202. for (const event of test.session.snapshotEvents()) {
  203. expect('surfaceOp' in event).toBe(false)
  204. expect(test.session.deriveEventMessage(event)).toBeNull()
  205. }
  206. expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([])
  207. expect(test.session.surface.nodes).toEqual([])
  208. expect(test.session.deriveMessages()).toEqual([])
  209. })
  210. it('rejects empty and whitespace-only input as a failed command record', async () => {
  211. const test = await harness()
  212. const expected = {
  213. kind: 'error',
  214. text: 'Feedback text is required. Usage: /feedback <text>',
  215. }
  216. await expect(run(test)).resolves.toEqual(expected)
  217. await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
  218. expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled()
  219. expect(feedbackTexts(test.session)).toEqual([])
  220. const done = test.session.snapshotEvents().filter(event => event.type === 'command/done')
  221. expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])
  222. for (const event of test.session.snapshotEvents()) {
  223. if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false)
  224. }
  225. })
  226. it('records nothing when dispatch rejects an already-cancelled request', async () => {
  227. const test = await harness()
  228. const controller = new AbortController()
  229. controller.abort(new Error('user cancelled the command'))
  230. await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal))
  231. .rejects.toThrow('user cancelled the command')
  232. expect(test.session.snapshotEvents()).toEqual([])
  233. })
  234. })