session-title.spec.ts 6.0 KB

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