session-title.spec.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { describe, expect, it } from 'vitest'
  4. import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  5. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  6. import SessionTitleService, {
  7. SessionTitleProviderId,
  8. fallbackSessionTitle,
  9. foldSessionTitle,
  10. normalizeSessionTitle,
  11. truncateTitleUtf8,
  12. } from '@deepseek-ai/dsh-session-title'
  13. const CONFIG = {
  14. fallbackMaxWords: 5,
  15. fallbackMaxBytes: 40,
  16. maxTitleBytes: 80,
  17. } as const
  18. async function settleTitles(): Promise<void> {
  19. await new Promise(resolve => setTimeout(resolve, 0))
  20. }
  21. describe('session title normalization', () => {
  22. it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => {
  23. expect(normalizeSessionTitle('\u001B]0;stolen\u0007 Hello\t brave\nnew world ', 80))
  24. .toBe('Hello brave new world')
  25. expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three')
  26. expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好')
  27. expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4)
  28. })
  29. it('rejects non-positive and fractional public limits', () => {
  30. expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/)
  31. expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/)
  32. })
  33. })
  34. describe('SessionTitleService', () => {
  35. it('logs and folds an immediate fallback after the first eligible human text message', async () => {
  36. const ctx = new Context()
  37. await ctx.plugin(SessionStore)
  38. await ctx.plugin(SessionProjectionRegistry)
  39. await ctx.plugin(SessionTitleService, CONFIG)
  40. const session = ctx.sessions.create(SessionId('fresh'))
  41. session.append('turn/start', {
  42. turn: 1,
  43. })
  44. const message = session.append('user/message', createUserMessage({
  45. content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }],
  46. source: { kind: 'user' },
  47. }), { surfaceOp: 'append' })
  48. await settleTitles()
  49. const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title')
  50. expect(titleEvent).toMatchObject({
  51. type: 'session/title',
  52. seq: 2,
  53. data: {
  54. title: 'Build log-backed session titles please',
  55. messageSeqs: [message.seq],
  56. source: { kind: 'fallback' },
  57. },
  58. })
  59. expect(ctx.sessionTitle.get(session)).toEqual({
  60. title: 'Build log-backed session titles please',
  61. messageSeqs: [message.seq],
  62. source: { kind: 'fallback' },
  63. eventSeq: 2,
  64. updatedAt: titleEvent?.time,
  65. })
  66. expect(session.deriveMessages()).toHaveLength(1)
  67. expect(session.surface.nodes).toEqual([message.seq])
  68. })
  69. it('derives a fallback title from the direct prompt instead of injected context', async () => {
  70. const ctx = new Context()
  71. await ctx.plugin(SessionStore)
  72. await ctx.plugin(SessionProjectionRegistry)
  73. await ctx.plugin(SessionTitleService, CONFIG)
  74. const session = ctx.sessions.create(SessionId('prefixed-title'))
  75. session.append('user/message', createUserMessage({
  76. content: [{ type: 'text', text: 'Referenced session snapshot' }],
  77. source: {
  78. kind: 'session-reference',
  79. form: 'recall',
  80. version: 1,
  81. references: [],
  82. },
  83. }), { surfaceOp: 'append' })
  84. session.append('turn/start', {
  85. turn: 1,
  86. })
  87. session.append('user/message', createUserMessage({
  88. content: [{ type: 'text', text: 'Explain this referenced session' }],
  89. source: { kind: 'user' },
  90. }), { surfaceOp: 'append' })
  91. await settleTitles()
  92. expect(ctx.sessionTitle.get(session)?.title).toBe('Explain this referenced session')
  93. })
  94. it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
  95. const ctx = new Context()
  96. await ctx.plugin(SessionStore)
  97. await ctx.plugin(SessionProjectionRegistry)
  98. await ctx.plugin(SessionTitleService, CONFIG)
  99. const session = ctx.sessions.create(SessionId('eligibility'))
  100. session.append('turn/start', {
  101. turn: 1,
  102. })
  103. session.append('user/message', createUserMessage({
  104. content: [{ type: 'text', text: 'plugin text' }],
  105. source: { kind: 'plugin', plugin: 'seed' },
  106. }), { surfaceOp: 'append' })
  107. session.append('user/message', createUserMessage({
  108. content: [{ type: 'reasoning', text: 'not visible text' }],
  109. source: { kind: 'user' },
  110. }), { surfaceOp: 'append' })
  111. session.append('user/message', createUserMessage({
  112. content: [{ type: 'text', text: ' \n\t ' }],
  113. source: { kind: 'user' },
  114. }), { surfaceOp: 'append' })
  115. await settleTitles()
  116. expect(ctx.sessionTitle.get(session)).toBeUndefined()
  117. const eligible = session.append('user/message', createUserMessage({
  118. content: [{ type: 'text', text: 'first real prompt' }],
  119. source: { kind: 'user' },
  120. }), { surfaceOp: 'append' })
  121. await settleTitles()
  122. const first = ctx.sessionTitle.get(session)
  123. session.append('user/message', createUserMessage({
  124. content: [{ type: 'text', text: 'later prompt' }],
  125. source: { kind: 'user' },
  126. }), { surfaceOp: 'append' })
  127. await settleTitles()
  128. expect(first?.messageSeqs).toEqual([eligible.seq])
  129. expect(ctx.sessionTitle.get(session)).toEqual(first)
  130. expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1)
  131. })
  132. it('folds the latest title event during replay', () => {
  133. const seed = Session.create(SessionId('source'))
  134. seed.append('session/title', {
  135. title: 'Earlier',
  136. messageSeqs: [SessionSeq(1)],
  137. source: { kind: 'fallback' },
  138. })
  139. seed.append('session/title', {
  140. title: 'Later',
  141. messageSeqs: [SessionSeq(1), SessionSeq(4)],
  142. source: {
  143. kind: 'provider',
  144. provider: SessionTitleProviderId('test-provider'),
  145. model: { provider: 'mock', model: 'title-model' },
  146. },
  147. })
  148. expect(foldSessionTitle(seed.snapshotEvents())).toEqual({
  149. title: 'Later',
  150. messageSeqs: [1, 4],
  151. source: {
  152. kind: 'provider',
  153. provider: SessionTitleProviderId('test-provider'),
  154. model: { provider: 'mock', model: 'title-model' },
  155. },
  156. eventSeq: 1,
  157. updatedAt: seed.snapshotEvents()[1]?.time,
  158. })
  159. })
  160. it('folds an empty or title-less log to undefined', () => {
  161. expect(foldSessionTitle([])).toBeUndefined()
  162. const empty = Session.create(SessionId('no-title'))
  163. expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined()
  164. })
  165. })