provider.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import LlmRuntime, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  5. import SessionTitleService, {
  6. SessionTitleProviderId,
  7. type SessionTitleProvider,
  8. type SessionTitleProviderRequest,
  9. type SessionTitleProviderResult,
  10. } from '@deepseek-ai/dsh-session-title'
  11. const CONFIG = {
  12. fallbackMaxWords: 5,
  13. fallbackMaxBytes: 24,
  14. maxTitleBytes: 24,
  15. } as const
  16. function deferred<T>(): {
  17. promise: Promise<T>
  18. resolve(value: T): void
  19. reject(error: unknown): void
  20. } {
  21. let resolve!: (value: T) => void
  22. let reject!: (error: unknown) => void
  23. const promise = new Promise<T>((accept, decline) => {
  24. resolve = accept
  25. reject = decline
  26. })
  27. return { promise, resolve, reject }
  28. }
  29. async function settle(): Promise<void> {
  30. await new Promise(resolve => setTimeout(resolve, 0))
  31. }
  32. function appendHumanPrompt(session: ReturnType<Context['sessions']['create']>, text: string) {
  33. return session.append('user/message', createUserMessage({
  34. content: [{ type: 'text', text }],
  35. source: { kind: 'user' },
  36. }), { surfaceOp: 'append' })
  37. }
  38. function appendRoute(session: ReturnType<Context['sessions']['create']>, reason: 'initial' | 'change' = 'initial'): void {
  39. session.append('request/header', {
  40. header: { config: { provider: 'main-route', model: 'chat-model' } },
  41. reason,
  42. })
  43. }
  44. describe('SessionTitleService Provider lifecycle', () => {
  45. it('inherits title events across forks, skips first-prompt retitling, and lets all-messages update later', async () => {
  46. const ctx = new Context()
  47. await ctx.plugin(SessionStore)
  48. await ctx.plugin(SessionTitleService, CONFIG)
  49. const parent = ctx.sessions.create(SessionId('title-parent'))
  50. parent.append('turn/start', {
  51. turn: 1,
  52. })
  53. const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt')
  54. await settle()
  55. parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  56. const child = ctx.sessions.fork(parent, undefined, SessionId('title-child'))
  57. expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent))
  58. expect(child.events.find(event => event.type === 'session/title'))
  59. .toEqual(parent.events.find(event => event.type === 'session/title'))
  60. const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
  61. title: 'Should not run',
  62. messageSeqs: [request.messages[0]!.seq],
  63. }))
  64. const disposeFirst = ctx.sessionTitle.register({
  65. id: SessionTitleProviderId('fork-first'),
  66. automatic: 'first-prompt',
  67. generate: firstGenerate,
  68. })
  69. child.append('turn/start', {
  70. turn: 2,
  71. })
  72. const childMessage = appendHumanPrompt(child, 'Child follow-up prompt')
  73. await settle()
  74. appendRoute(child)
  75. await settle()
  76. child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
  77. expect(firstGenerate).not.toHaveBeenCalled()
  78. await disposeFirst()
  79. const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({
  80. title: 'Fork all prompts',
  81. messageSeqs: request.messages.map(message => message.seq),
  82. }))
  83. ctx.sessionTitle.register({
  84. id: SessionTitleProviderId('fork-all'),
  85. automatic: 'all-prompts',
  86. generate: allGenerate,
  87. })
  88. child.append('turn/start', {
  89. turn: 3,
  90. })
  91. const latestMessage = appendHumanPrompt(child, 'Retitle the fork now')
  92. await settle()
  93. appendRoute(child, 'change')
  94. await settle()
  95. child.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
  96. expect(allGenerate).toHaveBeenCalledOnce()
  97. expect(ctx.sessionTitle.get(child)).toMatchObject({
  98. title: 'Fork all prompts',
  99. messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq],
  100. source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') },
  101. })
  102. expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt')
  103. })
  104. it('runs a first-prompt provider once after the routed request and retries only through refresh', async () => {
  105. const ctx = new Context()
  106. await ctx.plugin(SessionStore)
  107. await ctx.plugin(SessionTitleService, CONFIG)
  108. const requests: SessionTitleProviderRequest[] = []
  109. const provider: SessionTitleProvider = {
  110. id: SessionTitleProviderId('first-model'),
  111. automatic: 'first-prompt',
  112. async generate(request) {
  113. requests.push(request)
  114. return {
  115. title: '\u001B[31m A model-generated title that is too long ',
  116. messageSeqs: [request.messages[0]!.seq],
  117. model: { provider: 'aux-route', model: 'title-model' },
  118. }
  119. },
  120. }
  121. ctx.sessionTitle.register(provider)
  122. const session = ctx.sessions.create(SessionId('first-provider'))
  123. session.append('turn/start', {
  124. turn: 1,
  125. })
  126. const first = appendHumanPrompt(session, 'Explain asynchronous title generation')
  127. await settle()
  128. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
  129. appendRoute(session)
  130. await settle()
  131. expect(requests).toHaveLength(1)
  132. expect(requests[0]).toMatchObject({
  133. session,
  134. messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
  135. route: { provider: 'main-route', model: 'chat-model' },
  136. })
  137. expect(ctx.sessionTitle.get(session)).toMatchObject({
  138. title: 'A model-generated title',
  139. messageSeqs: [first.seq],
  140. source: {
  141. kind: 'provider',
  142. provider: SessionTitleProviderId('first-model'),
  143. model: { provider: 'aux-route', model: 'title-model' },
  144. },
  145. })
  146. const second = appendHumanPrompt(session, 'A later prompt')
  147. appendRoute(session, 'change')
  148. await settle()
  149. expect(requests).toHaveLength(1)
  150. await ctx.sessionTitle.refresh(session)
  151. expect(requests).toHaveLength(2)
  152. expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
  153. })
  154. it('rejects a second provider and drains stale work when the winner is disposed', async () => {
  155. const ctx = new Context()
  156. await ctx.plugin(SessionStore)
  157. await ctx.plugin(SessionTitleService, CONFIG)
  158. const pending = deferred<SessionTitleProviderResult>()
  159. let observedSignal: AbortSignal | undefined
  160. const first: SessionTitleProvider = {
  161. id: SessionTitleProviderId('winner'),
  162. automatic: 'all-prompts',
  163. generate(request) {
  164. observedSignal = request.signal
  165. return pending.promise
  166. },
  167. }
  168. const dispose = ctx.sessionTitle.register(first)
  169. expect(() => ctx.sessionTitle.register({
  170. id: SessionTitleProviderId('duplicate'),
  171. automatic: 'first-prompt',
  172. generate: async () => ({ title: 'duplicate', messageSeqs: [0] }),
  173. })).toThrow(/already registered/)
  174. const session = ctx.sessions.create(SessionId('dispose-provider'))
  175. session.append('turn/start', {
  176. turn: 1,
  177. })
  178. const message = appendHumanPrompt(session, 'Generate this title')
  179. await settle()
  180. appendRoute(session)
  181. await settle()
  182. expect(observedSignal?.aborted).toBe(false)
  183. const disposal = dispose()
  184. expect(observedSignal?.aborted).toBe(true)
  185. let disposed = false
  186. void disposal.then(() => { disposed = true })
  187. await settle()
  188. expect(disposed).toBe(false)
  189. pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] })
  190. await disposal
  191. expect(disposed).toBe(true)
  192. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
  193. const replacement: SessionTitleProvider = {
  194. id: SessionTitleProviderId('replacement'),
  195. automatic: 'first-prompt',
  196. generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }),
  197. }
  198. const disposeReplacement = ctx.sessionTitle.register(replacement)
  199. await disposeReplacement()
  200. })
  201. it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
  202. const ctx = new Context()
  203. await ctx.plugin(SessionStore)
  204. await ctx.plugin(SessionTitleService, CONFIG)
  205. const firstResult = deferred<SessionTitleProviderResult>()
  206. const requests: SessionTitleProviderRequest[] = []
  207. const provider: SessionTitleProvider = {
  208. id: SessionTitleProviderId('all-model'),
  209. automatic: 'all-prompts',
  210. generate(request) {
  211. requests.push(request)
  212. if (requests.length === 1) return firstResult.promise
  213. return Promise.resolve({
  214. title: 'Newest complete title',
  215. messageSeqs: request.messages.map(message => message.seq),
  216. })
  217. },
  218. }
  219. ctx.sessionTitle.register(provider)
  220. const session = ctx.sessions.create(SessionId('supersede'))
  221. session.append('turn/start', {
  222. turn: 1,
  223. })
  224. const first = appendHumanPrompt(session, 'First prompt')
  225. await settle()
  226. appendRoute(session)
  227. await settle()
  228. const second = appendHumanPrompt(session, 'Second prompt')
  229. expect(requests[0]?.signal.aborted).toBe(true)
  230. appendRoute(session, 'change')
  231. await settle()
  232. expect(ctx.sessionTitle.get(session)).toMatchObject({
  233. title: 'Newest complete title',
  234. messageSeqs: [first.seq, second.seq],
  235. })
  236. firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] })
  237. await settle()
  238. expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title')
  239. })
  240. it('runs an all-messages revision when the next main request reuses its logged header', async () => {
  241. const ctx = new Context()
  242. await ctx.plugin(LlmRuntime)
  243. await ctx.plugin(SessionStore)
  244. await ctx.plugin(SessionTitleService, CONFIG)
  245. const requests: SessionTitleProviderRequest[] = []
  246. ctx.sessionTitle.register({
  247. id: SessionTitleProviderId('unchanged-route'),
  248. automatic: 'all-prompts',
  249. async generate(request) {
  250. requests.push(request)
  251. return {
  252. title: `Revision ${requests.length}`,
  253. messageSeqs: request.messages.map(message => message.seq),
  254. }
  255. },
  256. })
  257. const session = ctx.sessions.create(SessionId('unchanged-route'))
  258. session.append('turn/start', {
  259. turn: 1,
  260. })
  261. const first = appendHumanPrompt(session, 'First routed prompt')
  262. await settle()
  263. session.append('step/start', { turn: 1, step: 1 })
  264. appendRoute(session)
  265. await settle()
  266. session.append('step/end', { turn: 1, step: 1 })
  267. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  268. session.append('turn/start', {
  269. turn: 2,
  270. })
  271. const second = appendHumanPrompt(session, 'Second prompt on the same route')
  272. await settle()
  273. session.append('step/start', { turn: 2, step: 1 })
  274. void ctx.llm.stream(markAgentLoopRequest(deepFreeze({
  275. provider: 'main-route',
  276. model: 'chat-model',
  277. messages: session.deriveMessages(),
  278. sessionId: session.id,
  279. })))
  280. await settle()
  281. expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
  282. expect(requests).toHaveLength(2)
  283. expect(requests[1]).toMatchObject({
  284. messages: [
  285. { seq: first.seq, text: 'First routed prompt' },
  286. { seq: second.seq, text: 'Second prompt on the same route' },
  287. ],
  288. route: { provider: 'main-route', model: 'chat-model' },
  289. })
  290. })
  291. it('ignores model streams that are not a matching loop request', async () => {
  292. const ctx = new Context()
  293. await ctx.plugin(LlmRuntime)
  294. await ctx.plugin(SessionStore)
  295. await ctx.plugin(SessionTitleService, CONFIG)
  296. const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult> => ({
  297. title: 'Unexpected title',
  298. messageSeqs: request.messages.map(message => message.seq),
  299. }))
  300. ctx.sessionTitle.register({
  301. id: SessionTitleProviderId('request-filter'),
  302. automatic: 'all-prompts',
  303. generate,
  304. })
  305. const options = { provider: 'main-route', model: 'chat-model', messages: [] }
  306. void ctx.llm.stream(deepFreeze(options))
  307. void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: SessionId('missing') })))
  308. const quiet = ctx.sessions.create(SessionId('quiet'))
  309. void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: quiet.id })))
  310. const pending = ctx.sessions.create(SessionId('unmatched-boundary'))
  311. pending.append('turn/start', {
  312. turn: 1,
  313. })
  314. appendHumanPrompt(pending, 'Wait for a matching request boundary')
  315. await settle()
  316. void ctx.llm.stream(markAgentLoopRequest(deepFreeze({ ...options, sessionId: pending.id })))
  317. await settle()
  318. expect(generate).not.toHaveBeenCalled()
  319. })
  320. it('contains automatic failures but lets explicit refresh reject', async () => {
  321. const ctx = new Context()
  322. await ctx.plugin(SessionStore)
  323. await ctx.plugin(SessionTitleService, CONFIG)
  324. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
  325. const provider: SessionTitleProvider = {
  326. id: SessionTitleProviderId('failing'),
  327. automatic: 'all-prompts',
  328. generate: async () => { throw new Error('title backend failed') },
  329. }
  330. ctx.sessionTitle.register(provider)
  331. const session = ctx.sessions.create(SessionId('failure'))
  332. session.append('turn/start', {
  333. turn: 1,
  334. })
  335. appendHumanPrompt(session, 'Keep a fallback')
  336. await settle()
  337. appendRoute(session)
  338. await settle()
  339. expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
  340. expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed'))
  341. await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed')
  342. warn.mockRestore()
  343. })
  344. })