session-history-journal.host.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /** Raw Session journal transport and message-aligned pagination coverage. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import AgentRegistry from '@deepseek-ai/dsh-agent'
  5. import SessionStore from '@deepseek-ai/dsh-session'
  6. import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  8. import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
  9. import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
  10. import { createSessionTestRemote } from './test-remote.ts'
  11. /** Append a production-shaped human prompt to the session surface. */
  12. function appendUserText(session: Session, text: string): SessionEvent {
  13. return session.append('user/message', createUserMessage({
  14. content: [{ type: 'text', text }], source: { kind: 'user' },
  15. }), { surfaceOp: 'append' })
  16. }
  17. /** Append a production-shaped assistant message to the session surface. */
  18. function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
  19. return session.append('assistant/message', {
  20. turn: 1,
  21. step,
  22. message: createMessage({
  23. role: 'assistant',
  24. content: [{ type: 'text', text }],
  25. source: { kind: 'model', provider: 'p', model: 'm' },
  26. }),
  27. }, { surfaceOp: 'append' })
  28. }
  29. /**
  30. * Append a plugin-owned log-only event. The host proxy is projection-only, so it
  31. * declares no compaction vocabulary; the cast writes the real event shape without
  32. * depending on the owning package.
  33. */
  34. function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
  35. return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
  36. }
  37. async function harness(): Promise<{ ctx: Context }> {
  38. const ctx = new Context()
  39. await ctx.plugin(SessionStore)
  40. await ctx.plugin(AgentRegistry)
  41. return { ctx }
  42. }
  43. /** Drain one Session follow until `count` event frames arrive. */
  44. async function collect(
  45. iterable: AsyncIterable<SessionFollowFrame>,
  46. count: number,
  47. abort: AbortController,
  48. ): Promise<SessionFollowFrame[]> {
  49. const frames: SessionFollowFrame[] = []
  50. for await (const frame of iterable) {
  51. frames.push(frame)
  52. if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort()
  53. }
  54. return frames
  55. }
  56. /** Open follow and wait until its cursor is fixed before appending fixtures. */
  57. async function openFollow(
  58. history: SessionHistoryController,
  59. sessionId: SessionId,
  60. signal: AbortSignal,
  61. ): Promise<AsyncIterable<SessionFollowFrame>> {
  62. const iterator = history.follow({
  63. address: { kind: 'session', sessionId },
  64. }, signal)[Symbol.asyncIterator]()
  65. await expect(iterator.next()).resolves.toMatchObject({
  66. done: false,
  67. value: { type: 'opened' },
  68. })
  69. return { [Symbol.asyncIterator]: () => iterator }
  70. }
  71. describe('Session history raw journal', () => {
  72. it('follows raw tool events and preserves result metadata without a Tools service', async () => {
  73. const { ctx } = await harness()
  74. const session = ctx.sessions.create()
  75. const history = new SessionHistoryController(ctx)
  76. const abort = new AbortController()
  77. const stream = await openFollow(history, session.id, abort.signal)
  78. const collected = collect(stream, 2, abort)
  79. const call = session.append('tool/call', {
  80. turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
  81. })
  82. const result = session.append('tool/result', {
  83. turn: 1, step: 1,
  84. message: createToolResultMessage({
  85. callId: CallId('raw-call'),
  86. content: [{ type: 'text', text: 'raw output' }],
  87. isError: false,
  88. }),
  89. meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
  90. }, { surfaceOp: 'append' })
  91. const frames = await collected
  92. expect(frames).toEqual([
  93. { type: 'event', event: call },
  94. { type: 'event', event: result },
  95. ])
  96. expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
  97. .toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
  98. })
  99. it('follows live results without rescanning Session history', async () => {
  100. const { ctx } = await harness()
  101. const session = ctx.sessions.create()
  102. const history = new SessionHistoryController(ctx)
  103. const abort = new AbortController()
  104. const stream = await openFollow(history, session.id, abort.signal)
  105. const iterator = stream[Symbol.asyncIterator]()
  106. session.append('tool/call', {
  107. turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
  108. })
  109. await expect(iterator.next()).resolves.toMatchObject({
  110. value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
  111. })
  112. const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
  113. throw new Error('live result rescanned Session history')
  114. })
  115. try {
  116. session.append('tool/result', {
  117. turn: 1, step: 1,
  118. message: createToolResultMessage({
  119. callId: CallId('live-fast'),
  120. content: [{ type: 'text', text: 'ok' }],
  121. isError: false,
  122. }),
  123. }, { surfaceOp: 'append' })
  124. await expect(iterator.next()).resolves.toMatchObject({
  125. value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
  126. })
  127. } finally {
  128. events.mockRestore()
  129. abort.abort()
  130. await iterator.next()
  131. await ctx.fiber.dispose()
  132. }
  133. })
  134. it('serves raw call and result entries without parsing tool arguments', async () => {
  135. const { ctx } = await harness()
  136. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  137. const session = ctx.sessions.create()
  138. const start = session.append('turn/start', { turn: 1 })
  139. const call = session.append('tool/call', {
  140. turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
  141. })
  142. const result = session.append('tool/result', {
  143. turn: 1, step: 1,
  144. message: createToolResultMessage({
  145. callId: CallId('history-call'),
  146. content: [{ type: 'text', text: 'failed raw output' }],
  147. isError: true,
  148. }),
  149. meta: { persisted: true, count: 3 },
  150. }, { surfaceOp: 'append' })
  151. const response = await remote.page({
  152. address: { kind: 'session', sessionId: session.id },
  153. throughSeq: session.seq - 1,
  154. })
  155. expect(response.ok).toBe(true)
  156. if (!response.ok) throw new Error('unreachable')
  157. expect(response.value.events).toEqual([
  158. { event: start },
  159. { event: call },
  160. { event: result },
  161. ])
  162. })
  163. it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
  164. const { ctx } = await harness()
  165. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  166. const session = ctx.sessions.create()
  167. session.append('turn/start', { turn: 1 })
  168. const first = appendUserText(session, 'first prompt')
  169. appendAssistantText(session, 'first reply', 1)
  170. const third = appendUserText(session, 'second prompt')
  171. appendAssistantText(session, 'second reply', 2)
  172. const shadowed = [...session.surface.nodes]
  173. // A compaction transaction: a log-only summary record immediately followed by the
  174. // replacement that shadows the range.
  175. const summary = appendExtension(session, 'compaction/summary', {
  176. summary: [{ type: 'text', text: 'summary' }],
  177. shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
  178. shadowedSeqs: shadowed,
  179. shadowedTokenCount: 0,
  180. provider: 'p',
  181. model: 'm',
  182. })
  183. session.append('user/message', createUserMessage({
  184. content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
  185. source: { kind: 'plugin', plugin: 'compact' },
  186. }), {
  187. surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
  188. sourceEventSeqs: [...shadowed, summary.seq],
  189. })
  190. const response = await remote.page({
  191. address: { kind: 'session', sessionId: session.id },
  192. throughSeq: session.seq - 1,
  193. maxMessages: 2,
  194. })
  195. if (!response.ok) throw new Error('unreachable')
  196. const page = response.value.events.map(entry => entry.event)
  197. // Two append-origin messages fill the page even though a replacement copy of
  198. // the same event type sits in the window: the copy is model-only.
  199. const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
  200. expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
  201. expect(page.some(event => event.seq === first.seq)).toBe(false)
  202. expect(response.value.hasMore).toBe(true)
  203. // The range stays contiguous, so the checkpoint's summary record is readable on
  204. // the same page as the checkpoint itself.
  205. const summaryIndex = page.findIndex(event => event.seq === summary.seq)
  206. expect(summaryIndex).toBeGreaterThan(-1)
  207. expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
  208. expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
  209. })
  210. it('paginates a message with many provenance sources without variadic argument expansion', async () => {
  211. const { ctx } = await harness()
  212. const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  213. const session = ctx.sessions.create()
  214. session.append('turn/start', { turn: 1 })
  215. const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
  216. turn: 1,
  217. step: 1,
  218. chunk: { type: 'text-delta', index, text: 'x' },
  219. }).seq)
  220. const message = session.append('assistant/message', {
  221. turn: 1,
  222. step: 1,
  223. message: createMessage({
  224. role: 'assistant',
  225. content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
  226. source: { kind: 'model', provider: 'p', model: 'm' },
  227. }),
  228. }, { surfaceOp: 'append', sourceEventSeqs: sources })
  229. const scalarMin = Math.min
  230. const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
  231. if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness')
  232. return scalarMin(...values)
  233. })
  234. try {
  235. const response = await remote.page({
  236. address: { kind: 'session', sessionId: session.id },
  237. throughSeq: message.seq,
  238. maxMessages: 1,
  239. })
  240. if (!response.ok) throw new Error('unreachable')
  241. expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
  242. expect(response.value.hasMore).toBe(true)
  243. } finally {
  244. min.mockRestore()
  245. }
  246. })
  247. it('follows a result after turn/end without reading the addressed Session log', async () => {
  248. const { ctx } = await harness()
  249. const session = ctx.sessions.create()
  250. const history = new SessionHistoryController(ctx)
  251. const abort = new AbortController()
  252. const stream = await openFollow(history, session.id, abort.signal)
  253. const iterator = stream[Symbol.asyncIterator]()
  254. session.append('turn/start', { turn: 1 })
  255. await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
  256. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
  257. await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
  258. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  259. await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
  260. const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
  261. throw new Error('live result rescanned Session history')
  262. })
  263. try {
  264. const result = session.append('tool/result', {
  265. turn: 1, step: 1,
  266. message: createToolResultMessage({
  267. callId: CallId('c-late'),
  268. content: [{ type: 'text', text: 'ok' }],
  269. isError: false,
  270. }),
  271. }, { surfaceOp: 'append' })
  272. await expect(iterator.next()).resolves.toEqual({
  273. done: false,
  274. value: { type: 'event', event: result },
  275. })
  276. } finally {
  277. events.mockRestore()
  278. abort.abort()
  279. await iterator.next()
  280. await ctx.fiber.dispose()
  281. }
  282. })
  283. })