provider.spec.ts 17 KB

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