chat-scroll-fixture.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. // Synthetic long-chat history for browser behavior contracts. The fixture is
  2. // generated through Session so pagination exercises the same event shapes as
  3. // persisted conversations, while unique markers let tests identify semantic
  4. // rows without depending on CSS-module names or the eventual virtualizer DOM.
  5. import {
  6. CallId,
  7. createAssistantMessage,
  8. createToolResultMessage,
  9. createUserMessage,
  10. } from '@deepseek-ai/dsh-llm'
  11. import {
  12. SESSION_FORMAT_VERSION,
  13. Session,
  14. SessionId,
  15. } from '@deepseek-ai/dsh-session'
  16. // Carries the session/title event declaration into this fixture builder.
  17. import type {} from '@deepseek-ai/dsh-session-title'
  18. /** Options for one deterministic long-chat fixture. */
  19. export interface ChatScrollFixtureOptions {
  20. /** Marker namespace, used when two sessions share one browser world. */
  21. readonly markerPrefix: string
  22. /** Searchable title projected into the sidebar. */
  23. readonly title: string
  24. /** Number of closed turns to generate. */
  25. readonly turns?: number
  26. }
  27. /** Semantic marker helpers returned with a generated fixture. */
  28. interface ChatScrollMarkers {
  29. /** Marker painted in the human message for a turn. */
  30. user(turn: number): string
  31. /** Marker painted in the final assistant message for a turn. */
  32. assistant(turn: number): string
  33. /** Marker painted in one seeded bash call and result. */
  34. tool(turn: number, index: number): string
  35. }
  36. /** Generated JSONL plus the stable facts browser scenarios assert. */
  37. export interface ChatScrollFixture {
  38. readonly log: string
  39. readonly markers: ChatScrollMarkers
  40. readonly title: string
  41. readonly turns: number
  42. }
  43. const DEFAULT_TURNS = 88
  44. const TOOL_INTERVAL = 8
  45. const CODE_INTERVAL = 11
  46. function text(value: string): { type: 'text'; text: string }[] {
  47. return [{ type: 'text', text: value }]
  48. }
  49. function suffix(turn: number): string {
  50. return String(turn).padStart(3, '0')
  51. }
  52. function markerHelpers(prefix: string): ChatScrollMarkers {
  53. return {
  54. user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
  55. assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
  56. tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
  57. }
  58. }
  59. function appendRequestHeader(session: Session, turn: number, step: number): void {
  60. session.append('request/header', {
  61. header: {
  62. config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  63. system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
  64. },
  65. reason: turn === 1 && step === 1 ? 'initial' : 'change',
  66. })
  67. }
  68. function appendAssistant(session: Session, turn: number, step: number, body: string): void {
  69. session.append('assistant/message', {
  70. turn,
  71. step,
  72. message: createAssistantMessage({
  73. content: text(body),
  74. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  75. }),
  76. usage: {
  77. inputTokens: 2_000 + turn * 7,
  78. outputTokens: 180 + step * 20,
  79. },
  80. }, { surfaceOp: 'append' })
  81. }
  82. function codeBlock(turn: number): string {
  83. if (turn % CODE_INTERVAL !== 0) return ''
  84. const lines = Array.from(
  85. { length: 30 },
  86. (_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
  87. )
  88. return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
  89. }
  90. function appendToolStep(
  91. session: Session,
  92. markers: ChatScrollMarkers,
  93. turn: number,
  94. ): void {
  95. const calls = [1, 2].map((index) => {
  96. const marker = markers.tool(turn, index)
  97. const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
  98. const args = JSON.stringify({
  99. command: `printf '${marker}\\n'`,
  100. description: marker,
  101. })
  102. return { args, callId, marker }
  103. })
  104. session.append('assistant/message', {
  105. turn,
  106. step: 1,
  107. message: createAssistantMessage({
  108. content: [
  109. { type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
  110. ...calls.map(call => ({
  111. type: 'tool-call' as const,
  112. id: call.callId,
  113. name: 'bash',
  114. arguments: call.args,
  115. })),
  116. ],
  117. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  118. }),
  119. usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
  120. }, { surfaceOp: 'append' })
  121. for (const call of calls) {
  122. const source = session.append('tool/call', {
  123. turn,
  124. step: 1,
  125. callId: call.callId,
  126. name: 'bash',
  127. arguments: call.args,
  128. })
  129. session.append('tool/result', {
  130. turn,
  131. step: 1,
  132. message: createToolResultMessage({
  133. callId: call.callId,
  134. content: text(Array.from(
  135. { length: 12 },
  136. (_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
  137. ).join('\n')),
  138. isError: false,
  139. }),
  140. }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
  141. }
  142. }
  143. function fixtureLog(session: Session): string {
  144. return [
  145. JSON.stringify({
  146. type: 'session',
  147. version: SESSION_FORMAT_VERSION,
  148. id: '{{sessionId}}',
  149. createdAt: Date.now() - 60_000,
  150. cwd: '{{cwd}}',
  151. delegationDepth: 0,
  152. }),
  153. ...session.events.map(event => JSON.stringify(event)),
  154. '',
  155. ].join('\n')
  156. }
  157. /**
  158. * Build a multi-page conversation with prose, fenced code, and paired bash
  159. * calls/results. Every turn is closed, so cold resume cannot repair or mutate
  160. * the seed before the browser observes it.
  161. * @param options - Fixture identity and optional turn count.
  162. * @returns Canonical JSONL and semantic marker helpers.
  163. */
  164. export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
  165. const turns = options.turns ?? DEFAULT_TURNS
  166. const markers = markerHelpers(options.markerPrefix)
  167. const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
  168. for (let turn = 1; turn <= turns; turn += 1) {
  169. session.append('turn/start', {
  170. turn,
  171. trigger: { kind: 'message', source: { kind: 'user' } },
  172. })
  173. const user = session.append('user/message', createUserMessage({
  174. content: text(
  175. `${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
  176. + 'Keep the visible message stable while history, tools, and new output change around it.',
  177. ),
  178. source: { kind: 'user' },
  179. }), { surfaceOp: 'append' })
  180. if (turn === 1) {
  181. session.append('session/title', {
  182. title: options.title,
  183. messageSeqs: [user.seq],
  184. source: { kind: 'fallback' },
  185. })
  186. }
  187. session.append('step/start', { turn, step: 1 })
  188. appendRequestHeader(session, turn, 1)
  189. if (turn % TOOL_INTERVAL === 0) {
  190. appendToolStep(session, markers, turn)
  191. session.append('step/end', { turn, step: 1 })
  192. session.append('step/start', { turn, step: 2 })
  193. appendRequestHeader(session, turn, 2)
  194. appendAssistant(
  195. session,
  196. turn,
  197. 2,
  198. `${markers.assistant(turn)} Both tool results are accounted for. `
  199. + `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
  200. )
  201. session.append('step/end', { turn, step: 2 })
  202. } else {
  203. appendAssistant(
  204. session,
  205. turn,
  206. 1,
  207. `${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
  208. + `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
  209. + 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
  210. + `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
  211. )
  212. session.append('step/end', { turn, step: 1 })
  213. }
  214. session.append('turn/end', { turn, reason: { kind: 'completed' } })
  215. }
  216. return { log: fixtureLog(session), markers, title: options.title, turns }
  217. }