command-goal.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { 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 GoalService from '@deepseek-ai/dsh-goal'
  8. import type { GoalRef } from '@deepseek-ai/dsh-goal'
  9. import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  10. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  11. import * as commandGoal from '@deepseek-ai/dsh-command-goal'
  12. import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
  13. interface Harness {
  14. readonly ctx: Context
  15. readonly agent: Agent
  16. readonly session: Session
  17. readonly plugin: Awaited<ReturnType<Context['plugin']>>
  18. }
  19. /** Build a live idle agent accepted by the exact-identity goal service. */
  20. function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
  21. // Store-created: the command executor durably logs lifecycle events on it.
  22. const session = ctx.sessions.create(SessionId(id))
  23. const inbox = createInboxStub()
  24. let status: AgentStatus = 'idle'
  25. const agent: Agent = {
  26. id: session.id,
  27. options: {},
  28. session,
  29. inbox,
  30. ctx: new Context(),
  31. get status() { return status },
  32. send: () => {},
  33. followup: () => {},
  34. steer: () => {},
  35. inject(input) { this.inbox.append('next-step', input) },
  36. cancel() { status = 'idle' },
  37. runMaintenance: task => task(new AbortController().signal),
  38. whenIdle() { return Promise.resolve() },
  39. }
  40. return { agent, session }
  41. }
  42. /** Mount the real command registry, goal domain, and producer. */
  43. async function harness(): Promise<Harness> {
  44. const ctx = new Context()
  45. await ctx.plugin(SessionStore)
  46. await ctx.plugin(SessionProjectionRegistry)
  47. await ctx.plugin(CommandRuntime)
  48. await ctx.plugin(AgentRegistry)
  49. await ctx.plugin(GoalService)
  50. const plugin = await ctx.plugin(commandGoal)
  51. const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
  52. await ctx.agents.register(agent)
  53. return { ctx, agent, session, plugin }
  54. }
  55. /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */
  56. function domainEvents(session: Session): readonly SessionEvent[] {
  57. const lifecycle = new Set<number>()
  58. for (const event of session.snapshotEvents()) {
  59. if (event.type !== 'command/run' && event.type !== 'command/done') continue
  60. lifecycle.add(event.seq)
  61. // The zero-step wrap around a lifecycle event is bookkeeping too.
  62. const before = session.snapshotEvents()[event.seq - 1]
  63. const after = session.snapshotEvents()[event.seq + 1]
  64. if (before?.type === 'turn/start') lifecycle.add(before.seq)
  65. if (after?.type === 'turn/end') lifecycle.add(after.seq)
  66. }
  67. return session.snapshotEvents().filter(event => !lifecycle.has(event.seq))
  68. }
  69. /** Execute `/goal` through the same registry boundary as a UI adapter. */
  70. async function run(test: Harness, suffix = ''): Promise<NonNullable<Awaited<ReturnType<CommandRuntime['execute']>>>['result']> {
  71. const execution = await test.ctx.commands.execute(
  72. test.agent,
  73. `/goal${suffix}`,
  74. [],
  75. new AbortController().signal,
  76. )
  77. if (execution === undefined) throw new Error('goal command was not registered')
  78. return execution.result
  79. }
  80. /** Current exact compare-and-set ref. */
  81. function ref(goal: NonNullable<ReturnType<GoalService['get']>>): GoalRef {
  82. return { id: goal.id, revision: goal.revision }
  83. }
  84. describe('@deepseek-ai/dsh-command-goal registration', () => {
  85. it('registers one global command with Loader-safe exports and disposes it', async () => {
  86. const test = await harness()
  87. expect(commandGoal.name).toBe('command-goal')
  88. expect(commandGoal.inject).toEqual(['commands', 'goals'])
  89. expect('default' in commandGoal).toBe(false)
  90. const loader = Object.create(Loader.prototype) as Loader
  91. expect(loader.unwrapExports(commandGoal)).toBe(commandGoal)
  92. expect(test.ctx.commands.list(test.agent)).toContainEqual({
  93. definitionId: '@deepseek-ai/dsh-command-goal',
  94. name: 'goal',
  95. description: 'Set or view the goal for a long-running task',
  96. input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
  97. })
  98. expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined()
  99. await test.plugin.dispose()
  100. expect(test.ctx.commands.find(test.agent, 'goal')).toBeUndefined()
  101. })
  102. })
  103. describe('/goal human command', () => {
  104. it('shows an empty status without mutating the session', async () => {
  105. const test = await harness()
  106. await expect(run(test)).resolves.toEqual({
  107. kind: 'success',
  108. text: 'No goal is currently set.\nUsage: /goal [<objective>|clear|edit <objective>|pause|resume]',
  109. })
  110. expect(domainEvents(test.session)).toEqual([])
  111. })
  112. it('creates a trimmed objective and refuses silent replacement of unfinished work', async () => {
  113. const test = await harness()
  114. const created = await run(test, '\n finish the release ')
  115. expect(created.kind).toBe('success')
  116. expect(created.text).toContain('Goal created\nStatus: active')
  117. expect(created.text).toContain('Objective: finish the release')
  118. expect(created.text).toContain('Rounds: 0/256')
  119. expect(created.text).toContain('Activation: armed')
  120. expect(test.ctx.goals.get(test.agent)?.objective).toBe('finish the release')
  121. expect(domainEvents(test.session).map(event => event.type)).toEqual(['goal/change'])
  122. const count = domainEvents(test.session).length
  123. await expect(run(test, ' replacement')).resolves.toEqual({
  124. kind: 'error',
  125. text: 'A goal is already active. Use /goal edit <objective> to change it or /goal clear before replacing it.',
  126. })
  127. expect(domainEvents(test.session)).toHaveLength(count)
  128. })
  129. it('treats only exact control words as controls', async () => {
  130. const test = await harness()
  131. await run(test, ' pause everything only after verification')
  132. expect(test.ctx.goals.get(test.agent)?.objective).toBe('pause everything only after verification')
  133. })
  134. it('edits inline, requires an objective, and starts a new goal when the old one is complete', async () => {
  135. const empty = await harness()
  136. const invalidEdit = await run(empty, ' edit')
  137. expect(invalidEdit.kind).toBe('error')
  138. expect(invalidEdit.text).toContain('requires a replacement objective')
  139. const missingEdit = await run(empty, ' edit replacement')
  140. expect(missingEdit.kind).toBe('error')
  141. expect(missingEdit.text).toContain('/goal edit requires one')
  142. const test = await harness()
  143. await run(test, ' first')
  144. const first = test.ctx.goals.get(test.agent)!
  145. const updated = await run(test, ' EDIT\n second ')
  146. expect(updated.kind).toBe('success')
  147. expect(updated.text).toContain('Goal updated')
  148. expect(test.ctx.goals.get(test.agent)).toMatchObject({ id: first.id, objective: 'second', revision: 2 })
  149. const current = test.ctx.goals.get(test.agent)!
  150. test.ctx.goals.complete(test.agent, ref(current))
  151. const replacement = await run(test, ' edit third')
  152. expect(replacement.kind).toBe('success')
  153. expect(replacement.text).toContain('Goal created')
  154. expect(test.ctx.goals.get(test.agent)).toMatchObject({ objective: 'third', revision: 1 })
  155. expect(test.ctx.goals.get(test.agent)?.id).not.toBe(first.id)
  156. })
  157. it('returns direct missing-state results for pause, resume, and clear', async () => {
  158. const test = await harness()
  159. const missingPause = await run(test, ' pause')
  160. expect(missingPause.kind).toBe('error')
  161. expect(missingPause.text).toContain('/goal pause requires one')
  162. const missingResume = await run(test, ' resume')
  163. expect(missingResume.kind).toBe('error')
  164. expect(missingResume.text).toContain('/goal resume requires one')
  165. await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'No goal to clear.' })
  166. })
  167. it('pauses, resumes, clears, and converts expected domain rejections to command errors', async () => {
  168. const test = await harness()
  169. await run(test, ' work')
  170. const redundantResume = await run(test, ' RESUME')
  171. expect(redundantResume).toEqual({
  172. kind: 'error',
  173. text: 'The goal command is not valid for the current state. Run /goal to view available commands.',
  174. })
  175. const paused = await run(test, ' PAUSE')
  176. expect(paused.kind).toBe('success')
  177. expect(paused.text).toContain('Goal paused')
  178. expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'paused', activation: 'disarmed' })
  179. const resumed = await run(test, ' resume')
  180. expect(resumed.kind).toBe('success')
  181. expect(resumed.text).toContain('Goal resumed')
  182. expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', activation: 'armed' })
  183. await expect(run(test, ' clear')).resolves.toEqual({ kind: 'success', text: 'Goal cleared.' })
  184. expect(test.ctx.goals.get(test.agent)).toBeUndefined()
  185. })
  186. it('shows every durable phase and distinguishes disarmed active state', async () => {
  187. const test = await harness()
  188. test.ctx.goals.create(test.agent, { objective: 'state matrix', maxGoalRounds: 1 })
  189. test.ctx.goals.disarm(test.agent)
  190. expect((await run(test)).text)
  191. .toContain('Status: active\nObjective: state matrix\nRounds: 0/1\nActivation: disarmed')
  192. expect((await run(test)).text).toContain('/goal resume')
  193. let goal = test.ctx.goals.get(test.agent)!
  194. goal = test.ctx.goals.resume(test.agent, ref(goal))
  195. goal = test.ctx.goals.pause(test.agent, ref(goal))
  196. expect((await run(test)).text).toContain('Status: paused')
  197. goal = test.ctx.goals.resume(test.agent, ref(goal))
  198. goal = test.ctx.goals.block(test.agent, ref(goal), {
  199. code: 'upstream-unavailable',
  200. message: 'Provider unavailable',
  201. })
  202. const blocked = await run(test)
  203. expect(blocked.text).toContain('Status: blocked')
  204. expect(blocked.text).toContain('Blocker: upstream-unavailable: Provider unavailable')
  205. goal = test.ctx.goals.resume(test.agent, ref(goal))
  206. test.ctx.goals.complete(test.agent, ref(goal))
  207. const complete = await run(test)
  208. expect(complete.text).toContain('Status: complete')
  209. expect(complete.text).toContain('Commands: /goal <objective>, /goal clear')
  210. })
  211. it('does not turn unexpected implementation failures into expected command results', async () => {
  212. const test = await harness()
  213. vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('unexpected failure') })
  214. await expect(run(test)).rejects.toThrow('unexpected failure')
  215. })
  216. })
  217. describe('/goal attachments', () => {
  218. const PNG = 'AAAA'
  219. /** Wire the fake store the executor admits through (once per harness). */
  220. function provideStore(test: Harness): void {
  221. let saved = 0
  222. const saveImage = (input: { mediaType: string; name?: string }) => {
  223. saved += 1
  224. return Promise.resolve({
  225. attachmentId: `att-${saved}`, mediaType: input.mediaType, bytes: 3, width: 1, height: 1,
  226. ...input.name === undefined ? {} : { name: input.name },
  227. })
  228. }
  229. test.ctx.provide('attachments', {
  230. imageLimits: {
  231. maxImageBytes: 1024, maxImagesPerMessage: 4, maxMessageImageBytes: 1024,
  232. maxImagePixels: 1_000_000, mediaTypes: ['image/png'],
  233. },
  234. validateImage: () => Promise.resolve(),
  235. saveImage,
  236. async saveImages(inputs: readonly { mediaType: string; name?: string }[]) {
  237. const refs = []
  238. for (const input of inputs) refs.push(await saveImage(input))
  239. return refs
  240. },
  241. saveFile(input: { data: Uint8Array; name?: string }) {
  242. saved += 1
  243. return Promise.resolve({
  244. attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment',
  245. })
  246. },
  247. })
  248. test.ctx.commands.registerFileReceiptResolver((_agent, receiptId) => receiptId === 'receipt-notes'
  249. ? { attachmentId: 'file-notes' as never, bytes: 5, name: 'notes.txt' }
  250. : undefined)
  251. }
  252. /** Run /goal with a mixed composer batch through the executor boundary. */
  253. async function runWithAttachments(test: Harness, suffix: string, includeFile = true) {
  254. const attachments = [
  255. { type: 'image' as const, mediaType: 'image/png' as const, data: PNG, name: 'ref.png' },
  256. ...(includeFile ? [{ type: 'file' as const, receiptId: 'receipt-notes' }] : []),
  257. ]
  258. const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, attachments, new AbortController().signal)
  259. if (execution === undefined) throw new Error('goal command was not registered')
  260. return execution.result
  261. }
  262. it('submits one user followup carrying mixed attachments ahead of the round prompt', async () => {
  263. const test = await harness()
  264. provideStore(test)
  265. const followup = vi.fn()
  266. ;(test.agent as unknown as { followup: typeof followup }).followup = followup
  267. const result = await runWithAttachments(test, ' rebuild the cathedral')
  268. expect(result.kind).toBe('success')
  269. expect(followup).toHaveBeenCalledTimes(1)
  270. const message = followup.mock.calls[0]?.[0] as {
  271. content: ReadonlyArray<Record<string, unknown>>
  272. source: { kind: string }
  273. }
  274. expect(message.source).toEqual({ kind: 'user' })
  275. expect(message.content.map(block => block.type)).toEqual(['image', 'file', 'text'])
  276. expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference attachments for the goal objective.' })
  277. expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref.png')
  278. expect((message.content[1] as { attachment: { name: string } }).attachment.name).toBe('notes.txt')
  279. })
  280. it('accompanies an edit and a post-complete recreate the same way', async () => {
  281. const test = await harness()
  282. provideStore(test)
  283. const followup = vi.fn()
  284. ;(test.agent as unknown as { followup: typeof followup }).followup = followup
  285. test.ctx.goals.create(test.agent, { objective: 'initial objective' })
  286. const result = await runWithAttachments(test, ' edit refined objective')
  287. expect(result.kind).toBe('success')
  288. expect(followup).toHaveBeenCalledTimes(1)
  289. })
  290. it('rejects attachments on sub-commands that cannot use them, leaving the domain untouched', async () => {
  291. const test = await harness()
  292. provideStore(test)
  293. const followup = vi.fn()
  294. ;(test.agent as unknown as { followup: typeof followup }).followup = followup
  295. test.ctx.goals.create(test.agent, { objective: 'active objective' })
  296. for (const suffix of [' pause', '', ' clear']) {
  297. const result = await runWithAttachments(test, suffix, false)
  298. expect(result).toEqual({
  299. kind: 'error',
  300. text: 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
  301. })
  302. }
  303. expect(followup).not.toHaveBeenCalled()
  304. expect(test.ctx.goals.get(test.agent)?.phase).toBe('active')
  305. })
  306. it('does not submit attachments when goal creation is refused', async () => {
  307. const test = await harness()
  308. provideStore(test)
  309. const followup = vi.fn()
  310. ;(test.agent as unknown as { followup: typeof followup }).followup = followup
  311. test.ctx.goals.create(test.agent, { objective: 'existing objective' })
  312. const result = await runWithAttachments(test, ' replacement objective')
  313. expect(result.kind).toBe('error')
  314. expect(followup).not.toHaveBeenCalled()
  315. })
  316. })