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

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