fork.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
  5. import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
  6. declare module '@deepseek-ai/dsh-session/types' {
  7. interface SessionEventMap {
  8. 'test/log-only': { value: string }
  9. /** Stands in for a plugin's open/close bracket (`compaction/start`). */
  10. 'test/bracket-open': { id: string }
  11. }
  12. }
  13. async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
  14. const ctx = new Context()
  15. await ctx.plugin(SessionStore)
  16. return { ctx, sessions: ctx.sessions }
  17. }
  18. function appendClosedTurn(
  19. session: Session,
  20. turn: number,
  21. text = `hello ${turn}`,
  22. reason: TurnEndReason = { kind: 'completed' },
  23. ): void {
  24. session.append('turn/start', { turn })
  25. session.append('user/message', createUserMessage({
  26. content: [{ type: 'text', text }],
  27. source: { kind: 'user' },
  28. }), { surfaceOp: 'append' })
  29. session.append('turn/end', { turn, reason })
  30. }
  31. function appendOpenTurn(session: Session, turn: number): void {
  32. session.append('turn/start', { turn })
  33. session.append('user/message', createUserMessage({
  34. content: [{ type: 'text', text: `open ${turn}` }],
  35. source: { kind: 'user' },
  36. }), { surfaceOp: 'append' })
  37. }
  38. function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
  39. const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message')
  40. if (event === undefined) throw new Error('missing user/message')
  41. return event
  42. }
  43. function lastSeq(session: Session): number {
  44. const event = session.events.at(-1)
  45. if (event === undefined) throw new Error('missing last event')
  46. return event.seq
  47. }
  48. /** A seeded child's constructor seed: its log minus the end-seed marker. */
  49. function inherited(session: Session): readonly SessionEvent[] {
  50. const events = session.events
  51. const last = events.at(-1)
  52. if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
  53. return events.slice(0, -1)
  54. }
  55. describe('SessionStore.fork', () => {
  56. it('forks an empty live session as an empty child with lineage metadata', async () => {
  57. const { ctx, sessions } = await setup()
  58. const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
  59. const child = sessions.fork(source, undefined, SessionId('empty-child'))
  60. expect(inherited(child)).toEqual([])
  61. expect(child.header).toMatchObject({
  62. id: SessionId('empty-child'),
  63. cwd: '/workspace',
  64. parentSession: SessionId('empty-parent'),
  65. seedLength: 0,
  66. })
  67. })
  68. it('forks the latest completed boundary by default into detached frozen seed events', async () => {
  69. const { ctx, sessions } = await setup()
  70. const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
  71. appendClosedTurn(source, 1, 'hello')
  72. const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
  73. expect(inherited(child)).toEqual(source.events)
  74. expect(child.events).not.toBe(source.events)
  75. expect(child.events[1]).not.toBe(source.events[1])
  76. expect(() => {
  77. firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
  78. }).toThrow(TypeError)
  79. expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
  80. expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
  81. expect(child.header).toMatchObject({
  82. id: SessionId('child'),
  83. cwd: '/workspace',
  84. parentSession: SessionId('parent'),
  85. seedLength: source.events.length,
  86. })
  87. })
  88. it('includes stable log-only events appended after a closed turn', async () => {
  89. const { ctx, sessions } = await setup()
  90. const source = ctx.sessions.create(SessionId('log-only-parent'))
  91. appendClosedTurn(source, 1, 'hello')
  92. source.append('test/log-only', { value: 'after execution' })
  93. const child = sessions.fork(source, undefined, SessionId('log-only-child'))
  94. expect(inherited(child)).toEqual(source.events)
  95. expect(inherited(child).at(-1)).toMatchObject({
  96. type: 'test/log-only',
  97. data: { value: 'after execution' },
  98. })
  99. })
  100. it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
  101. const { ctx, sessions } = await setup()
  102. const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
  103. appendClosedTurn(source, 1, 'first')
  104. const firstBoundary = lastSeq(source)
  105. appendClosedTurn(source, 2, 'second')
  106. appendOpenTurn(source, 3)
  107. const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
  108. expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
  109. expect(child.header.seedLength).toBe(firstBoundary + 1)
  110. expect(child.deriveMessages()).toEqual([{
  111. id: expect.any(String) as unknown,
  112. role: 'user',
  113. content: [{ type: 'text', text: 'first' }],
  114. source: { kind: 'user' },
  115. }])
  116. })
  117. it('accepts every turn/end reason as an explicit fork boundary', async () => {
  118. const { ctx, sessions } = await setup()
  119. const reasons: TurnEndReason[] = [
  120. { kind: 'completed' },
  121. { kind: 'aborted', reason: { kind: 'user' } },
  122. { kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } },
  123. { kind: 'aborted', reason: { kind: 'disposed' } },
  124. { kind: 'max-tokens' },
  125. { kind: 'interrupted' },
  126. ]
  127. for (const [index, reason] of reasons.entries()) {
  128. const source = ctx.sessions.create(SessionId(`parent-${index}`))
  129. appendClosedTurn(source, 1, reason.kind, reason)
  130. const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`))
  131. expect(inherited(child).at(-1)?.type).toBe('turn/end')
  132. expect(child.header.seedLength).toBe(source.events.length)
  133. }
  134. })
  135. it('marks a bracket the child inherited from a still-running parent', async () => {
  136. // The constructor placement's central claim, unreachable from the
  137. // persistence load path.
  138. const { ctx, sessions } = await setup()
  139. const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } })
  140. appendClosedTurn(parent, 1, 'work')
  141. const open = parent.append('test/bracket-open', { id: 'op-1' })
  142. const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
  143. // Parent: no end-seed event follows the bracket, so its owner treats it as live.
  144. expect(parent.events.at(-1)).toBe(open)
  145. expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
  146. // Child: the same bracket is before end-seed, so it belongs to the seed.
  147. const boundary = child.events.at(-1)
  148. expect(boundary).toMatchObject({ type: 'session/end-seed' })
  149. expect(boundary!.seq).toBeGreaterThan(open.seq)
  150. expect(child.firstLiveSeq).toBe(open.seq + 1)
  151. expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
  152. })
  153. it('rejects invalid boundaries before creating a child', async () => {
  154. const { ctx, sessions } = await setup()
  155. const empty = ctx.sessions.create(SessionId('empty'))
  156. expect(() => sessions.fork(empty, 0, SessionId('empty-child')))
  157. .toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY'))
  158. expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined()
  159. const source = ctx.sessions.create(SessionId('parent'))
  160. appendClosedTurn(source, 1)
  161. expect(() => sessions.fork(source, -1, SessionId('negative')))
  162. .toThrow(/non-negative safe integer/)
  163. expect(() => sessions.fork(source, 0.5, SessionId('fraction')))
  164. .toThrow(/non-negative safe integer/)
  165. expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe')))
  166. .toThrow(/non-negative safe integer/)
  167. expect(() => sessions.fork(source, source.seq, SessionId('past-end')))
  168. .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY'))
  169. })
  170. it('rejects a corrupted live source whose array index no longer matches event seq', async () => {
  171. const { ctx, sessions } = await setup()
  172. const source = ctx.sessions.create(SessionId('corrupt-parent'))
  173. appendClosedTurn(source, 1)
  174. const mutableLog = (source as unknown as { log: SessionEvent[] }).log
  175. mutableLog[2] = { ...mutableLog[2]!, seq: 99 }
  176. expect(() => sessions.fork(source, 2, SessionId('corrupt-child')))
  177. .toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY'))
  178. expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined()
  179. })
  180. it('rejects an unknown live session id', async () => {
  181. const { sessions } = await setup()
  182. expect(() => sessions.fork(SessionId('missing')))
  183. .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
  184. })
  185. it('rejects a detached Session object that is not live in ctx.sessions', async () => {
  186. const { sessions } = await setup()
  187. const detached = Session.create(SessionId('detached'))
  188. expect(() => sessions.fork(detached))
  189. .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
  190. })
  191. it('rejects a stale Session object whose id is live on a different instance', async () => {
  192. const { ctx, sessions } = await setup()
  193. ctx.sessions.create(SessionId('same-id'))
  194. const stale = Session.create(SessionId('same-id'))
  195. expect(() => sessions.fork(stale))
  196. .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
  197. })
  198. it('rejects selected slices whose boundary is inside an open turn', async () => {
  199. const { ctx, sessions } = await setup()
  200. const cases: [string, (session: Session) => number][] = [
  201. ['turn/start', (session) => {
  202. session.append('turn/start', { turn: 1 })
  203. return lastSeq(session)
  204. }],
  205. ['step/start', (session) => {
  206. session.append('turn/start', { turn: 1 })
  207. session.append('step/start', { turn: 1, step: 1 })
  208. return lastSeq(session)
  209. }],
  210. ['user/message', (session) => {
  211. session.append('turn/start', { turn: 1 })
  212. session.append('user/message', createUserMessage({
  213. content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
  214. }), { surfaceOp: 'append' })
  215. return lastSeq(session)
  216. }],
  217. ['assistant/message', (session) => {
  218. session.append('turn/start', { turn: 1 })
  219. session.append('step/start', { turn: 1, step: 1 })
  220. session.append('assistant/message', {
  221. turn: 1, step: 1,
  222. message: createMessage({
  223. role: 'assistant',
  224. content: [{ type: 'text', text: 'partial' }],
  225. source: {
  226. kind: 'model',
  227. ...{ provider: 'mock', model: 'mock' },
  228. },
  229. }),
  230. }, { surfaceOp: 'append' })
  231. return lastSeq(session)
  232. }],
  233. ['tool/call', (session) => {
  234. const callId = CallId('call-open')
  235. session.append('turn/start', { turn: 1 })
  236. session.append('step/start', { turn: 1, step: 1 })
  237. session.append('assistant/message', {
  238. turn: 1,
  239. step: 1,
  240. message: createMessage({
  241. role: 'assistant',
  242. content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
  243. source: {
  244. kind: 'model',
  245. ...{ provider: 'mock', model: 'mock' },
  246. },
  247. }),
  248. }, { surfaceOp: 'append' })
  249. session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
  250. return lastSeq(session)
  251. }],
  252. ]
  253. for (const [lastType, build] of cases) {
  254. const source = ctx.sessions.create(SessionId(`open-${lastType}`))
  255. const boundary = build(source)
  256. expect(() => sessions.fork(source, boundary))
  257. .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN'))
  258. }
  259. })
  260. it('rejects a child session id that is already live with a typed fork error', async () => {
  261. const { ctx, sessions } = await setup()
  262. const source = ctx.sessions.create(SessionId('parent'))
  263. appendClosedTurn(source, 1)
  264. ctx.sessions.create(SessionId('child'))
  265. expect(() => sessions.fork(source, undefined, SessionId('child')))
  266. .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
  267. })
  268. it('rejects a duplicate child session id before validating the boundary', async () => {
  269. const { ctx, sessions } = await setup()
  270. const source = ctx.sessions.create(SessionId('open-parent'))
  271. source.append('turn/start', { turn: 1 })
  272. ctx.sessions.create(SessionId('child'))
  273. expect(() => sessions.fork(source, undefined, SessionId('child')))
  274. .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
  275. })
  276. })