llm.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import { Context } from 'cordis'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import LlmService, { CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
  4. import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  6. import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
  7. import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
  8. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  9. import {
  10. generateSessionTitleWithLlm,
  11. resolveSessionTitleLlmConfig,
  12. SESSION_TITLE_TIMEOUT_CODE,
  13. } from '@deepseek-ai/dsh-session-title-llm'
  14. import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
  15. class RecordingAdapter extends LlmAdapter {
  16. readonly requests: GenerateOptions[] = []
  17. constructor(
  18. private readonly script: readonly StreamChunk[],
  19. private readonly onDispatch?: () => void,
  20. ) {
  21. super()
  22. }
  23. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  24. this.onDispatch?.()
  25. this.requests.push(options)
  26. yield * this.script
  27. }
  28. }
  29. class CooperativeAdapter extends LlmAdapter {
  30. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  31. const signal = options.signal
  32. if (signal === undefined) throw new Error('expected title request signal')
  33. await new Promise<never>((_resolve, reject) => {
  34. const rejectAbort = (): void => {
  35. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
  36. reject(signal.reason)
  37. }
  38. if (signal.aborted) {
  39. rejectAbort()
  40. return
  41. }
  42. signal.addEventListener('abort', rejectAbort, { once: true })
  43. })
  44. }
  45. }
  46. class DelayedSuccessAdapter extends LlmAdapter {
  47. constructor(private readonly delayMs: number) {
  48. super()
  49. }
  50. override async * stream(): AsyncIterable<StreamChunk> {
  51. await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
  52. yield * SCRIPT
  53. }
  54. }
  55. const SCRIPT: StreamChunk[] = [
  56. { type: 'block-start', index: 0, blockType: 'text' },
  57. { type: 'text-delta', index: 0, text: ' 五个字标题 ' },
  58. { type: 'finish', reason: { kind: 'stop' } },
  59. ]
  60. const CONFIG = {
  61. targetWords: 5,
  62. targetCjkCharacters: 10,
  63. maxInputBytes: 1_000,
  64. maxOutputTokens: 32,
  65. timeoutMs: 1_000,
  66. } as const
  67. const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
  68. let nextSession = 0
  69. function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
  70. const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
  71. session.append('turn/start', {
  72. turn: 1,
  73. trigger: { kind: 'message', source: { kind: 'user' } },
  74. })
  75. const first = session.append('user/message', {
  76. content: [{ type: 'text', text: 'first prompt' }],
  77. source: { kind: 'user' },
  78. }, { surfaceOp: 'append' })
  79. const second = session.append('user/message', {
  80. content: [{ type: 'text', text: '第二个问题' }],
  81. source: { kind: 'user' },
  82. }, { surfaceOp: 'append' })
  83. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  84. return {
  85. session,
  86. messages: [
  87. { seq: first.seq, text: 'first prompt' },
  88. { seq: second.seq, text: '第二个问题' },
  89. ],
  90. route: { provider: 'current-route', model: 'current-model' },
  91. signal,
  92. }
  93. }
  94. function requestWithoutRoute(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
  95. const routed = request(ctx, signal)
  96. return { session: routed.session, messages: routed.messages, signal }
  97. }
  98. async function withScript(script: readonly StreamChunk[]): Promise<{
  99. ctx: Context
  100. adapter: RecordingAdapter
  101. }> {
  102. const ctx = new Context()
  103. await ctx.plugin(SessionStore)
  104. await ctx.plugin(LlmService)
  105. const adapter = new RecordingAdapter(script)
  106. ctx.llm.registerAdapter(['current-route'], adapter)
  107. return { ctx, adapter }
  108. }
  109. describe('generateSessionTitleWithLlm', () => {
  110. it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
  111. const ctx = new Context()
  112. await ctx.plugin(SessionStore)
  113. await ctx.plugin(LlmService)
  114. const providerRequest = request(ctx)
  115. let requestWasLoggedAtDispatch = false
  116. const adapter = new RecordingAdapter(SCRIPT, () => {
  117. requestWasLoggedAtDispatch = providerRequest.session.events
  118. .some(event => event.type === 'session/title-llm-request')
  119. })
  120. ctx.llm.registerAdapter(['current-route'], adapter)
  121. const result = await generateSessionTitleWithLlm(
  122. ctx,
  123. resolveSessionTitleLlmConfig(CONFIG),
  124. providerRequest,
  125. providerRequest.messages,
  126. TITLE_PROVIDER,
  127. )
  128. expect(result).toEqual({
  129. title: '五个字标题',
  130. messageSeqs: providerRequest.messages.map(message => message.seq),
  131. model: { provider: 'current-route', model: 'current-model' },
  132. })
  133. expect(requestWasLoggedAtDispatch).toBe(true)
  134. expect(adapter.requests).toHaveLength(1)
  135. const options = adapter.requests[0]!
  136. expect(Object.isFrozen(options)).toBe(true)
  137. expect(Object.isFrozen(options.messages)).toBe(true)
  138. expect(isAgentLoopRequest(options)).toBe(false)
  139. expect(options).toMatchObject({
  140. provider: 'current-route',
  141. model: 'current-model',
  142. maxTokens: 32,
  143. sessionId: providerRequest.session.id,
  144. purpose: 'session-title',
  145. })
  146. expect(options.system).toContain('5 words')
  147. expect(options.system).toContain('10 CJK characters')
  148. const prompt = options.messages[0]?.content[0]
  149. expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
  150. expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
  151. expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data)
  152. .toEqual({
  153. titleProvider: TITLE_PROVIDER,
  154. messageSeqs: providerRequest.messages.map(message => message.seq),
  155. route: { provider: 'current-route', model: 'current-model' },
  156. system: options.system,
  157. messages: options.messages,
  158. maxTokens: 32,
  159. })
  160. })
  161. it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
  162. const ctx = new Context()
  163. await ctx.plugin(SessionStore)
  164. await ctx.plugin(LlmService)
  165. const adapter = new RecordingAdapter(SCRIPT)
  166. ctx.llm.registerAdapter(['explicit-route'], adapter)
  167. const oversized = request(ctx)
  168. const [selected] = oversized.messages
  169. if (selected === undefined) throw new Error('expected one selected message')
  170. const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
  171. const config = resolveSessionTitleLlmConfig({
  172. ...CONFIG,
  173. provider: 'explicit-route',
  174. model: 'explicit-model',
  175. maxInputBytes: rawInputBytes,
  176. })
  177. await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
  178. .rejects.toThrow(/input.*bytes.*maxInputBytes/i)
  179. expect(adapter.requests).toEqual([])
  180. expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
  181. const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
  182. const within = request(ctx)
  183. await generateSessionTitleWithLlm(ctx, withinLimit, within, [within.messages[0]!], TITLE_PROVIDER)
  184. expect(adapter.requests[0]).toMatchObject({
  185. provider: 'explicit-route',
  186. model: 'explicit-model',
  187. })
  188. })
  189. it('requires every deployment limit and a complete optional route pair', () => {
  190. expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
  191. expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
  192. expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
  193. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
  194. .toThrow(/unknown config key "extra"/)
  195. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
  196. .toThrow(/targetWords.*positive integer/)
  197. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
  198. .toThrow(/targetWords.*positive integer/)
  199. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
  200. .toThrow(/provider and model must be supplied together/)
  201. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
  202. .toThrow(/provider and model must be supplied together/)
  203. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
  204. .toThrow(/overrides must be non-empty strings/)
  205. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
  206. .toThrow(/overrides must be non-empty strings/)
  207. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
  208. .toThrow(/overrides must be non-empty strings/)
  209. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
  210. .toThrow(/overrides must be non-empty strings/)
  211. expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
  212. .toThrow(/timeoutMs must not exceed/)
  213. expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
  214. })
  215. it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
  216. const { ctx, adapter } = await withScript(SCRIPT)
  217. const config = resolveSessionTitleLlmConfig(CONFIG)
  218. const unrouted = requestWithoutRoute(ctx)
  219. await expect(generateSessionTitleWithLlm(ctx, config, unrouted, unrouted.messages, TITLE_PROVIDER))
  220. .rejects.toThrow(/no logged request route/)
  221. const empty = request(ctx)
  222. await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
  223. .rejects.toThrow(/at least one source message/)
  224. const controller = new AbortController()
  225. controller.abort(new Error('caller stopped'))
  226. const aborted = request(ctx, controller.signal)
  227. await expect(generateSessionTitleWithLlm(ctx, config, aborted, aborted.messages, TITLE_PROVIDER))
  228. .rejects.toThrow('caller stopped')
  229. expect(adapter.requests).toEqual([])
  230. })
  231. it.each([
  232. [{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
  233. [{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
  234. ] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
  235. const { ctx } = await withScript([{ type: 'finish', reason }])
  236. const providerRequest = request(ctx)
  237. await expect(generateSessionTitleWithLlm(
  238. ctx,
  239. resolveSessionTitleLlmConfig(CONFIG),
  240. providerRequest,
  241. providerRequest.messages,
  242. TITLE_PROVIDER,
  243. )).rejects.toMatchObject({ message, code })
  244. expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true)
  245. })
  246. it.each([
  247. [{ kind: 'max-tokens' }, /reached maxOutputTokens/],
  248. [{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
  249. [{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
  250. ] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
  251. const { ctx } = await withScript([{ type: 'finish', reason }])
  252. const providerRequest = request(ctx)
  253. await expect(generateSessionTitleWithLlm(
  254. ctx,
  255. resolveSessionTitleLlmConfig(CONFIG),
  256. providerRequest,
  257. providerRequest.messages,
  258. TITLE_PROVIDER,
  259. )).rejects.toThrow(error)
  260. })
  261. it('rejects tool-call blocks and a successful response with no text', async () => {
  262. const toolScript: StreamChunk[] = [
  263. { type: 'block-start', index: 0, blockType: 'tool-call' },
  264. { type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
  265. { type: 'finish', reason: { kind: 'stop' } },
  266. ]
  267. const tool = await withScript(toolScript)
  268. const toolRequest = request(tool.ctx)
  269. await expect(generateSessionTitleWithLlm(
  270. tool.ctx,
  271. resolveSessionTitleLlmConfig(CONFIG),
  272. toolRequest,
  273. toolRequest.messages,
  274. TITLE_PROVIDER,
  275. )).rejects.toThrow(/output must contain text only/)
  276. const reasoning = await withScript([
  277. { type: 'block-start', index: 0, blockType: 'reasoning' },
  278. { type: 'reasoning-delta', index: 0, text: 'no final title' },
  279. { type: 'finish', reason: { kind: 'stop' } },
  280. ])
  281. const reasoningRequest = request(reasoning.ctx)
  282. await expect(generateSessionTitleWithLlm(
  283. reasoning.ctx,
  284. resolveSessionTitleLlmConfig(CONFIG),
  285. reasoningRequest,
  286. reasoningRequest.messages,
  287. TITLE_PROVIDER,
  288. )).rejects.toThrow(/produced no text/)
  289. })
  290. it('aborts a cooperative model stream at the configured deadline', async () => {
  291. vi.useFakeTimers()
  292. try {
  293. const ctx = new Context()
  294. await ctx.plugin(SessionStore)
  295. await ctx.plugin(LlmService)
  296. ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
  297. const providerRequest = request(ctx)
  298. const pending = generateSessionTitleWithLlm(
  299. ctx,
  300. resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
  301. providerRequest,
  302. providerRequest.messages,
  303. TITLE_PROVIDER,
  304. )
  305. const rejected = expect(pending).rejects.toMatchObject({
  306. code: SESSION_TITLE_TIMEOUT_CODE,
  307. timeoutMs: 10,
  308. })
  309. await vi.advanceTimersByTimeAsync(10)
  310. await rejected
  311. } finally {
  312. vi.useRealTimers()
  313. }
  314. })
  315. it('rejects a successful stream that completes after the configured deadline', async () => {
  316. vi.useFakeTimers()
  317. try {
  318. const ctx = new Context()
  319. await ctx.plugin(SessionStore)
  320. await ctx.plugin(LlmService)
  321. ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
  322. const providerRequest = request(ctx)
  323. const pending = generateSessionTitleWithLlm(
  324. ctx,
  325. resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
  326. providerRequest,
  327. providerRequest.messages,
  328. TITLE_PROVIDER,
  329. )
  330. const rejected = expect(pending).rejects.toMatchObject({
  331. code: SESSION_TITLE_TIMEOUT_CODE,
  332. timeoutMs: 10,
  333. })
  334. await vi.advanceTimersByTimeAsync(20)
  335. await rejected
  336. } finally {
  337. vi.useRealTimers()
  338. }
  339. })
  340. })