invariant.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  4. import InvariantService from '@deepseek-ai/dsh-invariants'
  5. import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
  6. import { createUserMessage, markAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
  7. async function setup(): Promise<Context> {
  8. const ctx = new Context()
  9. await ctx.plugin(SessionStore)
  10. await ctx.plugin(InvariantService)
  11. await ctx.plugin(AgentLoopInvariant)
  12. return ctx
  13. }
  14. function dispatch(ctx: Context, options: unknown): void {
  15. void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
  16. }
  17. function loopRequest<T extends object>(options: T): Readonly<T> {
  18. markAgentLoopRequest(options as GenerateOptions)
  19. return Object.freeze(options)
  20. }
  21. async function requestSetup() {
  22. const ctx = await setup()
  23. const session = ctx.sessions.create(SessionId('req-check'))
  24. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  25. session.append('user/message', createUserMessage({
  26. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  27. }), { surfaceOp: 'append' })
  28. const boundary = session.deriveMessages()
  29. session.append('step/start', { turn: 1, step: 1 })
  30. session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
  31. return { ctx, session, boundary }
  32. }
  33. describe('request-reconstruction invariant', () => {
  34. it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
  35. const { ctx, session, boundary } = await requestSetup()
  36. const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
  37. expect(() => { dispatch(ctx, options) }).not.toThrow()
  38. })
  39. it('uses the step boundary rather than content appended afterward', async () => {
  40. const { ctx, session, boundary } = await requestSetup()
  41. session.append('user/message', createUserMessage({
  42. content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' },
  43. }), { surfaceOp: 'append' })
  44. const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
  45. expect(() => { dispatch(ctx, options) }).not.toThrow()
  46. })
  47. it('requires the messages to equal the boundary derivation exactly (no unlogged prefix)', async () => {
  48. const { ctx, session, boundary } = await requestSetup()
  49. const extra = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
  50. expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
  51. .not.toThrow()
  52. expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) })
  53. .toThrow(/diverges from the boundary derivation/)
  54. expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) })
  55. .toThrow(/diverges from the boundary derivation/)
  56. })
  57. it('rejects message and header divergence', async () => {
  58. const { ctx, session, boundary } = await requestSetup()
  59. const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
  60. expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
  61. .toThrow(/diverges from the boundary derivation/)
  62. expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
  63. .toThrow(/diverges from the folded request header/)
  64. })
  65. it('rejects loop requests with no boundary or header', async () => {
  66. const ctx = await setup()
  67. const session = ctx.sessions.create(SessionId('req-bare'))
  68. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  69. const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
  70. expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
  71. session.append('step/start', { turn: 1, step: 1 })
  72. expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
  73. })
  74. it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
  75. const { ctx, session, boundary } = await requestSetup()
  76. expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) })
  77. .toThrow(/frozen messages array/)
  78. expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
  79. expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
  80. expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
  81. .not.toThrow()
  82. const directSession = ctx.sessions.create(SessionId('direct-one-shot'))
  83. expect(() => {
  84. dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id }))
  85. }).not.toThrow()
  86. })
  87. it('rejects malformed requests carrying the loop marker', async () => {
  88. const { ctx, session } = await requestSetup()
  89. const messages: GenerateOptions['messages'] = []
  90. Object.freeze(messages)
  91. expect(() => {
  92. dispatch(ctx, markAgentLoopRequest({ provider: 'p', model: 'm', messages, sessionId: session.id }))
  93. }).toThrow(/request must be frozen/)
  94. expect(() => {
  95. dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
  96. }).toThrow(/carry a session id/)
  97. expect(() => {
  98. dispatch(ctx, loopRequest({
  99. model: 'm',
  100. messages: Object.freeze([]),
  101. sessionId: SessionId('missing-loop-session'),
  102. }))
  103. }).toThrow(/live session id/)
  104. })
  105. it('prepends ahead of a short-circuiting stream listener', async () => {
  106. const ctx = new Context()
  107. await ctx.plugin(SessionStore)
  108. ctx.on('llm/stream', () => (async function* () {})() as never)
  109. await ctx.plugin(InvariantService)
  110. await ctx.plugin(AgentLoopInvariant)
  111. const session = ctx.sessions.create(SessionId('prepend-check'))
  112. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  113. session.append('user/message', createUserMessage({
  114. content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
  115. }), { surfaceOp: 'append' })
  116. session.append('step/start', { turn: 1, step: 1 })
  117. session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
  118. const divergent = loopRequest({
  119. model: 'm',
  120. messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
  121. sessionId: session.id,
  122. })
  123. expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
  124. })
  125. })