command-feedback.spec.ts 10 KB

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