properties.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. /**
  2. * Property-based tests for the agent loop's inbox/turn scheduling (the
  3. * property-testing Agent Note). Deterministic by construction: schedules are driven
  4. * through the `agent/status` settle signal (no wall-clock sleeps), so a flake
  5. * is a finding, not timing noise.
  6. *
  7. * Invariants: every sent message appears exactly once in the log (none lost);
  8. * turn numbers strictly increase; status transitions follow
  9. * idle→running→idle, while teardown is a registry lifecycle.
  10. */
  11. import { describe, expect, it } from 'vitest'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import LlmRuntime from '@deepseek-ai/dsh-llm'
  14. import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
  15. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  16. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  17. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  18. import ToolRuntime from '@deepseek-ai/dsh-tools'
  19. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  20. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  21. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  22. import fc from 'fast-check'
  23. /** A never-exhausting adapter: every model call returns the same short reply. */
  24. class EchoAdapter extends LlmAdapter {
  25. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  26. if (options.signal?.aborted) throw new Error('aborted')
  27. const text = 'ok'
  28. yield { type: 'block-start', index: 0, blockType: 'text' }
  29. yield { type: 'text-delta', index: 0, text }
  30. yield { type: 'block-end', index: 0, block: { type: 'text', text } }
  31. yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
  32. yield { type: 'finish', reason: { kind: 'stop' } }
  33. }
  34. }
  35. async function harness() {
  36. const ctx = new Context()
  37. await ctx.plugin(LlmRuntime)
  38. await ctx.plugin(SessionStore)
  39. await ctx.plugin(SessionProjectionRegistry)
  40. await ctx.plugin(SystemPrompt)
  41. await ctx.plugin(ToolRuntime)
  42. await ctx.plugin(AgentRegistry)
  43. await ctx.plugin(AgentLoop, { agents: [] })
  44. ctx.llm.registerAdapter(['mock'], new EchoAdapter())
  45. return ctx
  46. }
  47. /** Resolve on the agent's next transition to idle (event-based, not polled). */
  48. function nextIdle(ctx: Context, agent: Agent): Promise<void> {
  49. return new Promise((resolve) => {
  50. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  51. if (subject === agent && status === 'idle') {
  52. dispose()
  53. resolve()
  54. }
  55. })
  56. })
  57. }
  58. /** Record every status transition for the legal-machine assertion. Returns
  59. * the seen list plus a disposer for the listener (per the registry convention). */
  60. function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
  61. const seen: string[] = []
  62. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  63. if (subject === agent) seen.push(status)
  64. })
  65. return { seen, dispose }
  66. }
  67. function userMessageTexts(agent: Agent): string[] {
  68. return agent.session.snapshotEvents()
  69. .filter(e => e.type === 'user/message')
  70. .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
  71. }
  72. function turnNumbers(agent: Agent): number[] {
  73. return agent.session.snapshotEvents()
  74. .filter(e => e.type === 'turn/start')
  75. .map(e => e.data.turn)
  76. }
  77. function turnEndNumbers(agent: Agent): number[] {
  78. return agent.session.snapshotEvents()
  79. .filter(e => e.type === 'turn/end')
  80. .map(e => (e.data as { turn: number }).turn)
  81. }
  82. function userMessageCountsByTurn(agent: Agent): number[] {
  83. const counts: number[] = []
  84. for (const event of agent.session.snapshotEvents()) {
  85. if (event.type === 'turn/start') counts.push(0)
  86. if (event.type === 'user/message') counts[counts.length - 1]! += 1
  87. }
  88. return counts
  89. }
  90. /** Assert a status trace is a legal run: idle/running alternating, ending idle. */
  91. function assertLegalStatusTrace(trace: string[]): void {
  92. for (let i = 1; i < trace.length; i++) {
  93. expect(trace[i]).not.toBe(trace[i - 1]) // no repeats (setStatus dedups)
  94. }
  95. for (const s of trace) expect(['idle', 'running']).toContain(s)
  96. }
  97. describe('agent loop scheduling properties', () => {
  98. it('a synchronous burst gives every message its own strictly increasing turn', async () => {
  99. await fc.assert(fc.asyncProperty(
  100. fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
  101. async (texts) => {
  102. const ctx = await harness()
  103. try {
  104. const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  105. const { seen: trace } = recordStatus(ctx, agent)
  106. const idle = nextIdle(ctx, agent)
  107. // Send all in one synchronous tick: they queue before the loop wakes.
  108. for (const text of texts) agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  109. await idle
  110. // No message lost: every send appears as a user/message, in order.
  111. expect(userMessageTexts(agent)).toEqual(texts)
  112. // This failure-free fixture maps every item to an independent turn.
  113. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
  114. expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
  115. expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1))
  116. expect(trace).toEqual(['running', 'idle'])
  117. assertLegalStatusTrace(trace)
  118. } finally {
  119. await ctx.fiber.dispose()
  120. }
  121. },
  122. ), { numRuns: 25, timeout: 2000 })
  123. })
  124. it('sequential sends each get their own turn with increasing numbers', async () => {
  125. await fc.assert(fc.asyncProperty(
  126. fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 5 }),
  127. async (texts) => {
  128. const ctx = await harness()
  129. try {
  130. const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  131. for (const text of texts) {
  132. const idle = nextIdle(ctx, agent)
  133. agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  134. await idle
  135. }
  136. // Each send was drained at a separate turn start: N turns, 1..N.
  137. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
  138. expect(userMessageTexts(agent)).toEqual(texts)
  139. } finally {
  140. await ctx.fiber.dispose()
  141. }
  142. },
  143. ), { numRuns: 20, timeout: 2000 })
  144. })
  145. it('mixed settled and same-tick sends preserve one turn per message', async () => {
  146. // Each step optionally waits for idle before the next send; that scheduling
  147. // choice must not change the ordinary message-to-turn mapping.
  148. const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
  149. await fc.assert(fc.asyncProperty(
  150. fc.array(stepArb, { minLength: 1, maxLength: 6 }),
  151. async (steps) => {
  152. const ctx = await harness()
  153. try {
  154. const agent = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
  155. // Capture before each send; the last waiter covers the final turn, and
  156. // awaiting an already-settled earlier waiter is harmless.
  157. let lastIdle: Promise<void> | undefined
  158. for (const step of steps) {
  159. const idle = nextIdle(ctx, agent)
  160. lastIdle = idle
  161. agent.followup(createUserMessage({ content: [{ type: 'text', text: step.text }], source: { kind: 'user' } }))
  162. if (step.settle) await idle
  163. }
  164. await lastIdle
  165. // No message is lost or reordered, regardless of driver timing.
  166. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
  167. // Every item forms one FIFO-ordered turn containing only that message.
  168. const turns = turnNumbers(agent)
  169. expect(turns).toEqual(steps.map((_, i) => i + 1))
  170. expect(turnEndNumbers(agent)).toEqual(turns)
  171. expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1))
  172. } finally {
  173. await ctx.fiber.dispose()
  174. }
  175. },
  176. ), { numRuns: 25, timeout: 3000 })
  177. })
  178. })