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

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