loader-composition.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  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, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Include from '@deepseek-ai/cordis-plugin-include'
  8. import Loader from '@deepseek-ai/cordis-plugin-loader'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  11. import Storage from '@deepseek-ai/dsh-storage'
  12. import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
  13. import * as StorageJson from '@deepseek-ai/dsh-storage-json'
  14. import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  15. import MessageFeedbackService from '../src/index.ts'
  16. import { appendMessageFixture } from './helpers.ts'
  17. let root: string | undefined
  18. const contexts: Context[] = []
  19. afterEach(async () => {
  20. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  21. if (root !== undefined) await rm(root, { recursive: true, force: true })
  22. root = undefined
  23. })
  24. async function loadComposition(configPath: string): Promise<Context> {
  25. const ctx = new Context()
  26. contexts.push(ctx)
  27. ctx.baseUrl = pathToFileURL(root as string).href + '/'
  28. await ctx.plugin(Loader)
  29. ctx.loader.builtins.include = Include
  30. const modules = new Map<string, unknown>([
  31. ['@deepseek-ai/dsh-session', SessionStore],
  32. ['@deepseek-ai/dsh-session-persistence-jsonl', JsonlSessionPersistence],
  33. ['@deepseek-ai/dsh-storage', Storage],
  34. ['@deepseek-ai/dsh-storage-json', StorageJson],
  35. ['@deepseek-ai/dsh-storage-domain', StorageDomain],
  36. ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService],
  37. ])
  38. ctx.loader.internal = {
  39. version: 'v2',
  40. async import(specifier: string) {
  41. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  42. return modules.get(specifier)
  43. },
  44. } as unknown as NonNullable<typeof ctx.loader.internal>
  45. await ctx.loader.create({
  46. name: 'cordis:include',
  47. config: { path: pathToFileURL(configPath).href },
  48. })
  49. await ctx.loader.await()
  50. const unloaded = [...ctx.loader.entries()]
  51. .filter(entry => entry.fiber === undefined && !entry.disabled)
  52. .map(entry => entry.options.name)
  53. expect(unloaded).toEqual([])
  54. return ctx
  55. }
  56. describe('message feedback through a real Loader composition', () => {
  57. it('persists a checkpointed target and its sidecar across a cold restart', async () => {
  58. root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-'))
  59. const configPath = join(root, 'cordis.yml')
  60. await writeFile(configPath, [
  61. "- name: '@deepseek-ai/dsh-session'",
  62. "- name: '@deepseek-ai/dsh-session-persistence-jsonl'",
  63. ' config:',
  64. ` root: ${JSON.stringify(join(root, 'sessions'))}`,
  65. ' compression: none',
  66. "- name: '@deepseek-ai/dsh-storage'",
  67. "- name: '@deepseek-ai/dsh-storage-json'",
  68. ' config:',
  69. ` root: ${JSON.stringify(join(root, 'storage'))}`,
  70. "- name: '@deepseek-ai/dsh-storage-domain'",
  71. ' config:',
  72. ' backend: json',
  73. "- name: '@deepseek-ai/dsh-message-feedback'",
  74. ' config:',
  75. ' maxNoteBytes: 32',
  76. '',
  77. ].join('\n'))
  78. const first = await loadComposition(configPath)
  79. expect(first.messageFeedback.typertRemote.namespace).toBe('messageFeedback')
  80. expect(remoteMethods(first.messageFeedback).map(marker => marker.method))
  81. .toEqual(['list', 'put', 'delete'])
  82. const session = first.sessions.create(SessionId('loader-feedback'), {
  83. meta: { cwd: root },
  84. })
  85. // The mounted backend routes this published session's `session/event`
  86. // batches and `session/flush` barriers into its active write handle.
  87. const writeHandle = await first.sessionPersistence.create(session.header)
  88. const fixture = appendMessageFixture(session)
  89. const put = await first.messageFeedback.put({
  90. sessionId: session.id,
  91. messageId: fixture.assistantMessageIds[0],
  92. rating: 'positive',
  93. note: 'survives restart',
  94. ifVersion: null,
  95. })
  96. if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`)
  97. const readHandle = await first.sessionPersistence.open(session.id, 'read')
  98. const durableEvents = await readHandle.read()
  99. await readHandle.close()
  100. expect(durableEvents.some(event =>
  101. event.type === 'assistant/message'
  102. && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true)
  103. await writeHandle.close()
  104. await first.fiber.dispose()
  105. contexts.splice(contexts.indexOf(first), 1)
  106. const second = await loadComposition(configPath)
  107. await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({
  108. ok: true,
  109. value: { items: [put.value] },
  110. })
  111. expect(second.sessions.get(session.id)).toBeUndefined()
  112. })
  113. })