properties.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. /**
  2. * Deterministic property tests for inbox scheduling: every sent message logs
  3. * once, turn numbers increase, and status follows idle→running→idle/disposed.
  4. * Schedules advance on status events rather than wall-clock sleeps.
  5. */
  6. import { describe, expect, it } from 'vitest'
  7. import { Context } from 'cordis'
  8. import LlmService from '@deepseek-ai/dsh-llm'
  9. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  10. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  11. import SessionStore from '@deepseek-ai/dsh-session'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import ToolRegistry from '@deepseek-ai/dsh-tools'
  14. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  15. import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
  16. import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  17. import fc from 'fast-check'
  18. /** A never-exhausting adapter: every model call returns the same short reply. */
  19. class EchoAdapter extends LlmAdapter {
  20. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  21. if (options.signal?.aborted) throw new Error('aborted')
  22. const text = 'ok'
  23. yield { type: 'block-start', index: 0, blockType: 'text' }
  24. yield { type: 'text-delta', index: 0, text }
  25. yield { type: 'block-end', index: 0, block: { type: 'text', text } }
  26. yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
  27. yield { type: 'finish', reason: { kind: 'stop' } }
  28. }
  29. }
  30. async function harness() {
  31. const ctx = new Context()
  32. await ctx.plugin(LlmService)
  33. await ctx.plugin(SessionStore)
  34. await ctx.plugin(SystemPrompt)
  35. await ctx.plugin(ToolRegistry)
  36. await ctx.plugin(AgentRegistry)
  37. await ctx.plugin(AgentExecutionProvider)
  38. await ctx.plugin(AgentLoop, { agents: [] })
  39. ctx.llm.registerAdapter(['mock'], new EchoAdapter())
  40. return ctx
  41. }
  42. /** Resolve on the agent's next transition to idle (event-based, not polled). */
  43. function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  44. return new Promise((resolve) => {
  45. const dispose = ctx.on('agent/status', (subject, status) => {
  46. if (subject === agent && status === 'idle') {
  47. dispose()
  48. resolve()
  49. }
  50. })
  51. })
  52. }
  53. /** Record every status transition for the legal-machine assertion. Returns
  54. * the seen list plus a disposer for the listener (per the registry convention). */
  55. function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
  56. const seen: string[] = []
  57. const dispose = ctx.on('agent/status', (subject, status) => {
  58. if (subject === agent) seen.push(status)
  59. })
  60. return { seen, dispose }
  61. }
  62. function userMessageTexts(agent: ReactLoopAgent): string[] {
  63. return agent.session.events
  64. .filter(e => e.type === 'user/message')
  65. .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
  66. }
  67. function turnNumbers(agent: ReactLoopAgent): number[] {
  68. return agent.session.events
  69. .filter(e => e.type === 'turn/start')
  70. .map(e => (e.data as { turn: number }).turn)
  71. }
  72. /** Assert a status trace is a legal run: idle/running alternating, ending idle. */
  73. function assertLegalStatusTrace(trace: string[]): void {
  74. for (let i = 1; i < trace.length; i++) {
  75. expect(trace[i]).not.toBe(trace[i - 1]) // no repeats (setStatus dedups)
  76. }
  77. for (const s of trace) expect(['idle', 'running']).toContain(s)
  78. }
  79. describe('agent loop scheduling properties', () => {
  80. it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
  81. await fc.assert(fc.asyncProperty(
  82. fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
  83. async (texts) => {
  84. const ctx = await harness()
  85. try {
  86. const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
  87. const { seen: trace } = recordStatus(ctx, agent)
  88. const idle = nextIdle(ctx, agent)
  89. // Send all in one synchronous tick: they queue before the loop wakes.
  90. for (const text of texts) agent.send([{ type: 'text', text }])
  91. await idle
  92. // No message lost: every send appears as a user/message, in order.
  93. expect(userMessageTexts(agent)).toEqual(texts)
  94. // A synchronous burst batches into exactly one turn.
  95. expect(turnNumbers(agent)).toEqual([1])
  96. assertLegalStatusTrace(trace)
  97. } finally {
  98. await ctx.fiber.dispose()
  99. }
  100. },
  101. ), { numRuns: 25, timeout: 2000 })
  102. })
  103. it('sequential sends each get their own turn with increasing numbers', async () => {
  104. await fc.assert(fc.asyncProperty(
  105. fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 5 }),
  106. async (texts) => {
  107. const ctx = await harness()
  108. try {
  109. const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
  110. for (const text of texts) {
  111. const idle = nextIdle(ctx, agent)
  112. agent.send([{ type: 'text', text }])
  113. await idle
  114. }
  115. // Each send was drained at a separate turn start: N turns, 1..N.
  116. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
  117. expect(userMessageTexts(agent)).toEqual(texts)
  118. } finally {
  119. await ctx.fiber.dispose()
  120. }
  121. },
  122. ), { numRuns: 20, timeout: 2000 })
  123. })
  124. it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
  125. // Each step is a (text, settle?) pair: settle=true awaits idle before the
  126. // next send (own turn); settle=false sends in the same tick (batches).
  127. const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
  128. await fc.assert(fc.asyncProperty(
  129. fc.array(stepArb, { minLength: 1, maxLength: 6 }),
  130. async (steps) => {
  131. const ctx = await harness()
  132. try {
  133. const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
  134. // Capture before each send; the last waiter covers the final turn, and
  135. // awaiting an already-settled earlier waiter is harmless.
  136. let lastIdle: Promise<void> | undefined
  137. for (const step of steps) {
  138. const idle = nextIdle(ctx, agent)
  139. lastIdle = idle
  140. agent.send([{ type: 'text', text: step.text }])
  141. if (step.settle) await idle
  142. }
  143. await lastIdle
  144. // No message lost or reordered, regardless of batching.
  145. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
  146. // Turn numbers are a strictly increasing 1..N prefix (N = turn count).
  147. const turns = turnNumbers(agent)
  148. expect(turns).toEqual(turns.map((_, i) => i + 1))
  149. // Every message landed in some turn; turns never exceed messages.
  150. expect(turns.length).toBeLessThanOrEqual(steps.length)
  151. expect(turns.length).toBeGreaterThanOrEqual(1)
  152. } finally {
  153. await ctx.fiber.dispose()
  154. }
  155. },
  156. ), { numRuns: 25, timeout: 3000 })
  157. })
  158. })