projection.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
  5. import type { Session, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session'
  6. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  7. import SessionTitleService from '@deepseek-ai/dsh-session-title'
  8. const CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 }
  9. async function harness(withTitleService: boolean): Promise<{ ctx: Context; session: Session }> {
  10. const ctx = new Context()
  11. await ctx.plugin(SessionStore)
  12. await ctx.plugin(SessionProjectionRegistry)
  13. if (withTitleService) await ctx.plugin(SessionTitleService, CONFIG)
  14. return { ctx, session: ctx.sessions.create(SessionId('titled')) }
  15. }
  16. function appendTitle(session: Session, title: string): SessionSeqType {
  17. const messageSeq = session.snapshotEvents().find(event =>
  18. event.type === 'user/message' && event.data.source.kind === 'user')?.seq
  19. ?? session.append('user/message', createUserMessage({
  20. content: [{ type: 'text', text: 'Title source' }],
  21. source: { kind: 'user' },
  22. }), { surfaceOp: 'append' }).seq
  23. return session.append('session/title', {
  24. title, messageSeqs: [messageSeq], source: { kind: 'fallback' },
  25. }).seq
  26. }
  27. describe('title projection unit', () => {
  28. it('serves null before the first title event', async () => {
  29. const { ctx, session } = await harness(true)
  30. const snapshot = ctx.sessionProjections.snapshot(session)
  31. expect(snapshot.values.title).toBeNull()
  32. expect(ctx.sessionProjections.checkpoint(session).title).toEqual({ ver: 1, seq: -1, val: null })
  33. })
  34. it('serves the latest title last-wins and notifies the change feed with the causing seq', async () => {
  35. const { ctx, session } = await harness(true)
  36. const changes: { key: string; value: unknown; seq: SessionSeqType }[] = []
  37. ctx.sessionProjections.onChanged((_session, key, value, seq) => {
  38. changes.push({ key, value, seq })
  39. })
  40. const firstSeq = appendTitle(session, 'First title')
  41. const secondSeq = appendTitle(session, 'Second title')
  42. session.append('turn/start', { turn: 1 })
  43. expect(changes).toEqual([
  44. { key: 'title', value: 'First title', seq: firstSeq },
  45. { key: 'title', value: 'Second title', seq: secondSeq },
  46. ])
  47. const snapshot = ctx.sessionProjections.snapshot(session)
  48. expect(snapshot.values.title).toBe('Second title')
  49. expect(snapshot.asOfSeq).toBe(session.seq - 1)
  50. })
  51. it('reads the version-1 string checkpoint format used by existing title caches', async () => {
  52. const { ctx } = await harness(true)
  53. expect(ctx.sessionProjections.viewCheckpoint({
  54. title: { ver: 1, seq: SessionSeq(8), val: 'Cached title' },
  55. })).toEqual({ title: 'Cached title' })
  56. })
  57. it('folds titles already in the log when the service mounts late (lazy cell build)', async () => {
  58. const { ctx, session } = await harness(false)
  59. appendTitle(session, 'Pre-mount title')
  60. await ctx.plugin(SessionTitleService, CONFIG)
  61. expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Pre-mount title')
  62. })
  63. it('has no title key without the title service, and drops it when the service unloads (HMR safety)', async () => {
  64. const { ctx, session } = await harness(false)
  65. expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
  66. const fiber = await ctx.plugin(SessionTitleService, CONFIG)
  67. appendTitle(session, 'Ephemeral')
  68. expect(ctx.sessionProjections.snapshot(session).values.title).toBe('Ephemeral')
  69. await fiber.dispose()
  70. expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
  71. })
  72. it('keeps thousands of title inputs as a bounded aggregate and checkpoints it', async () => {
  73. const { ctx, session } = await harness(false)
  74. session.append('turn/start', { turn: 1 })
  75. for (let index = 0; index < 5_000; index++) {
  76. session.append('user/message', createUserMessage({
  77. content: [{ type: 'text', text: `message ${String(index)}` }],
  78. source: { kind: 'user' },
  79. }), { surfaceOp: 'append' })
  80. }
  81. await ctx.plugin(SessionTitleService, CONFIG)
  82. const state = ctx.sessionProjections.stateOf(session, 'titleInput')
  83. expect(state?.count).toBe(5_000)
  84. expect(state?.first?.text).toBe('message 0')
  85. expect(state?.lastSeq).toBe(session.seq - 1)
  86. expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined()
  87. })
  88. it('rejects a version-matching checkpoint with inconsistent title input counters', async () => {
  89. const { ctx, session } = await harness(true)
  90. const checkpoint = ctx.sessionProjections.checkpoint(session)
  91. const row = checkpoint.titleInput
  92. expect(row).toBeDefined()
  93. const invalidStates = [
  94. { first: null, count: 1, lastSeq: null },
  95. { first: { seq: 1, text: 'first' }, count: 1, lastSeq: null },
  96. { first: { seq: 1, text: 'first' }, count: 0, lastSeq: 1 },
  97. { first: { seq: 2, text: 'first' }, count: 1, lastSeq: 1 },
  98. ]
  99. for (const state of invalidStates) {
  100. const malformed = {
  101. ...checkpoint,
  102. titleInput: { ...row!, val: state },
  103. }
  104. expect(() => ctx.sessionProjections.restore(
  105. malformed, [], SessionLogOffset(0), session.header, session.inheritedEventCount,
  106. ))
  107. .toThrow(/title input state must pair its count with first and last message seqs/)
  108. }
  109. expect(() => ctx.sessionProjections.restore({
  110. ...checkpoint,
  111. titleInput: {
  112. ...row!,
  113. val: { first: { seq: 1, text: 'first' }, count: 1, lastSeq: 1 },
  114. },
  115. }, [], SessionLogOffset(0), session.header, session.inheritedEventCount)).not.toThrow()
  116. })
  117. })