commands-queue-attachment.host.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  3. import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
  4. import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
  5. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  6. import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { ApiSessionAgentController } from '../src/agent.ts'
  11. import { SessionCommandController } from '../src/commands.ts'
  12. async function commandHarness(): Promise<{
  13. ctx: Context
  14. controller: SessionCommandController
  15. agent: Agent
  16. inbox: Inbox
  17. steer: ReturnType<typeof vi.fn>
  18. cancel: ReturnType<typeof vi.fn>
  19. }> {
  20. const ctx = new Context()
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(AgentRegistry)
  23. const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
  24. const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
  25. const steer = vi.fn()
  26. const cancel = vi.fn()
  27. const agent = {
  28. id: session.id,
  29. session,
  30. inbox,
  31. status: 'running',
  32. ctx,
  33. steer,
  34. followup: vi.fn(),
  35. cancel,
  36. } as unknown as Agent
  37. ctx.agents.register(agent)
  38. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  39. ctx.provide('agentDefaultModel', {
  40. currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  41. saveSelection: () => Promise.resolve(),
  42. } as never)
  43. const selection: ModelSelectionRef = {
  44. current: { provider: 'fixture', model: 'fixture-model' },
  45. assembled: undefined,
  46. }
  47. const agents = {
  48. resolveAgent: () => Promise.resolve({ agent }),
  49. selectionFor: () => selection,
  50. serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
  51. composeAgent: () => Promise.resolve({ setup: () => {} }),
  52. } as unknown as ApiSessionAgentController
  53. return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel }
  54. }
  55. async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
  56. await expect(operation).rejects.toMatchObject({ failure: { code } })
  57. }
  58. describe('Session queue commands', () => {
  59. it('edits, removes, steers, and rejects stale queue occurrences', async () => {
  60. const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
  61. const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
  62. const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
  63. inbox.append('next-turn', queued)
  64. inbox.append('next-step', nextStep)
  65. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  66. sessionId: agent.id,
  67. itemId: queued.id,
  68. action: {
  69. kind: 'edit',
  70. content: [{
  71. type: 'image',
  72. attachment: {
  73. attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
  74. },
  75. }],
  76. },
  77. })), 'attachment-error')
  78. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  79. sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
  80. })), 'queue-item-not-found')
  81. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  82. sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
  83. })), 'queue-item-not-found')
  84. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  85. sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
  86. })), 'steer-unavailable')
  87. Object.assign(agent, { status: 'idle' })
  88. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  89. sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
  90. })), 'steer-unavailable')
  91. expect(controller.updateQueue({
  92. sessionId: agent.id,
  93. itemId: queued.id,
  94. action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
  95. })).toEqual({ accepted: true })
  96. expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
  97. expect(controller.updateQueue({
  98. sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
  99. })).toEqual({ accepted: true })
  100. Object.assign(agent, { status: 'running' })
  101. const steered = inbox.nextTurn[0]
  102. if (steered === undefined) throw new Error('missing edited queue item')
  103. expect(controller.updateQueue({
  104. sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
  105. })).toEqual({ accepted: true })
  106. expect(steer).toHaveBeenCalledWith(steered)
  107. await expectFailure(Promise.resolve().then(() => controller.cancel({
  108. sessionId: SessionId('missing'),
  109. })), 'session-not-found')
  110. expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
  111. expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
  112. await ctx.fiber.dispose()
  113. })
  114. })
  115. function imageRef(id: string): ImageAttachmentRef {
  116. return {
  117. attachmentId: AttachmentId(id),
  118. mediaType: 'image/png',
  119. bytes: 1,
  120. width: 1,
  121. height: 1,
  122. }
  123. }
  124. function event(type: string, seq: number, data: unknown): SessionEvent {
  125. return { type, seq, time: seq + 1, data } as SessionEvent
  126. }
  127. async function persistedController(
  128. events: SessionEvent[],
  129. readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
  130. ): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
  131. const ctx = new Context()
  132. await ctx.plugin(SessionStore)
  133. const sessionId = SessionId('cold-attachment')
  134. const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
  135. ctx.provide('sessionPersistence', {
  136. list: () => Promise.resolve([meta]),
  137. inspect: () => Promise.resolve({ meta, events }),
  138. } as never)
  139. ctx.provide('attachments', { readImage } as never)
  140. const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
  141. return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
  142. }
  143. describe('Session attachment authorization', () => {
  144. it('finds references in direct, message, inserted, nested, and streamed content', async () => {
  145. const nested = imageRef('nested')
  146. const message = imageRef('message')
  147. const inserted = imageRef('inserted')
  148. const streamed = imageRef('streamed')
  149. const events = [
  150. event('fixture/direct', 0, {
  151. content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
  152. type: 'tool-result', content: [{ type: 'image', attachment: nested }],
  153. }],
  154. }),
  155. event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }),
  156. event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }),
  157. event('assistant/chunk', 3, {
  158. chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } },
  159. }),
  160. ]
  161. const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
  162. const { ctx, controller, sessionId } = await persistedController(events, readImage)
  163. for (const ref of [nested, message, inserted, streamed]) {
  164. await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
  165. .resolves.toEqual({ attachment: ref, data: 'AQ==' })
  166. }
  167. expect(readImage).toHaveBeenCalledTimes(4)
  168. await ctx.fiber.dispose()
  169. })
  170. it('maps missing persistence identities and attachment backend failures', async () => {
  171. const noPersistence = new Context()
  172. await noPersistence.plugin(SessionStore)
  173. const noPersistenceController = new SessionCommandController(
  174. noPersistence,
  175. { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
  176. '/workspace',
  177. )
  178. await expectFailure(noPersistenceController.attachment({
  179. sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
  180. }), 'internal')
  181. const missing = new Context()
  182. await missing.plugin(SessionStore)
  183. missing.provide('sessionPersistence', {
  184. list: () => Promise.resolve([]),
  185. inspect: vi.fn(),
  186. } as never)
  187. const missingController = new SessionCommandController(
  188. missing,
  189. { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
  190. '/workspace',
  191. )
  192. await expectFailure(missingController.attachment({
  193. sessionId: SessionId('missing'), attachmentId: 'att' as never,
  194. }), 'session-not-found')
  195. for (const thrown of [
  196. new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
  197. new Error('backend offline'),
  198. ]) {
  199. const ref = imageRef(`failure-${thrown.name}`)
  200. const fixture = await persistedController(
  201. [event('fixture/content', 0, { content: [{ type: 'image', attachment: ref }] })],
  202. () => Promise.reject(thrown),
  203. )
  204. await expectFailure(fixture.controller.attachment({
  205. sessionId: fixture.sessionId,
  206. attachmentId: ref.attachmentId,
  207. }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal')
  208. await fixture.ctx.fiber.dispose()
  209. }
  210. })
  211. })