session-checkpoint-policy.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  5. import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  8. import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  11. import * as checkpointPolicy from '../src/index.ts'
  12. const contexts: Context[] = []
  13. class TestPersistence extends SessionPersistence {
  14. locate(_meta: SessionHeader): undefined { return undefined }
  15. create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
  16. append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
  17. load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
  18. return Promise.reject(new Error('not used'))
  19. }
  20. inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
  21. return Promise.reject(new Error('not used'))
  22. }
  23. list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
  24. listSnapshots(): Promise<never[]> { return Promise.resolve([]) }
  25. }
  26. class RecordingAdapter extends LlmAdapter {
  27. constructor(private readonly order: string[]) { super() }
  28. async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  29. this.order.push('adapter')
  30. yield { type: 'finish', reason: { kind: 'stop' } }
  31. }
  32. }
  33. async function setup(): Promise<Context> {
  34. const ctx = new Context()
  35. contexts.push(ctx)
  36. await ctx.plugin(SessionStore)
  37. await ctx.plugin(LlmService)
  38. await ctx.plugin(SystemPrompt)
  39. await ctx.plugin(ToolRegistry)
  40. await ctx.plugin(TestPersistence)
  41. await ctx.plugin(checkpointPolicy)
  42. return ctx
  43. }
  44. async function drain(stream: AsyncIterable<StreamChunk>): Promise<void> {
  45. for await (const _chunk of stream) { /* drain */ }
  46. }
  47. afterEach(async () => {
  48. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  49. })
  50. describe('session-checkpoint-policy request boundary', () => {
  51. it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
  52. const ctx = await setup()
  53. const session = ctx.sessions.create(SessionId('request-checkpoint'))
  54. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  55. const gate = Promise.withResolvers<undefined>()
  56. const order: string[] = []
  57. ctx.on('session/flush', async () => {
  58. order.push('flush:start')
  59. await gate.promise
  60. order.push('flush:end')
  61. })
  62. ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
  63. const pending = drain(ctx.llm.stream({
  64. provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
  65. }))
  66. await Promise.resolve()
  67. expect(order).toEqual(['flush:start'])
  68. gate.resolve(undefined)
  69. await pending
  70. expect(order).toEqual(['flush:start', 'flush:end', 'adapter'])
  71. })
  72. it('delegates a request without a live session without checkpointing', async () => {
  73. const ctx = await setup()
  74. const order: string[] = []
  75. ctx.on('session/flush', () => { order.push('flush') })
  76. ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
  77. await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] }))
  78. expect(order).toEqual(['adapter'])
  79. })
  80. it('delegates an already-detached session id without checkpointing', async () => {
  81. const ctx = await setup()
  82. const order: string[] = []
  83. ctx.on('session/flush', () => { order.push('flush') })
  84. ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
  85. await drain(ctx.llm.stream({
  86. provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'),
  87. }))
  88. expect(order).toEqual(['adapter'])
  89. })
  90. it('does not dispatch the adapter when the checkpoint rejects', async () => {
  91. const ctx = await setup()
  92. const session = ctx.sessions.create(SessionId('request-failure'))
  93. const order: string[] = []
  94. ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
  95. ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
  96. await expect(drain(ctx.llm.stream({
  97. provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
  98. }))).rejects.toThrow('disk unavailable')
  99. expect(order).toEqual([])
  100. })
  101. })
  102. describe('session-checkpoint-policy tool and step boundaries', () => {
  103. it('awaits the checkpoint before a top-level tool body', async () => {
  104. const ctx = await setup()
  105. const session = ctx.sessions.create(SessionId('tool-checkpoint'))
  106. const agent = { session } as Agent
  107. const gate = Promise.withResolvers<undefined>()
  108. const order: string[] = []
  109. ctx.on('session/flush', async () => {
  110. order.push('flush:start')
  111. await gate.promise
  112. order.push('flush:end')
  113. })
  114. ctx.tools.register({
  115. name: 'write', description: 'side effect', parameters: {},
  116. output: { schema: { type: 'null' }, render: () => [] },
  117. execute: async () => { order.push('tool'); return null },
  118. })
  119. const pending = ctx.tools.execute({
  120. callId: CallId('write-1'), name: 'write', arguments: {}, agent,
  121. signal: new AbortController().signal,
  122. })
  123. await Promise.resolve()
  124. expect(order).toEqual(['flush:start'])
  125. gate.resolve(undefined)
  126. await expect(pending).resolves.toMatchObject({ isError: false })
  127. expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
  128. })
  129. it('does not dispatch when cancellation lands during the tool checkpoint', async () => {
  130. const ctx = await setup()
  131. const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel'))
  132. const agent = { session } as Agent
  133. const controller = new AbortController()
  134. const gate = Promise.withResolvers<undefined>()
  135. const order: string[] = []
  136. ctx.on('session/flush', async () => {
  137. order.push('flush:start')
  138. await gate.promise
  139. order.push('flush:end')
  140. })
  141. ctx.tools.register({
  142. name: 'write', description: 'side effect', parameters: {},
  143. output: { schema: { type: 'null' }, render: () => [] },
  144. execute: async () => { order.push('tool'); return null },
  145. })
  146. const pending = ctx.tools.execute({
  147. callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent,
  148. signal: controller.signal,
  149. })
  150. await Promise.resolve()
  151. expect(order).toEqual(['flush:start'])
  152. controller.abort('cancelled during checkpoint')
  153. gate.resolve(undefined)
  154. await expect(pending).resolves.toEqual({
  155. content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
  156. isError: true,
  157. error: {
  158. message: 'tool call aborted before dispatch',
  159. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  160. },
  161. })
  162. expect(order).toEqual(['flush:start', 'flush:end'])
  163. })
  164. it('turns a rejected checkpoint into an error result without running the tool body', async () => {
  165. const ctx = await setup()
  166. const session = ctx.sessions.create(SessionId('tool-failure'))
  167. const agent = { session } as Agent
  168. let ran = false
  169. ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
  170. ctx.tools.register({
  171. name: 'write', description: 'side effect', parameters: {},
  172. output: { schema: { type: 'null' }, render: () => [] },
  173. execute: async () => { ran = true; return null },
  174. })
  175. const result = await ctx.tools.execute({
  176. callId: CallId('write-2'), name: 'write', arguments: {}, agent,
  177. signal: new AbortController().signal,
  178. })
  179. expect(result.isError).toBe(true)
  180. expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }])
  181. expect(ran).toBe(false)
  182. })
  183. it('reuses the outer checkpoint for a nested tool dispatch', async () => {
  184. const ctx = await setup()
  185. const session = ctx.sessions.create(SessionId('nested-tool'))
  186. const agent = { session } as Agent
  187. let flushes = 0
  188. ctx.on('session/flush', () => { flushes += 1 })
  189. ctx.tools.register({
  190. name: 'nested', description: 'nested', parameters: {},
  191. output: { schema: { type: 'null' }, render: () => [] },
  192. execute: async () => null,
  193. })
  194. await ctx.tools.execute({
  195. callId: CallId('nested-1'), name: 'nested', arguments: {}, agent,
  196. parent: Symbol('outer') as never,
  197. signal: new AbortController().signal,
  198. })
  199. expect(flushes).toBe(0)
  200. })
  201. it('checkpoints the complete recorded step at agent/post-step', async () => {
  202. const ctx = await setup()
  203. const session = ctx.sessions.create(SessionId('post-step'))
  204. const agent = { session } as Agent
  205. const flushed: string[] = []
  206. ctx.on('session/flush', (current) => { flushed.push(current.id) })
  207. await agentEvents(ctx, agent).serial(
  208. 'agent/post-step', 1, 1, new AbortController().signal,
  209. )
  210. expect(flushed).toEqual([session.id])
  211. })
  212. })
  213. describe('session-checkpoint-policy lifecycle', () => {
  214. it('removes its wrappers when the owning fiber is disposed', async () => {
  215. const ctx = new Context()
  216. contexts.push(ctx)
  217. await ctx.plugin(SessionStore)
  218. await ctx.plugin(LlmService)
  219. await ctx.plugin(SystemPrompt)
  220. await ctx.plugin(ToolRegistry)
  221. await ctx.plugin(TestPersistence)
  222. const session = ctx.sessions.create(SessionId('disposed-policy'))
  223. let flushes = 0
  224. ctx.on('session/flush', () => { flushes += 1 })
  225. ctx.llm.registerAdapter(['mock'], new RecordingAdapter([]))
  226. const fiber = await ctx.plugin(checkpointPolicy)
  227. await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
  228. expect(flushes).toBe(1)
  229. await fiber.dispose()
  230. await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
  231. expect(flushes).toBe(1)
  232. })
  233. it('keeps the Loader-safe namespace plugin shape', () => {
  234. expect('default' in checkpointPolicy).toBe(false)
  235. const loader = Object.create(Loader.prototype) as Loader
  236. const unwrapped = loader.unwrapExports(checkpointPolicy) as Record<string, unknown>
  237. expect(unwrapped).toBe(checkpointPolicy)
  238. expect(unwrapped.name).toBe('session-checkpoint-policy')
  239. expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools'])
  240. expect(typeof unwrapped.apply).toBe('function')
  241. })
  242. })