resume.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { mkdtemp, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import LlmService from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry from '@deepseek-ai/dsh-tools'
  11. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  12. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  13. import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  14. import { MockAdapter, textResponse } from './mock-adapter.ts'
  15. const dirs: string[] = []
  16. afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
  17. async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
  18. const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
  19. dirs.push(root)
  20. const ctx = new Context()
  21. await ctx.plugin(LlmService)
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(SystemPrompt)
  24. await ctx.plugin(ToolRegistry)
  25. await ctx.plugin(AgentRegistry)
  26. await ctx.plugin(AgentLoop, { agents: [] })
  27. await ctx.plugin(SessionPersistenceJsonl, { root })
  28. ctx.llm.registerAdapter(['mock'], adapter)
  29. return { ctx, root }
  30. }
  31. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  32. return new Promise((resolve) => {
  33. const dispose = ctx.on('agent/status', (subject, status) => {
  34. if (subject === agent && status === 'idle') { dispose(); resolve() }
  35. })
  36. })
  37. }
  38. describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
  39. it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
  40. const adapter = new MockAdapter([textResponse('hi')])
  41. const { ctx } = await persistentHarness(adapter)
  42. const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
  43. expect(agent.session.id).toBe('custom-session')
  44. expect(agent.session.header.cwd).toBe('/w')
  45. await ctx.fiber.dispose()
  46. })
  47. it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
  48. const adapter = new MockAdapter([textResponse('hi')])
  49. const { ctx } = await persistentHarness(adapter)
  50. ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
  51. // A second create with the SAME agent id but a fresh session id must reject
  52. // up front — and must NOT leave an orphaned 'sess-b' session behind.
  53. expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
  54. expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
  55. await ctx.fiber.dispose()
  56. })
  57. it('createAgent works without meta (no cwd)', async () => {
  58. const adapter = new MockAdapter([textResponse('hi')])
  59. const { ctx } = await persistentHarness(adapter)
  60. const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
  61. expect(agent.session.id).toBe('nometa-session')
  62. expect(agent.session.header.cwd).toBeUndefined()
  63. await ctx.fiber.dispose()
  64. })
  65. it('resume of a session with no cwd carries an undefined cwd header', async () => {
  66. // Lifecycle 1: create a no-cwd session and run a turn.
  67. const adapter1 = new MockAdapter([textResponse('a')])
  68. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  69. const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
  70. a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
  71. await waitForIdle(ctx1, a1)
  72. await ctx1.fiber.dispose()
  73. // Lifecycle 2: resume it; the header cwd stays undefined (no-cwd branch).
  74. const adapter2 = new MockAdapter([textResponse('b')])
  75. const ctx2 = new Context()
  76. await ctx2.plugin(LlmService)
  77. await ctx2.plugin(SessionStore)
  78. await ctx2.plugin(SystemPrompt)
  79. await ctx2.plugin(ToolRegistry)
  80. await ctx2.plugin(AgentRegistry)
  81. await ctx2.plugin(AgentLoop, { agents: [] })
  82. await ctx2.plugin(SessionPersistenceJsonl, { root })
  83. ctx2.llm.registerAdapter(['mock'], adapter2)
  84. const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
  85. expect(a2.session.header.cwd).toBeUndefined()
  86. await ctx2.fiber.dispose()
  87. })
  88. it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
  89. // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
  90. const adapter1 = new MockAdapter([textResponse('a')])
  91. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  92. const sources1: string[] = []
  93. ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
  94. const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
  95. expect(sources1).toEqual(['startup'])
  96. a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
  97. await waitForIdle(ctx1, a1)
  98. await ctx1.fiber.dispose()
  99. // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
  100. const adapter2 = new MockAdapter([textResponse('b')])
  101. const ctx2 = new Context()
  102. await ctx2.plugin(LlmService)
  103. await ctx2.plugin(SessionStore)
  104. await ctx2.plugin(SystemPrompt)
  105. await ctx2.plugin(ToolRegistry)
  106. await ctx2.plugin(AgentRegistry)
  107. await ctx2.plugin(AgentLoop, { agents: [] })
  108. await ctx2.plugin(SessionPersistenceJsonl, { root })
  109. ctx2.llm.registerAdapter(['mock'], adapter2)
  110. const sources2: string[] = []
  111. ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
  112. await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
  113. expect(sources2).toEqual(['resume'])
  114. await ctx2.fiber.dispose()
  115. })
  116. it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
  117. // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
  118. // in its header) by creating it with a complete-turn seed — the write path
  119. // materializes the fork (header + seed) on disk.
  120. const seed: SessionEvent[] = [
  121. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  122. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  123. ]
  124. const adapter1 = new MockAdapter([textResponse('a')])
  125. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  126. const forked = ctx1.sessions.create(SessionId('forked-sess'), {
  127. seed,
  128. meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
  129. })
  130. await ctx1.parallel('session/flush', forked)
  131. await ctx1.fiber.dispose()
  132. // Lifecycle 2: resume it; the parentSession + seedLength header survives the
  133. // round-trip (exercises resume's parentSession- and seedLength-present
  134. // branches). seedLength must come from the PERSISTED header, not from the
  135. // resume seed length (which is the whole stored log, not the original
  136. // boundary).
  137. const adapter2 = new MockAdapter([textResponse('b')])
  138. const ctx2 = new Context()
  139. await ctx2.plugin(LlmService)
  140. await ctx2.plugin(SessionStore)
  141. await ctx2.plugin(SystemPrompt)
  142. await ctx2.plugin(ToolRegistry)
  143. await ctx2.plugin(AgentRegistry)
  144. await ctx2.plugin(AgentLoop, { agents: [] })
  145. await ctx2.plugin(SessionPersistenceJsonl, { root })
  146. ctx2.llm.registerAdapter(['mock'], adapter2)
  147. const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
  148. expect(a2.session.header.parentSession).toBe('parent-sess')
  149. expect(a2.session.header.cwd).toBe('/w')
  150. expect(a2.session.header.seedLength).toBe(seed.length)
  151. await ctx2.fiber.dispose()
  152. })
  153. it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
  154. // Lifecycle 1: run a turn, then inject context while idle. The idle inject
  155. // wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
  156. // — without an explicit flush or clean dispose, the notice must still reach
  157. // disk, since a crash before the next turn would otherwise lose it.
  158. const adapter1 = new MockAdapter([textResponse('answer')])
  159. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  160. const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
  161. a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
  162. await waitForIdle(ctx1, a1)
  163. a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
  164. // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
  165. await new Promise(r => setTimeout(r, 30))
  166. // A SEPARATE backend reads the on-disk log — proving the inject persisted
  167. // itself, not a later dispose drain.
  168. const probe = new Context()
  169. await probe.plugin(SessionStore)
  170. await probe.plugin(SessionPersistenceJsonl, { root })
  171. const loaded = await probe.sessionPersistence.load(SessionId('inject-sess'))
  172. expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
  173. await probe.fiber.dispose()
  174. await ctx1.fiber.dispose()
  175. })
  176. it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => {
  177. // Lifecycle 1: run a turn, then inject context while idle. The idle inject
  178. // wraps its context/message in a one-shot turn so it is turn-enclosed —
  179. // otherwise scanLog would treat the trailing context as a crash tail and
  180. // drop it on reload (the bug this guards).
  181. const adapter1 = new MockAdapter([textResponse('answer')])
  182. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  183. const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
  184. a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
  185. await waitForIdle(ctx1, a1)
  186. a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
  187. await ctx1.parallel('session/flush', a1.session)
  188. await ctx1.fiber.dispose()
  189. // Lifecycle 2: resume; the injected context is still in the derived history.
  190. const adapter2 = new MockAdapter([textResponse('next')])
  191. const ctx2 = new Context()
  192. await ctx2.plugin(LlmService)
  193. await ctx2.plugin(SessionStore)
  194. await ctx2.plugin(SystemPrompt)
  195. await ctx2.plugin(ToolRegistry)
  196. await ctx2.plugin(AgentRegistry)
  197. await ctx2.plugin(AgentLoop, { agents: [] })
  198. await ctx2.plugin(SessionPersistenceJsonl, { root })
  199. ctx2.llm.registerAdapter(['mock'], adapter2)
  200. const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
  201. const flat = JSON.stringify(a2.session.deriveMessages())
  202. expect(flat).toContain('background task 42 finished')
  203. await ctx2.fiber.dispose()
  204. })
  205. it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
  206. // Lifecycle 1: run one full turn, persisting it.
  207. const adapter1 = new MockAdapter([textResponse('first answer')])
  208. const { ctx: ctx1, root } = await persistentHarness(adapter1)
  209. const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
  210. a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
  211. await waitForIdle(ctx1, a1)
  212. const events1 = [...a1.session.events]
  213. const seqs1 = events1.map(e => e.seq)
  214. expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous
  215. await ctx1.fiber.dispose()
  216. // Lifecycle 2: a brand-new context over the SAME root; resume the session.
  217. const adapter2 = new MockAdapter([textResponse('second answer')])
  218. const ctx2 = new Context()
  219. await ctx2.plugin(LlmService)
  220. await ctx2.plugin(SessionStore)
  221. await ctx2.plugin(SystemPrompt)
  222. await ctx2.plugin(ToolRegistry)
  223. await ctx2.plugin(AgentRegistry)
  224. await ctx2.plugin(AgentLoop, { agents: [] })
  225. await ctx2.plugin(SessionPersistenceJsonl, { root })
  226. ctx2.llm.registerAdapter(['mock'], adapter2)
  227. const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
  228. // The resumed session carries the prior history…
  229. expect(a2.session.id).toBe('sess-resume')
  230. expect(a2.session.events.length).toBe(events1.length)
  231. const replay = new Session(SessionId('replay'), events1)
  232. expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
  233. // …and a new turn continues numbering (turn 2) with contiguous seqs.
  234. a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
  235. await waitForIdle(ctx2, a2)
  236. const allSeqs = a2.session.events.map(e => e.seq)
  237. expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates
  238. const turnStarts = a2.session.events.filter(e => e.type === 'turn/start')
  239. expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2])
  240. await ctx2.fiber.dispose()
  241. })
  242. it('resume rejects when session persistence is not configured', async () => {
  243. // A harness WITHOUT the persistence plugin.
  244. const adapter = new MockAdapter([textResponse('x')])
  245. const ctx = new Context()
  246. await ctx.plugin(LlmService)
  247. await ctx.plugin(SessionStore)
  248. await ctx.plugin(SystemPrompt)
  249. await ctx.plugin(ToolRegistry)
  250. await ctx.plugin(AgentRegistry)
  251. await ctx.plugin(AgentLoop, { agents: [] })
  252. ctx.llm.registerAdapter(['mock'], adapter)
  253. await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
  254. .rejects.toThrow(/session persistence is not configured/)
  255. await ctx.fiber.dispose()
  256. })
  257. })