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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry from '@deepseek-ai/dsh-agent'
  3. import type { Agent, Inbox, 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, {
  8. SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
  9. } from '@deepseek-ai/dsh-session'
  10. import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
  11. import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
  12. import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
  13. import { describe, expect, it, vi } from 'vitest'
  14. import { ApiSessionAgentController } from '../src/agent.ts'
  15. import { SessionCommandController } from '../src/commands.ts'
  16. import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
  17. import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
  18. async function commandHarness(
  19. childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
  20. ): Promise<{
  21. ctx: Context
  22. controller: SessionCommandController
  23. agent: Agent
  24. inbox: Inbox
  25. steer: ReturnType<typeof vi.fn>
  26. cancel: ReturnType<typeof vi.fn>
  27. }> {
  28. const ctx = new Context()
  29. await ctx.plugin(SessionStore)
  30. await ctx.plugin(AgentRegistry)
  31. installSessionReadTestServices(ctx)
  32. ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
  33. const sessionId = SessionId('commands-session')
  34. const ancestor = Session.create(SessionId('ancestor'))
  35. ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
  36. mode: 'continuable', provider: 'test', label: 'ancestor',
  37. }))
  38. // A seeded child inherits exactly the ancestor prefix; its own descriptor
  39. // is appended after creation, as the continuation manager does. `seed-only`
  40. // never appends one: the identity folds as continuable, but from the
  41. // inherited prefix rather than this Session's own suffix.
  42. const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
  43. ? ancestor.snapshotEvents()
  44. : undefined
  45. const session = ctx.sessions.create(sessionId, {
  46. ...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
  47. meta: {
  48. cwd: '/workspace',
  49. ...(childMode === undefined ? {} : {
  50. origin: 'subagent' as const,
  51. parentSession: SessionId('offline-parent'),
  52. }),
  53. ...lineage === undefined ? {} : { isSeeded: true },
  54. },
  55. })
  56. if (childMode === 'continuable' || childMode === 'seeded-continuable') {
  57. session.append('subagent/descriptor', snapshotSubagentDescriptor({
  58. mode: 'continuable', provider: 'test', label: 'child',
  59. }))
  60. } else if (childMode === 'one-shot') {
  61. session.append('subagent/descriptor', snapshotSubagentDescriptor({
  62. mode: 'one-shot', provider: 'test', label: 'child',
  63. }))
  64. } else if (childMode === 'corrupt') {
  65. session.append('subagent/descriptor', {
  66. version: SUBAGENT_DESCRIPTOR_VERSION,
  67. mode: 'continuable',
  68. provider: 1,
  69. } as never)
  70. }
  71. const inbox = createInboxStub()
  72. const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
  73. const cancel = vi.fn()
  74. const agent = {
  75. id: session.id,
  76. session,
  77. inbox,
  78. status: 'running',
  79. ctx,
  80. steer,
  81. followup: vi.fn(),
  82. cancel,
  83. } as unknown as Agent
  84. ctx.agents.register(agent)
  85. ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
  86. ctx.provide('agentDefaultModel', {
  87. currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  88. saveSelection: () => Promise.resolve(),
  89. } as never)
  90. const selection: ModelSelectionRef = {
  91. current: { provider: 'fixture', model: 'fixture-model' },
  92. assembled: undefined,
  93. }
  94. const agents = {
  95. resolveAgent: () => Promise.resolve({ agent }),
  96. selectionFor: () => selection,
  97. serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
  98. composeAgent: () => Promise.resolve({ setup: () => {} }),
  99. } as unknown as ApiSessionAgentController
  100. return {
  101. ctx,
  102. controller: new SessionCommandController(ctx, agents, '/workspace'),
  103. agent,
  104. inbox,
  105. steer,
  106. cancel,
  107. }
  108. }
  109. async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
  110. await expect(operation).rejects.toMatchObject({ code })
  111. }
  112. describe('Session queue commands', () => {
  113. it('edits, removes, steers, and rejects stale queue occurrences', async () => {
  114. const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
  115. const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
  116. const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
  117. inbox.append('next-turn', queued)
  118. inbox.append('next-step', nextStep)
  119. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  120. sessionId: agent.id,
  121. itemId: queued.id,
  122. action: {
  123. kind: 'edit',
  124. content: [{
  125. type: 'image',
  126. attachment: {
  127. attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
  128. },
  129. }],
  130. },
  131. })), 'session/attachment-invalid')
  132. for (const content of [[], [{ type: 'text' as const, text: ' \t\n' }]]) {
  133. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  134. sessionId: agent.id,
  135. itemId: queued.id,
  136. action: { kind: 'edit', content },
  137. })), 'gateway/bad-request')
  138. }
  139. expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'queued' }])
  140. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  141. sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
  142. })), 'session/queue-item-not-found')
  143. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  144. sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
  145. })), 'session/queue-item-not-found')
  146. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  147. sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
  148. })), 'session/steer-unavailable')
  149. Object.assign(agent, { status: 'idle' })
  150. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  151. sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
  152. })), 'session/steer-unavailable')
  153. expect(controller.updateQueue({
  154. sessionId: agent.id,
  155. itemId: queued.id,
  156. action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
  157. })).toEqual({ accepted: true })
  158. expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
  159. // An edit rewrites content in place, so the occurrence a client addressed
  160. // by id stays addressable.
  161. expect(inbox.nextTurn[0]?.id).toBe(queued.id)
  162. expect(controller.updateQueue({
  163. sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
  164. })).toEqual({ accepted: true })
  165. Object.assign(agent, { status: 'running' })
  166. const steered = inbox.nextTurn[0]
  167. if (steered === undefined) throw new Error('missing edited queue item')
  168. expect(controller.updateQueue({
  169. sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
  170. })).toEqual({ accepted: true })
  171. expect(steer).toHaveBeenCalledWith(steered)
  172. const queuedFile = createUserMessage({
  173. content: [{
  174. type: 'file',
  175. attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
  176. }],
  177. source: { kind: 'user', rpcId: 'file-rpc' as never },
  178. })
  179. inbox.append('next-turn', queuedFile)
  180. expect(controller.updateQueue({
  181. sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
  182. })).toEqual({ accepted: true })
  183. expect(steer).toHaveBeenLastCalledWith(queuedFile)
  184. expect(queuedFile).toMatchObject({
  185. source: { kind: 'user', rpcId: 'file-rpc' },
  186. content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
  187. })
  188. await expectFailure(Promise.resolve().then(() => controller.cancel({
  189. sessionId: SessionId('missing'),
  190. })), 'session/not-found')
  191. expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
  192. expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
  193. await ctx.fiber.dispose()
  194. })
  195. it.each(['continuable', 'seeded-continuable'] as const)(
  196. 'mutates both inbox destinations of a live %s child while its parent is offline',
  197. async (childMode) => {
  198. const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
  199. const queued = createUserMessage({
  200. content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
  201. })
  202. const context = createUserMessage({
  203. content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
  204. })
  205. inbox.append('next-turn', queued)
  206. inbox.append('next-step', context)
  207. expect(controller.updateQueue({
  208. sessionId: agent.id,
  209. itemId: context.id,
  210. action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
  211. })).toEqual({ accepted: true })
  212. const editedContext = inbox.nextStep[0]
  213. expect(editedContext).toMatchObject({
  214. content: [{ type: 'text', text: 'edited context' }],
  215. source: context.source,
  216. })
  217. expect(editedContext?.id).toBe(context.id)
  218. if (editedContext === undefined) throw new Error('missing edited context')
  219. expect(controller.updateQueue({
  220. sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
  221. })).toEqual({ accepted: true })
  222. expect(controller.updateQueue({
  223. sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
  224. })).toEqual({ accepted: true })
  225. expect(steer).toHaveBeenCalledWith(queued)
  226. await ctx.fiber.dispose()
  227. },
  228. )
  229. it('removes the selected message before handing it to Agent steering', async () => {
  230. const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
  231. const first = createUserMessage({
  232. content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
  233. })
  234. const second = createUserMessage({
  235. content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
  236. })
  237. inbox.append('next-turn', first)
  238. inbox.append('next-turn', second)
  239. // Stand in for the Agent's cancellation-convergence destination; the
  240. // command must accept whichever boundary `Agent.steer()` selects.
  241. steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
  242. expect(controller.updateQueue({
  243. sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
  244. })).toEqual({ accepted: true })
  245. expect(steer).toHaveBeenCalledWith(first)
  246. // Ordering proves the removal happened before delivery rather than after.
  247. expect(inbox.nextTurn).toEqual([second, first])
  248. expect(inbox.nextStep).toEqual([])
  249. await ctx.fiber.dispose()
  250. })
  251. it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
  252. for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
  253. const { ctx, controller, agent, inbox } = await commandHarness(mode)
  254. const queued = createUserMessage({
  255. content: [{ type: 'text', text: mode }], source: { kind: 'user' },
  256. })
  257. inbox.append('next-turn', queued)
  258. await expectFailure(Promise.resolve().then(() => controller.updateQueue({
  259. sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
  260. })), 'session/agent-busy')
  261. expect(inbox.nextTurn).toEqual([queued])
  262. await ctx.fiber.dispose()
  263. }
  264. })
  265. })
  266. function imageRef(id: string): ImageAttachmentRef {
  267. return {
  268. attachmentId: AttachmentId(id),
  269. mediaType: 'image/png',
  270. bytes: 1,
  271. width: 1,
  272. height: 1,
  273. }
  274. }
  275. function event(type: string, seq: SessionSeq, data: unknown): SessionEvent {
  276. return { type, seq, time: seq + 1, data } as SessionEvent
  277. }
  278. async function persistedController(
  279. events: SessionEvent[],
  280. readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
  281. ): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
  282. const ctx = new Context()
  283. await ctx.plugin(SessionStore)
  284. const sessionId = SessionId('cold-attachment')
  285. const meta: SessionHeader = {
  286. version: SESSION_FORMAT_VERSION,
  287. id: sessionId,
  288. createdAt: 1,
  289. cwd: '/workspace',
  290. isSeeded: false,
  291. }
  292. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  293. list: () => Promise.resolve([meta]),
  294. inspect: () => Promise.resolve({
  295. meta,
  296. inheritedEventCount: SessionLogOffset(0),
  297. events,
  298. }),
  299. }) as never)
  300. installSessionReadTestServices(ctx)
  301. ctx.provide('attachments', { readImage } as never)
  302. const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
  303. return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
  304. }
  305. describe('Session attachment authorization', () => {
  306. it('finds references in direct, message, inserted, nested, and streamed content', async () => {
  307. const nested = imageRef('nested')
  308. const message = imageRef('message')
  309. const inserted = imageRef('inserted')
  310. const streamed = imageRef('streamed')
  311. const events: SessionEvent[] = [
  312. { ...event('fixture/direct', SessionSeq(0), {
  313. content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
  314. type: 'tool-result', content: [{ type: 'image', attachment: nested }],
  315. }],
  316. }), ignorable: true as const },
  317. {
  318. type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append',
  319. data: {
  320. turn: 1,
  321. step: 1,
  322. stream: [],
  323. message: createAssistantMessage({
  324. content: [{ type: 'image', attachment: message }],
  325. source: { provider: 'fixture', model: 'fixture' },
  326. }),
  327. },
  328. },
  329. event('agent/inbox/spliced', SessionSeq(2), {
  330. target: 'next-turn',
  331. start: 0,
  332. inserted: [createUserMessage({
  333. content: [{ type: 'image', attachment: inserted }],
  334. source: { kind: 'user' },
  335. })],
  336. }),
  337. event('assistant/attempt', SessionSeq(3), {
  338. turn: 1,
  339. step: 1,
  340. stream: [
  341. {
  342. type: 'chunk',
  343. time: 3,
  344. chunk: { type: 'block-start', index: 0, blockType: 'text' },
  345. },
  346. {
  347. type: 'chunk',
  348. time: 3,
  349. chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } },
  350. },
  351. ],
  352. }),
  353. event('assistant/attempt', SessionSeq(4), {
  354. turn: 1,
  355. step: 1,
  356. stream: [{
  357. type: 'chunk',
  358. time: 4,
  359. chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
  360. }],
  361. }),
  362. ]
  363. const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
  364. const { ctx, controller, sessionId } = await persistedController(events, readImage)
  365. for (const ref of [nested, message, inserted, streamed]) {
  366. await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
  367. .resolves.toEqual({ attachment: ref, data: 'AQ==' })
  368. }
  369. expect(readImage).toHaveBeenCalledTimes(4)
  370. await ctx.fiber.dispose()
  371. })
  372. it('maps missing persistence identities and attachment backend failures', async () => {
  373. const noPersistence = new Context()
  374. await noPersistence.plugin(SessionStore)
  375. installSessionReadTestServices(noPersistence)
  376. const noPersistenceController = new SessionCommandController(
  377. noPersistence,
  378. { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
  379. '/workspace',
  380. )
  381. await expectFailure(noPersistenceController.attachment({
  382. sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
  383. }), 'session/not-found')
  384. const missing = new Context()
  385. await missing.plugin(SessionStore)
  386. missing.provide('sessionPersistence', testSessionPersistence(missing, {
  387. list: () => Promise.resolve([]),
  388. inspect: vi.fn(),
  389. }) as never)
  390. installSessionReadTestServices(missing)
  391. const missingController = new SessionCommandController(
  392. missing,
  393. { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
  394. '/workspace',
  395. )
  396. await expectFailure(missingController.attachment({
  397. sessionId: SessionId('missing'), attachmentId: 'att' as never,
  398. }), 'session/not-found')
  399. for (const thrown of [
  400. new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
  401. new Error('backend offline'),
  402. ]) {
  403. const ref = imageRef(`failure-${thrown.name}`)
  404. const fixture = await persistedController(
  405. [event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })],
  406. () => Promise.reject(thrown),
  407. )
  408. await expectFailure(fixture.controller.attachment({
  409. sessionId: fixture.sessionId,
  410. attachmentId: ref.attachmentId,
  411. }), thrown instanceof AttachmentError ? 'session/attachment-invalid' : 'gateway/internal')
  412. await fixture.ctx.fiber.dispose()
  413. }
  414. })
  415. it('maps a cold observation failure to an internal authorization error', async () => {
  416. const ctx = new Context()
  417. await ctx.plugin(SessionStore)
  418. installSessionReadTestServices(ctx)
  419. vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
  420. const controller = new SessionCommandController(
  421. ctx,
  422. { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
  423. '/workspace',
  424. )
  425. await expectFailure(controller.attachment({
  426. sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
  427. }), 'gateway/internal')
  428. await ctx.fiber.dispose()
  429. })
  430. })