mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/sandbox-acp-agent/cordis.yml # examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/loop.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/session/README.md # packages/core/session/src/types.ts # packages/core/session/tests/derived-cache.spec.ts # packages/llm/llm-deepseek/src/index.ts # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/convert.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/call-config.ts # packages/llm/llm/src/index.ts # packages/ui/acp-agent/src/index.ts # packages/ui/acp/tests/harness.ts # packages/ui/jsonrpc/README.md # packages/ui/jsonrpc/src/server.ts # packages/ui/stdio-agent/README.md # packages/ui/stdio-agent/src/index.ts # python/sdk/README.i18n.yaml
170 lines
6.8 KiB
TypeScript
170 lines
6.8 KiB
TypeScript
/**
|
|
* Deterministic property tests for inbox scheduling: every sent message logs
|
|
* once, turn numbers increase, and status follows idle→running→idle/disposed.
|
|
* Schedules advance on status events rather than wall-clock sleeps.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService from '@deepseek-ai/dsh-llm'
|
|
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
|
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
|
import fc from 'fast-check'
|
|
|
|
/** A never-exhausting adapter: every model call returns the same short reply. */
|
|
class EchoAdapter extends LlmAdapter {
|
|
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
if (options.signal?.aborted) throw new Error('aborted')
|
|
const text = 'ok'
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
yield { type: 'text-delta', index: 0, text }
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text } }
|
|
yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
}
|
|
}
|
|
|
|
async function harness() {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
ctx.llm.registerAdapter(['mock'], new EchoAdapter())
|
|
return ctx
|
|
}
|
|
|
|
/** Resolve on the agent's next transition to idle (event-based, not polled). */
|
|
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const dispose = ctx.on('agent/status', (subject, status) => {
|
|
if (subject === agent && status === 'idle') {
|
|
dispose()
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/** Record every status transition for the legal-machine assertion. Returns
|
|
* the seen list plus a disposer for the listener (per the registry convention). */
|
|
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
|
|
const seen: string[] = []
|
|
const dispose = ctx.on('agent/status', (subject, status) => {
|
|
if (subject === agent) seen.push(status)
|
|
})
|
|
return { seen, dispose }
|
|
}
|
|
|
|
function userMessageTexts(agent: ReactLoopAgent): string[] {
|
|
return agent.session.events
|
|
.filter(e => e.type === 'user/message')
|
|
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
|
|
}
|
|
|
|
function turnNumbers(agent: ReactLoopAgent): number[] {
|
|
return agent.session.events
|
|
.filter(e => e.type === 'turn/start')
|
|
.map(e => (e.data as { turn: number }).turn)
|
|
}
|
|
|
|
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
|
|
function assertLegalStatusTrace(trace: string[]): void {
|
|
for (let i = 1; i < trace.length; i++) {
|
|
expect(trace[i]).not.toBe(trace[i - 1]) // no repeats (setStatus dedups)
|
|
}
|
|
for (const s of trace) expect(['idle', 'running']).toContain(s)
|
|
}
|
|
|
|
describe('agent loop scheduling properties', () => {
|
|
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
|
|
await fc.assert(fc.asyncProperty(
|
|
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
|
|
async (texts) => {
|
|
const ctx = await harness()
|
|
try {
|
|
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
|
const { seen: trace } = recordStatus(ctx, agent)
|
|
const idle = nextIdle(ctx, agent)
|
|
// Send all in one synchronous tick: they queue before the loop wakes.
|
|
for (const text of texts) agent.send([{ type: 'text', text }])
|
|
await idle
|
|
|
|
// No message lost: every send appears as a user/message, in order.
|
|
expect(userMessageTexts(agent)).toEqual(texts)
|
|
// A synchronous burst batches into exactly one turn.
|
|
expect(turnNumbers(agent)).toEqual([1])
|
|
assertLegalStatusTrace(trace)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
},
|
|
), { numRuns: 25, timeout: 2000 })
|
|
})
|
|
|
|
it('sequential sends each get their own turn with increasing numbers', async () => {
|
|
await fc.assert(fc.asyncProperty(
|
|
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 5 }),
|
|
async (texts) => {
|
|
const ctx = await harness()
|
|
try {
|
|
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
|
for (const text of texts) {
|
|
const idle = nextIdle(ctx, agent)
|
|
agent.send([{ type: 'text', text }])
|
|
await idle
|
|
}
|
|
// Each send was drained at a separate turn start: N turns, 1..N.
|
|
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
|
expect(userMessageTexts(agent)).toEqual(texts)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
},
|
|
), { numRuns: 20, timeout: 2000 })
|
|
})
|
|
|
|
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
|
|
// Each step is a (text, settle?) pair: settle=true awaits idle before the
|
|
// next send (own turn); settle=false sends in the same tick (batches).
|
|
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
|
|
await fc.assert(fc.asyncProperty(
|
|
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
|
|
async (steps) => {
|
|
const ctx = await harness()
|
|
try {
|
|
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
|
// Capture before each send; the last waiter covers the final turn, and
|
|
// awaiting an already-settled earlier waiter is harmless.
|
|
let lastIdle: Promise<void> | undefined
|
|
for (const step of steps) {
|
|
const idle = nextIdle(ctx, agent)
|
|
lastIdle = idle
|
|
agent.send([{ type: 'text', text: step.text }])
|
|
if (step.settle) await idle
|
|
}
|
|
await lastIdle
|
|
|
|
// No message lost or reordered, regardless of batching.
|
|
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
|
|
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
|
|
const turns = turnNumbers(agent)
|
|
expect(turns).toEqual(turns.map((_, i) => i + 1))
|
|
// Every message landed in some turn; turns never exceed messages.
|
|
expect(turns.length).toBeLessThanOrEqual(steps.length)
|
|
expect(turns.length).toBeGreaterThanOrEqual(1)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
},
|
|
), { numRuns: 25, timeout: 3000 })
|
|
})
|
|
})
|