command-goal.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { describe, expect, it, vi } 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 GoalService from '@deepseek-ai/dsh-goal'
  8. import type { GoalRef } from '@deepseek-ai/dsh-goal'
  9. import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
  10. import * as commandGoal from '@deepseek-ai/dsh-command-goal'
  11. interface Harness {
  12. readonly ctx: Context
  13. readonly agent: Agent
  14. readonly session: Session
  15. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  16. }
  17. /** Append one idle injection using the public Agent contract (idle inject wraps in a one-shot injection turn, per turn enclosure). */
  18. function appendInjection(session: Session, input: UserMessage): void {
  19. const lastStart = session.events.findLast(event => event.type === 'turn/start')
  20. const turn = (lastStart?.data.turn ?? 0) + 1
  21. session.append('turn/start', { turn, trigger: { kind: 'injection', source: input.source } })
  22. session.append('user/message', input, { surfaceOp: 'append' })
  23. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  24. }
  25. /** Build a live idle agent accepted by the exact-identity goal service. */
  26. function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
  27. // Store-created: the command executor durably logs lifecycle events on it.
  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. ctx: new Context(),
  35. get status() { return status },
  36. get acceptsNextStep() { return status === 'running' },
  37. send: () => {},
  38. updateInbox: () => 'not-found',
  39. followup: () => {},
  40. steer: () => {},
  41. inject(input) { appendInjection(session, input) },
  42. cancel() { status = 'idle' },
  43. whenIdle() { return Promise.resolve() },
  44. }
  45. return { agent, session }
  46. }
  47. /** Mount the real command registry, goal domain, and producer. */
  48. async function harness(): Promise<Harness> {
  49. const ctx = new Context()
  50. await ctx.plugin(SessionStore)
  51. await ctx.plugin(CommandService)
  52. await ctx.plugin(AgentRegistry)
  53. await ctx.plugin(GoalService)
  54. const plugin = await ctx.plugin(commandGoal)
  55. const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
  56. ctx.agents.register(agent)
  57. return { ctx, agent, session, plugin }
  58. }
  59. /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */
  60. function domainEvents(session: Session): readonly Session['events'][number][] {
  61. const lifecycle = new Set<number>()
  62. for (const event of session.events) {
  63. if (event.type !== 'command/run' && event.type !== 'command/done') continue
  64. lifecycle.add(event.seq)
  65. // The zero-step wrap around a lifecycle event is bookkeeping too.
  66. const before = session.events[event.seq - 1]
  67. const after = session.events[event.seq + 1]
  68. if (before?.type === 'turn/start') lifecycle.add(before.seq)
  69. if (after?.type === 'turn/end') lifecycle.add(after.seq)
  70. }
  71. return session.events.filter(event => !lifecycle.has(event.seq))
  72. }
  73. /** Execute `/goal` through the same registry boundary as a UI adapter. */
  74. async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandService['execute']>>>['result']> {
  75. const execution = await test.ctx.commands.execute(
  76. test.agent,
  77. `/goal${suffix}`,
  78. new AbortController().signal,
  79. )
  80. if (execution === undefined) throw new Error('goal command was not registered')
  81. return execution.result
  82. }
  83. /** Current exact compare-and-set ref. */
  84. function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
  85. return { id: goal.id, revision: goal.revision }
  86. }
  87. describe('@deepseek-ai/dsh-command-goal registration', () => {
  88. it('registers one global command with Loader-safe exports and disposes it', async () => {
  89. const test = await harness()
  90. expect(commandGoal.name).toBe('command-goal')
  91. expect(commandGoal.inject).toEqual(['commands', 'goals'])
  92. expect('default' in commandGoal).toBe(false)
  93. const loader = Object.create(Loader.prototype) as Loader
  94. expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
  95. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  96. name: 'goal',
  97. description: 'set or view the goal for a long-running task',
  98. input: { hint: '[<objective>|clear|edit <objective>|pause|resume]' },
  99. })
  100. expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
  101. await test.plugin.dispose()
  102. expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
  103. })
  104. })
  105. describe('/goal human command', () => {
  106. it('shows an empty status without mutating the session', async () => {
  107. const test = await harness()
  108. await expect(run(test)).resolves.toEqual({
  109. kind: 'success',
  110. text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
  111. })
  112. expect(domainEvents(test.session)).toEqual([])
  113. })
  114. it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
  115. const test = await harness()
  116. const created = await run(test, '\n finish the release ')
  117. expect(created.kind).toBe('success')
  118. expect(created.text).toContain('Goal created\nStatus: active')
  119. expect(created.text).toContain('Objective: finish the release')
  120. expect(created.text).toContain('Rounds: 0/256')
  121. expect(created.text).toContain('Activation: armed')
  122. expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
  123. expect(domainEvents(test.session).map(event => event.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
  124. const count = domainEvents(test.session).length
  125. await expect(run(test, ' replacement')).resolves.toEqual({
  126. kind: 'error',
  127. text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
  128. })
  129. expect(domainEvents(test.session)).toHaveLength(count)
  130. })
  131. it('treats only exact control words as controls', async () => {
  132. const test = await harness()
  133. await run(test, ' pause everything only after verification')
  134. expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification')
  135. })
  136. it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => {
  137. const empty = await harness()
  138. const invalidEdit = await run(empty, ' edit')
  139. expect(invalidEdit.kind).toBe('error')
  140. expect(invalidEdit.text).toContain('requires a replacement objective')
  141. const missingEdit = await run(empty, ' edit replacement')
  142. expect(missingEdit.kind).toBe('error')
  143. expect(missingEdit.text).toContain('/goal edit requires one')
  144. const test = await harness()
  145. await run(test, ' first')
  146. const first = test.ctx.goals.get(test.agent)!
  147. const updated = await run(test, ' EDIT\n second ')
  148. expect(updated.kind).toBe('success')
  149. expect(updated.text).toContain('Goal updated')
  150. expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 })
  151. const current = test.ctx.goals.get(test.agent)!
  152. test.ctx.goals.complete(test.agent, ref(current))
  153. const replacement = await run(test, ' edit third')
  154. expect(replacement.kind).toBe('success')
  155. expect(replacement.text).toContain('Goal created')
  156. expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 })
  157. expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id)
  158. })
  159. it('returns direct missing-state results for pause, resume, and clear', async () => {
  160. const test = await harness()
  161. const missingPause = await run(test, ' pause')
  162. expect(missingPause.kind).toBe('error')
  163. expect(missingPause.text).toContain('/goal pause requires one')
  164. const missingResume = await run(test, ' resume')
  165. expect(missingResume.kind).toBe('error')
  166. expect(missingResume.text).toContain('/goal resume requires one')
  167. await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' })
  168. })
  169. it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => {
  170. const test = await harness()
  171. await run(test, ' work')
  172. const redundantResume = await run(test, ' RESUME')
  173. expect(redundantResume).toEqual({
  174. kind: 'error',
  175. text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
  176. })
  177. const paused = await run(test, ' PAUSE')
  178. expect(paused.kind).toBe('success')
  179. expect(paused.text).toContain('Goal paused')
  180. expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' })
  181. const resumed = await run(test, ' resume')
  182. expect(resumed.kind).toBe('success')
  183. expect(resumed.text).toContain('Goal resumed')
  184. expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' })
  185. await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' })
  186. expect(test.ctx.goals.get(test.agent)).toBeUndefined()
  187. })
  188. it('shows every durable phase and distinguishes disarmed active state', async () => {
  189. const test = await harness()
  190. test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 })
  191. test.ctx.goals.disarm(test.agent)
  192. expect((await run(test)).text)
  193. .toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed')
  194. expect((await run(test)).text).toContain('/goal resume')
  195. let goal = test.ctx.goals.get(test.agent)!
  196. goal = test.ctx.goals.resume(test.agent, ref(goal))
  197. goal = test.ctx.goals.pause(test.agent, ref(goal))
  198. expect((await run(test)).text).toContain('Status: paused')
  199. goal = test.ctx.goals.resume(test.agent, ref(goal))
  200. goal = test.ctx.goals.block(test.agent, ref(goal), {
  201. code: 'upstream-unavailable',
  202. message: 'Provider unavailable',
  203. })
  204. const blocked = await run(test)
  205. expect(blocked.text).toContain('Status: blocked')
  206. expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
  207. goal = test.ctx.goals.resume(test.agent, ref(goal))
  208. test.ctx.goals.complete(test.agent, ref(goal))
  209. const complete = await run(test)
  210. expect(complete.text).toContain('Status: complete')
  211. expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')
  212. })
  213. it('does not turn unexpected implementation failures into expected command results', async () => {
  214. const test = await harness()
  215. vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') })
  216. await expect(run(test)).rejects.toThrow('unexpected failure')
  217. })
  218. })