llm.spec.ts 14 KB

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