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

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