rename.spec.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. // SessionTitleService.rename: user-source acceptance, normalization/rejection
  2. // boundaries, and the pin (a user-sourced latest title schedules no automatic
  3. // revision; explicit refresh stays the unpin).
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  9. import SessionTitleService, {
  10. SessionTitleProviderId,
  11. foldSessionTitle,
  12. type SessionTitleProviderRequest,
  13. } from '@deepseek-ai/dsh-session-title'
  14. const CONFIG = {
  15. fallbackMaxWords: 5,
  16. fallbackMaxBytes: 40,
  17. maxTitleBytes: 40,
  18. } as const
  19. async function settle(): Promise<void> {
  20. await new Promise(resolve => setTimeout(resolve, 0))
  21. }
  22. function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
  23. return session.append('user/message', createUserMessage({
  24. content: [{ type: 'text', text }],
  25. source: { kind: 'user' },
  26. }), { surfaceOp: 'append' })
  27. }
  28. describe('SessionTitleService.rename', () => {
  29. it('appends a normalized user-source title', async () => {
  30. const ctx = new Context()
  31. await ctx.plugin(SessionStore)
  32. await ctx.plugin(SessionProjectionRegistry)
  33. await ctx.plugin(SessionTitleService, CONFIG)
  34. const session = ctx.sessions.create(SessionId('rename-accept'))
  35. session.append('turn/start', { turn: 1 })
  36. appendHumanPrompt(session, 'Original prompt text')
  37. await settle()
  38. const accepted = ctx.sessionTitle.rename(session, ' Hand\tpicked name ')
  39. expect(accepted).toMatchObject({
  40. title: 'Hand picked name',
  41. messageSeqs: [],
  42. source: { kind: 'user' },
  43. })
  44. const event = session.snapshotEvents().findLast(item => item.type === 'session/title')
  45. expect(event?.data).toEqual({
  46. title: 'Hand picked name',
  47. messageSeqs: [],
  48. source: { kind: 'user' },
  49. })
  50. // foldSessionTitle round-trips the third source kind.
  51. expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' })
  52. })
  53. it('rejects titles that normalize to empty and dead sessions', async () => {
  54. const ctx = new Context()
  55. await ctx.plugin(SessionStore)
  56. await ctx.plugin(SessionProjectionRegistry)
  57. await ctx.plugin(SessionTitleService, CONFIG)
  58. const session = ctx.sessions.create(SessionId('rename-reject'))
  59. expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/)
  60. expect(() => ctx.sessionTitle.rename(Session.create(SessionId('detached')), 'name'))
  61. .toThrow(/not live in this store/)
  62. })
  63. it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
  64. const ctx = new Context()
  65. await ctx.plugin(SessionStore)
  66. await ctx.plugin(SessionProjectionRegistry)
  67. await ctx.plugin(SessionTitleService, CONFIG)
  68. const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
  69. title: 'Provider title',
  70. messageSeqs: request.messages.map(message => message.seq),
  71. }))
  72. ctx.sessionTitle.register({
  73. id: SessionTitleProviderId('pin-provider'),
  74. automatic: 'all-prompts',
  75. generate,
  76. })
  77. const session = ctx.sessions.create(SessionId('rename-pin'))
  78. session.append('turn/start', { turn: 1 })
  79. appendHumanPrompt(session, 'First prompt')
  80. await settle()
  81. ctx.sessionTitle.rename(session, 'Pinned by hand')
  82. // A later eligible prompt must schedule nothing while the pin stands.
  83. appendHumanPrompt(session, 'Second prompt after the pin')
  84. await settle()
  85. session.append('request/header', {
  86. header: { config: { provider: 'main-route', model: 'chat-model' } },
  87. reason: 'change',
  88. })
  89. await settle()
  90. expect(generate).not.toHaveBeenCalled()
  91. expect(ctx.sessionTitle.get(session)?.title).toBe('Pinned by hand')
  92. // Explicit refresh remains the deliberate unpin.
  93. const refreshed = await ctx.sessionTitle.refresh(session)
  94. expect(generate).toHaveBeenCalledOnce()
  95. expect(refreshed?.title).toBe('Provider title')
  96. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('provider')
  97. })
  98. it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
  99. const ctx = new Context()
  100. await ctx.plugin(SessionStore)
  101. await ctx.plugin(SessionProjectionRegistry)
  102. await ctx.plugin(SessionTitleService, CONFIG)
  103. const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
  104. session.append('turn/start', { turn: 1 })
  105. appendHumanPrompt(session, 'Derivable prompt words')
  106. await settle()
  107. ctx.sessionTitle.rename(session, 'Pinned without provider')
  108. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
  109. const refreshed = await ctx.sessionTitle.refresh(session)
  110. expect(refreshed).toMatchObject({
  111. title: 'Derivable prompt words',
  112. source: { kind: 'fallback' },
  113. })
  114. // The pin is gone: the latest title is fallback-sourced, so the
  115. // onUserMessage pin check no longer skips scheduling.
  116. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
  117. })
  118. it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
  119. const ctx = new Context()
  120. await ctx.plugin(SessionStore)
  121. await ctx.plugin(SessionProjectionRegistry)
  122. await ctx.plugin(SessionTitleService, CONFIG)
  123. // The provider parks on a test-held deferred so rename lands while its
  124. // generation is ACTIVE (not merely scheduled).
  125. let releaseProvider: (() => void) | undefined
  126. const gate = new Promise<void>((resolve) => { releaseProvider = resolve })
  127. let aborted = false
  128. const generate = vi.fn(async (request: SessionTitleProviderRequest) => {
  129. request.signal.addEventListener('abort', () => { aborted = true })
  130. await gate
  131. return { title: 'Late provider title', messageSeqs: request.messages.map(message => message.seq) }
  132. })
  133. ctx.sessionTitle.register({
  134. id: SessionTitleProviderId('deferred-provider'),
  135. automatic: 'all-prompts',
  136. generate,
  137. })
  138. const session = ctx.sessions.create(SessionId('rename-supersede'))
  139. session.append('turn/start', { turn: 1 })
  140. appendHumanPrompt(session, 'Prompt that triggers generation')
  141. session.append('request/header', {
  142. header: { config: { provider: 'main-route', model: 'chat-model' } },
  143. reason: 'change',
  144. })
  145. await settle()
  146. expect(generate).toHaveBeenCalledOnce()
  147. ctx.sessionTitle.rename(session, 'User wins')
  148. expect(aborted).toBe(true)
  149. releaseProvider?.()
  150. await settle()
  151. // The released provider result must not append over the user title, and
  152. // the swallowed abort must not surface as an unhandled rejection.
  153. const latest = session.snapshotEvents().findLast(item => item.type === 'session/title')
  154. expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } })
  155. })
  156. it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
  157. const ctx = new Context()
  158. await ctx.plugin(SessionStore)
  159. await ctx.plugin(SessionProjectionRegistry)
  160. // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
  161. // re-derived fallback is empty, so the pinned title survives the refresh.
  162. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
  163. const session = ctx.sessions.create(SessionId('rename-unpin-empty'))
  164. session.append('turn/start', { turn: 1 })
  165. appendHumanPrompt(session, '😀😀')
  166. await settle()
  167. ctx.sessionTitle.rename(session, 'Sticky emoji pin')
  168. const refreshed = await ctx.sessionTitle.refresh(session)
  169. expect(refreshed?.title).toBe('Sticky emoji pin')
  170. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('user')
  171. })
  172. })