1
0

feedback-composition.spec.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { pathToFileURL } from 'node:url'
  5. import { afterEach, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  11. import MessageFeedback from '@deepseek-ai/dsh-message-feedback'
  12. import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
  13. import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  14. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  15. import DeepSeekLlmApiExtensions from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
  16. import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  17. import * as SessionLogDeepSeek from '../src/index.ts'
  18. import type { DeepSeekSessionLogExtension } from '../src/types.ts'
  19. let root: string | undefined
  20. let ctx: Context | undefined
  21. let server: MockLlmServer | undefined
  22. afterEach(async () => {
  23. await ctx?.fiber.dispose()
  24. await server?.close()
  25. if (root !== undefined) await rm(root, { recursive: true, force: true })
  26. ctx = undefined
  27. server = undefined
  28. root = undefined
  29. vi.unstubAllEnvs()
  30. })
  31. it('uploads freeform feedback and message put/edit/delete through the unchanged provider route', async () => {
  32. root = await mkdtemp(join(tmpdir(), 'dsh-feedback-upload-'))
  33. vi.stubEnv('DSH_HOME', root)
  34. vi.stubEnv('DEEPSEEK_API_KEY', 'feedback-test-key')
  35. server = await startMockLlmServer({ sequence: ['invalid_request', 'success', 'success'] })
  36. const modules = new Map<string, unknown>([
  37. ['@deepseek-ai/dsh-session', SessionStore],
  38. ['@deepseek-ai/dsh-session-persistence-jsonl', JsonlSessionPersistence],
  39. ['@deepseek-ai/dsh-message-feedback', MessageFeedback],
  40. ['@deepseek-ai/dsh-llm', LlmRuntime],
  41. ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek],
  42. ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensions],
  43. ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek],
  44. ])
  45. const config = join(root, 'cordis.yml')
  46. await writeFile(config, JSON.stringify([...modules.keys()].map(name => ({
  47. name,
  48. ...name === '@deepseek-ai/dsh-session-persistence-jsonl'
  49. ? { config: { root: join(root!, 'sessions'), compression: 'none' } }
  50. : name === '@deepseek-ai/dsh-message-feedback'
  51. ? { config: { maxNoteBytes: 1024 } }
  52. : name === '@deepseek-ai/dsh-llm-deepseek'
  53. ? { config: { baseURL: server!.baseURL } }
  54. : name === '@deepseek-ai/dsh-session-log-deepseek'
  55. ? { config: { enabled: true } }
  56. : {},
  57. }))))
  58. ctx = new Context()
  59. ctx.baseUrl = pathToFileURL(root).href + '/'
  60. await ctx.plugin(Loader)
  61. ctx.loader.builtins.include = Include
  62. ctx.loader.internal = {
  63. version: 'v2',
  64. async import(specifier: string) {
  65. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  66. return modules.get(specifier)
  67. },
  68. } as unknown as NonNullable<typeof ctx.loader.internal>
  69. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
  70. await ctx.loader.await()
  71. expect([...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)).toEqual([])
  72. const session = ctx.sessions.create(SessionId('feedback-upload'))
  73. const handle = await ctx.sessionPersistence.create(session.header)
  74. try {
  75. const user = createUserMessage({ content: [{ type: 'text', text: 'Question' }], source: { kind: 'user' } })
  76. const assistant = createAssistantMessage({ content: [{ type: 'text', text: 'Answer' }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
  77. session.append('user/message', user, { surfaceOp: 'append' })
  78. session.append('assistant/message', { message: assistant, stream: [], turn: 1, step: 1 }, { surfaceOp: 'append' })
  79. const messages = session.deriveMessages()
  80. recordFeedback(session, { text: ' The session needs a clearer explanation. ' })
  81. const created = await ctx.messageFeedback.put({ sessionId: session.id, messageId: assistant.id, rating: 'negative', note: 'Explain the result.', ifVersion: null })
  82. if (!created.ok) throw new Error(created.error.code)
  83. const initialPrefix = session.snapshotEvents()
  84. const request = async () => {
  85. const chunks = []
  86. for await (const chunk of ctx!.llm.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash', sessionId: session.id, messages: session.deriveMessages() })) chunks.push(chunk)
  87. return chunks.at(-1)
  88. }
  89. expect(await request()).toMatchObject({ type: 'finish', reason: { kind: 'error' } })
  90. expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1)
  91. expect(await request()).toMatchObject({ type: 'finish', reason: { kind: 'stop' } })
  92. const first = (server.requests[0]!.body as { dsh_session_log: DeepSeekSessionLogExtension }).dsh_session_log
  93. const retry = (server.requests[1]!.body as { dsh_session_log: DeepSeekSessionLogExtension }).dsh_session_log
  94. expect(retry).toEqual(first)
  95. expect(first.events).toEqual(initialPrefix)
  96. expect(first.events.slice(-2)).toMatchObject([
  97. { type: 'feedback/record', data: { text: 'The session needs a clearer explanation.' } },
  98. { type: 'feedback/message-put', data: { sessionId: session.id, item: created.value } },
  99. ])
  100. expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(first.throughSeq)
  101. const edited = await ctx.messageFeedback.put({
  102. sessionId: session.id, messageId: assistant.id, rating: 'positive',
  103. note: 'The explanation is clear now.', ifVersion: created.value.version,
  104. })
  105. if (!edited.ok) throw new Error(edited.error.code)
  106. expect(await ctx.messageFeedback.delete({
  107. sessionId: session.id, messageId: assistant.id, ifVersion: edited.value.version,
  108. })).toEqual({ ok: true, value: { absent: true } })
  109. expect(await request()).toMatchObject({ type: 'finish', reason: { kind: 'stop' } })
  110. const suffix = (server.requests[2]!.body as { dsh_session_log: DeepSeekSessionLogExtension }).dsh_session_log
  111. expect(suffix.afterSeq).toBe(first.throughSeq)
  112. expect(suffix.events).toMatchObject([
  113. { type: 'session-log-deepseek/delivery-accepted' },
  114. { type: 'feedback/message-put', data: { sessionId: session.id, item: edited.value } },
  115. { type: 'feedback/message-delete', data: { sessionId: session.id, messageId: assistant.id } },
  116. ])
  117. expect(suffix.events.every(event => event.seq > first.throughSeq)).toBe(true)
  118. expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(suffix.throughSeq)
  119. expect(session.deriveMessages()).toEqual(messages)
  120. expect(await ctx.messageFeedback.list({ sessionId: session.id })).toEqual({ ok: true, value: { items: [] } })
  121. for (const wire of server.requests) {
  122. expect(wire.path).toBe('/chat/completions')
  123. expect(wire.body).not.toHaveProperty('dsh_feedback')
  124. expect(wire.body).toMatchObject({ model: 'deepseek-v4-flash', messages: [
  125. { role: 'user', content: 'Question' },
  126. { role: 'assistant', content: 'Answer' },
  127. ] })
  128. }
  129. await ctx.sessions.flush(session)
  130. expect((await handle.read()).events).toEqual(session.snapshotEvents())
  131. } finally {
  132. await handle.close()
  133. }
  134. })