system-prompt-projection.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
  5. import type { Session, SurfaceIntent } from '@deepseek-ai/dsh-session'
  6. import { SystemPromptProjection } from '../src/runtime-context.ts'
  7. import type { SystemPromptCommit, SystemPromptDecisionInput } from '../src/runtime-context.ts'
  8. const SOURCE = '@deepseek-ai/dsh-system-prompt'
  9. const REPLACING: SystemPromptDecisionInput = { inHistory: false, startsSeries: false }
  10. const CONTINUING: SystemPromptDecisionInput = { inHistory: true, startsSeries: false }
  11. const NEW_SERIES: SystemPromptDecisionInput = { inHistory: true, startsSeries: true }
  12. async function sessionStore(): Promise<Context> {
  13. const ctx = new Context()
  14. await ctx.plugin(SessionStore)
  15. return ctx
  16. }
  17. function appendUser(session: Session, text: string) {
  18. return session.append('user/message', createUserMessage({
  19. content: [{ type: 'text', text }],
  20. source: { kind: 'user' },
  21. }), { surfaceOp: 'append' })
  22. }
  23. function commit(session: Session, turn: number, decision: SystemPromptCommit | undefined) {
  24. if (decision === undefined) throw new Error('expected a system prompt commit')
  25. return session.append('system/message', { turn, step: 1, message: decision.message }, decision.intent)
  26. }
  27. function replaceOf(seq: number): SurfaceIntent {
  28. const at = SessionSeq(seq)
  29. return { surfaceOp: { op: 'replace', startSeq: at, endSeq: at }, sourceEventSeqs: [at] }
  30. }
  31. describe('SystemPromptProjection', () => {
  32. it('clears multiblock and nontext system nodes rather than treating them as dormant', async () => {
  33. const ctx = await sessionStore()
  34. try {
  35. const session = ctx.sessions.create(SessionId('system-prompt-multiblock'))
  36. const base = createSystemMessage('old', SOURCE)
  37. session.append('system/message', { turn: 1, step: 1, message: {
  38. ...base, content: [{ type: 'text', text: 'old ' }, { type: 'text', text: 'instructions' }],
  39. } }, { surfaceOp: 'append' })
  40. appendUser(session, 'hello')
  41. session.append('system/message', { turn: 1, step: 1, message: {
  42. ...createSystemMessage('tail', SOURCE), content: [{ type: 'reasoning', text: 'retained content' }],
  43. } }, { surfaceOp: 'append' })
  44. const projection = new SystemPromptProjection(session)
  45. for (const update of projection.project('', CONTINUING)) commit(session, 2, update)
  46. expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
  47. expect(projection.project('', CONTINUING)).toEqual([])
  48. } finally {
  49. await ctx.fiber.dispose()
  50. }
  51. })
  52. it('appends the first rendered prompt, skips an unchanged one, and replaces the retained node on change', async () => {
  53. const ctx = await sessionStore()
  54. const session = ctx.sessions.create(SessionId('system-prompt-fresh'))
  55. const projection = new SystemPromptProjection(session)
  56. const first = projection.project('v1', REPLACING)[0]
  57. expect(first?.intent).toEqual({ surfaceOp: 'append' })
  58. expect(first?.message.role).toBe('system')
  59. expect(first?.message.source).toEqual({ kind: 'plugin', plugin: SOURCE })
  60. const head = commit(session, 1, first)
  61. appendUser(session, 'hello')
  62. expect(projection.project('v1', REPLACING)[0]).toBeUndefined()
  63. const second = projection.project('v2', REPLACING)[0]
  64. expect(second?.intent).toEqual(replaceOf(head.seq))
  65. const replaced = commit(session, 2, second)
  66. expect(session.surface.nodes[0]).toBe(replaced.seq)
  67. expect(projection.project('v2', REPLACING)[0]).toBeUndefined()
  68. // An emptied prompt keeps the head node with empty content, which projects to no wire message.
  69. const emptied = projection.project('', REPLACING)[0]
  70. expect(emptied?.message.content).toEqual([])
  71. commit(session, 3, emptied)
  72. expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
  73. expect(projection.project('', REPLACING)[0]).toBeUndefined()
  74. expect(projection.project('v3', REPLACING)[0]?.intent).toMatchObject({ surfaceOp: { op: 'replace' } })
  75. })
  76. it('reserves an empty head before user history and replaces it when a prompt appears', async () => {
  77. const ctx = await sessionStore()
  78. try {
  79. const session = ctx.sessions.create(SessionId('system-prompt-empty-head'))
  80. const projection = new SystemPromptProjection(session)
  81. const first = projection.project('', REPLACING)[0]
  82. expect(first?.intent).toEqual({ surfaceOp: 'append' })
  83. expect(first?.message.content).toEqual([])
  84. const head = session.append('system/message', { turn: 1, step: 1, message: first!.message }, first!.intent)
  85. const user = appendUser(session, 'hello')
  86. expect(session.surface.nodes).toEqual([head.seq, user.seq])
  87. expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
  88. expect(projection.project('', REPLACING)).toEqual([])
  89. const next = projection.project('Follow this guidance.', REPLACING)[0]
  90. expect(next?.intent).toEqual({
  91. surfaceOp: { op: 'replace', startSeq: head.seq, endSeq: head.seq },
  92. sourceEventSeqs: [head.seq],
  93. })
  94. const replacement = session.append('system/message', { turn: 2, step: 1, message: next!.message }, next!.intent)
  95. expect(session.surface.nodes).toEqual([replacement.seq, user.seq])
  96. expect(session.deriveMessages()).toEqual([next!.message, user.data])
  97. } finally {
  98. await ctx.fiber.dispose()
  99. }
  100. })
  101. it('reads the surviving system node from the log, including one restored as "no prompt"', async () => {
  102. const ctx = await sessionStore()
  103. const session = ctx.sessions.create(SessionId('system-prompt-replay'))
  104. const stale = session.append('system/message', { turn: 1, step: 1, message: createSystemMessage('stale', SOURCE) }, { surfaceOp: 'append' })
  105. appendUser(session, 'hello')
  106. const current = session.append('system/message', { turn: 2, step: 1, message: createSystemMessage('current', SOURCE) }, replaceOf(stale.seq))
  107. const projection = new SystemPromptProjection(session)
  108. expect(projection.project('current', REPLACING)[0]).toBeUndefined()
  109. expect(projection.project('next', REPLACING)[0]?.intent).toEqual(replaceOf(current.seq))
  110. const emptySession = ctx.sessions.create(SessionId('system-prompt-empty-replay'))
  111. const empty = emptySession.append('system/message', { turn: 1, step: 1, message: createSystemMessage('', SOURCE) }, { surfaceOp: 'append' })
  112. appendUser(emptySession, 'hello')
  113. const emptyProjection = new SystemPromptProjection(emptySession)
  114. expect(emptyProjection.project('', REPLACING)[0]).toBeUndefined()
  115. expect(emptyProjection.project('now present', REPLACING)[0]?.intent).toEqual(replaceOf(empty.seq))
  116. })
  117. it('appends again after a replacement shadowed a system node that was not the head', async () => {
  118. const ctx = await sessionStore()
  119. const session = ctx.sessions.create(SessionId('system-prompt-shadowed'))
  120. const projection = new SystemPromptProjection(session)
  121. appendUser(session, 'before any prompt')
  122. const late = projection.project('late prompt', REPLACING)[0]
  123. expect(late?.intent).toEqual({ surfaceOp: 'append' })
  124. const node = commit(session, 1, late)
  125. expect(session.surface.nodes.indexOf(node.seq)).toBe(1)
  126. expect(projection.project('late prompt', REPLACING)[0]).toBeUndefined()
  127. session.append('user/message', createUserMessage({
  128. content: [{ type: 'text', text: 'summary' }],
  129. source: { kind: 'plugin', plugin: 'test-compaction' },
  130. }), replaceOf(node.seq))
  131. expect(projection.project('late prompt', REPLACING)[0]?.intent).toEqual({ surfaceOp: 'append' })
  132. })
  133. it('appends a changed prompt after cached history on an in-history route while the series continues', async () => {
  134. const ctx = await sessionStore()
  135. const session = ctx.sessions.create(SessionId('system-prompt-in-history'))
  136. const projection = new SystemPromptProjection(session)
  137. const head = commit(session, 1, projection.project('v1', CONTINUING)[0])
  138. appendUser(session, 'hello')
  139. expect(projection.project('v1', CONTINUING)[0]).toBeUndefined()
  140. const update = projection.project('v2', CONTINUING)[0]
  141. expect(update?.intent).toEqual({ surfaceOp: 'append' })
  142. const appended = commit(session, 2, update)
  143. expect(session.surface.nodes).toEqual([head.seq, expect.any(Number), appended.seq])
  144. expect(session.deriveMessages().map(message => message.role)).toEqual(['system', 'user', 'system'])
  145. expect(projection.project('v2', CONTINUING)[0]).toBeUndefined()
  146. // The effective prompt is the latest surviving node: a further change compares against it.
  147. appendUser(session, 'more')
  148. const third = projection.project('v3', CONTINUING)[0]
  149. expect(third?.intent).toEqual({ surfaceOp: 'append' })
  150. commit(session, 3, third)
  151. expect(session.deriveMessages().flatMap(message => message.role === 'system' ? [message.content[0]] : []))
  152. .toEqual([{ type: 'text', text: 'v1' }, { type: 'text', text: 'v2' }, { type: 'text', text: 'v3' }])
  153. })
  154. it('re-baselines node 0 and empties later active nodes at a series start', async () => {
  155. const ctx = await sessionStore()
  156. const session = ctx.sessions.create(SessionId('system-prompt-series-start'))
  157. const projection = new SystemPromptProjection(session)
  158. const head = commit(session, 1, projection.project('v1', CONTINUING)[0])
  159. appendUser(session, 'hello')
  160. // A new series already costs the cache, so the change folds into node 0.
  161. const rebased = projection.project('v2', NEW_SERIES)[0]
  162. expect(rebased?.intent).toEqual(replaceOf(head.seq))
  163. const newHead = commit(session, 2, rebased)
  164. expect(session.surface.nodes[0]).toBe(newHead.seq)
  165. appendUser(session, 'again')
  166. const later = commit(session, 3, projection.project('v3', CONTINUING)[0])
  167. const updates = projection.project('v4', NEW_SERIES)
  168. expect(updates.map(update => update.intent)).toEqual([replaceOf(later.seq), replaceOf(newHead.seq)])
  169. for (const update of updates) commit(session, 4, update)
  170. expect(session.deriveMessages().filter(message => message.role === 'system').map(message => message.content))
  171. .toEqual([[{ type: 'text', text: 'v4' }]])
  172. expect(projection.project('v4', CONTINUING)).toEqual([])
  173. })
  174. it('empties all active nodes when clearing an in-history prompt', async () => {
  175. const ctx = await sessionStore()
  176. const session = ctx.sessions.create(SessionId('system-prompt-in-history-clear'))
  177. const projection = new SystemPromptProjection(session)
  178. commit(session, 1, projection.project('v1', CONTINUING)[0])
  179. appendUser(session, 'hello')
  180. const update = commit(session, 2, projection.project('v2', CONTINUING)[0])
  181. const cleared = projection.project('', CONTINUING)
  182. expect(cleared).toHaveLength(2)
  183. expect(cleared[0]?.intent).toEqual(replaceOf(update.seq))
  184. for (const empty of cleared) {
  185. expect(empty.message.content).toEqual([])
  186. commit(session, 3, empty)
  187. }
  188. expect(session.deriveMessages().map(message => message.role)).toEqual(['user'])
  189. expect(projection.project('', CONTINUING)).toEqual([])
  190. expect(projection.project('v3', REPLACING).map(commit => commit.message.content)).toEqual([[{ type: 'text', text: 'v3' }]])
  191. })
  192. })