loader-composition.spec.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  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. ' writeBatchMaxDelayMs: 1',
  67. "- name: '@deepseek-ai/dsh-storage'",
  68. "- name: '@deepseek-ai/dsh-storage-json'",
  69. ' config:',
  70. ` root: ${JSON.stringify(join(root, 'storage'))}`,
  71. "- name: '@deepseek-ai/dsh-storage-domain'",
  72. ' config:',
  73. ' backend: json',
  74. "- name: '@deepseek-ai/dsh-message-feedback'",
  75. ' config:',
  76. ' maxNoteBytes: 32',
  77. '',
  78. ].join('\n'))
  79. const first = await loadComposition(configPath)
  80. expect(first.messageFeedback.typertRemote.namespace).toBe('messageFeedback')
  81. expect(remoteMethods(first.messageFeedback).map(marker => marker.method))
  82. .toEqual(['list', 'put', 'delete'])
  83. const session = first.sessions.create(SessionId('loader-feedback'), {
  84. meta: { cwd: root },
  85. })
  86. const fixture = appendMessageFixture(session)
  87. const put = await first.messageFeedback.put({
  88. sessionId: session.id,
  89. messageId: fixture.assistantMessageIds[0],
  90. rating: 'positive',
  91. note: 'survives restart',
  92. ifVersion: null,
  93. })
  94. if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`)
  95. const durable = await first.sessionPersistence.readFrom(session.id, 0)
  96. expect(durable.events.some(event =>
  97. event.type === 'assistant/message'
  98. && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true)
  99. await first.fiber.dispose()
  100. contexts.splice(contexts.indexOf(first), 1)
  101. const second = await loadComposition(configPath)
  102. await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({
  103. ok: true,
  104. value: { items: [put.value] },
  105. })
  106. expect(second.sessions.get(session.id)).toBeUndefined()
  107. })
  108. })