fork.spec.ts 12 KB

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