command-feedback.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import AgentRegistry 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 * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
  9. interface Harness {
  10. readonly ctx: Context
  11. readonly agent: Agent
  12. readonly session: Session
  13. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  14. }
  15. /** Build a live idle agent over a store-owned session, as an app's spine does. */
  16. function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
  17. const session = ctx.sessions.create(SessionId(id))
  18. let status: AgentStatus = 'idle'
  19. const agent: Agent = {
  20. id: session.id,
  21. options: {},
  22. session,
  23. ctx: new Context(),
  24. get status() { return status },
  25. get acceptsNextStep() { return status === 'running' },
  26. send: () => {},
  27. followup: () => {},
  28. steer: () => {},
  29. inject: () => {},
  30. cancel() { status = 'idle' },
  31. whenIdle() { return Promise.resolve() },
  32. }
  33. return { agent, session }
  34. }
  35. /** Mount the real command registry and this producer. */
  36. async function harness(): Promise<Harness> {
  37. const ctx = new Context()
  38. await ctx.plugin(CommandService)
  39. await ctx.plugin(AgentRegistry)
  40. await ctx.plugin(SessionStore)
  41. const plugin = await ctx.plugin(commandFeedback)
  42. const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
  43. ctx.agents.register(agent)
  44. return { ctx, agent, session, plugin }
  45. }
  46. /** Execute `/feedback` through the same registry boundary as a UI adapter. */
  47. async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
  48. const settled = await test.ctx.commands.execute(
  49. test.agent,
  50. `/feedback${suffix}`,
  51. new AbortController().signal,
  52. )
  53. if (settled === undefined) throw new Error('feedback command was not registered')
  54. return settled.result
  55. }
  56. /** The registry's durable record of each accepted command, in log order. */
  57. function commandRecords(session: Session): { name: string; args: string; kind: string }[] {
  58. const runs = session.events.filter(event => event.type === 'command/run')
  59. return runs.map((event) => {
  60. const done = session.events.find(item =>
  61. item.type === 'command/done' && item.data.commandId === event.data.commandId)
  62. if (done?.type !== 'command/done') throw new Error('every command/run must be paired')
  63. return { name: event.data.name, args: event.data.args, kind: done.data.kind }
  64. })
  65. }
  66. describe('@deepseek-ai/dsh-command-feedback registration', () => {
  67. it('registers one global command with Loader-safe exports and disposes it', async () => {
  68. const test = await harness()
  69. expect(commandFeedback.name).toBe('command-feedback')
  70. expect(commandFeedback.inject).toEqual(['commands'])
  71. expect('default' in commandFeedback).toBe(false)
  72. const loader = Object.create(Loader.prototype) as Loader
  73. expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
  74. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  75. name: 'feedback',
  76. description: 'record feedback about this session',
  77. input: { hint: '<text>' },
  78. })
  79. expect(test.ctx.commands.find(test.agent, 'feedback')).toBeDefined()
  80. await test.plugin.dispose()
  81. expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
  82. })
  83. })
  84. describe('/feedback human command', () => {
  85. it('acknowledges feedback and leaves the registry record as its durable trace', async () => {
  86. const test = await harness()
  87. await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
  88. kind: 'success',
  89. text: 'Feedback recorded.',
  90. })
  91. expect(commandRecords(test.session)).toEqual([
  92. { name: 'feedback', args: ' the diff view is unreadable', kind: 'success' },
  93. ])
  94. })
  95. it('adds no event of its own beyond the registry pairing', async () => {
  96. const test = await harness()
  97. await run(test, ' nothing else happens')
  98. // The whole point of the command: record and do nothing. Only the
  99. // registry's own pairing appears, and no turn of model work starts.
  100. expect(test.session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
  101. })
  102. it('records verbatim text, including input that looks like another command', async () => {
  103. const test = await harness()
  104. await run(test, ' /plan felt SLOW\n\ttwice today ')
  105. expect(commandRecords(test.session)).toEqual([
  106. { name: 'feedback', args: ' /plan felt SLOW\n\ttwice today ', kind: 'success' },
  107. ])
  108. })
  109. it('records each entry separately without replacing earlier ones', async () => {
  110. const test = await harness()
  111. await run(test, ' first')
  112. await run(test, ' second')
  113. expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
  114. })
  115. it('records concurrent submissions in dispatch order', async () => {
  116. const test = await harness()
  117. const signal = new AbortController().signal
  118. // The shipped TUI dispatches commands fire-and-forget.
  119. const settled = await Promise.all([
  120. test.ctx.commands.execute(test.agent, '/feedback first', signal),
  121. test.ctx.commands.execute(test.agent, '/feedback second', signal),
  122. ])
  123. expect(settled.map(item => item?.result)).toEqual([
  124. { kind: 'success', text: 'Feedback recorded.' },
  125. { kind: 'success', text: 'Feedback recorded.' },
  126. ])
  127. expect(commandRecords(test.session).map(record => record.args)).toEqual([' first', ' second'])
  128. })
  129. it('keeps every recorded event off the model surface and out of derived history', async () => {
  130. const test = await harness()
  131. await run(test, ' invisible to the model')
  132. for (const event of test.session.events) {
  133. expect('surfaceOp' in event).toBe(false)
  134. expect(test.session.deriveEventMessage(event)).toBeNull()
  135. }
  136. expect(foldSurface(test.session.events).nodes).toEqual([])
  137. expect(test.session.surface.nodes).toEqual([])
  138. expect(test.session.deriveMessages()).toEqual([])
  139. })
  140. it('rejects empty and whitespace-only input as a failed command record', async () => {
  141. const test = await harness()
  142. const expected = {
  143. kind: 'error',
  144. text: 'Feedback text is required. Usage: /feedback <text>',
  145. }
  146. await expect(run(test)).resolves.toEqual(expected)
  147. await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
  148. // Rejected input still leaves the registry's own pairing, settled as an
  149. // error, so no entry is mistaken for accepted feedback.
  150. expect(commandRecords(test.session).map(record => record.kind)).toEqual(['error', 'error'])
  151. })
  152. it('records nothing when dispatch rejects an already-cancelled request', async () => {
  153. const test = await harness()
  154. const controller = new AbortController()
  155. controller.abort(new Error('user cancelled the command'))
  156. await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal))
  157. .rejects.toThrow('user cancelled the command')
  158. expect(test.session.events).toEqual([])
  159. })
  160. })