fix(invariants): assert runtime relationships, not API shapes
This commit is contained in:
108
packages/workflow/workflow/tests/invariant.spec.ts
Normal file
108
packages/workflow/workflow/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRunInfo,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import * as WorkflowInvariant from '@deepseek-ai/dsh-workflow/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(WorkflowInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const info = (overrides: Partial<WorkflowRunInfo> = {}): WorkflowRunInfo => ({
|
||||
id: WorkflowRunId('workflow-1'),
|
||||
meta: { name: 'review', description: 'Review a change' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const agent = (overrides: Partial<WorkflowAgentInfo> = {}): WorkflowAgentInfo => ({
|
||||
seq: 1,
|
||||
label: 'reviewer',
|
||||
childId: SessionId('child-1'),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const agentEnd = (overrides: Partial<WorkflowAgentEndInfo> = {}): WorkflowAgentEndInfo => ({
|
||||
...agent(),
|
||||
outcome: 'completed',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const result = (overrides: Partial<WorkflowResultInfo> = {}): WorkflowResultInfo => ({
|
||||
stopReason: 'completed',
|
||||
agentsStarted: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('workflow invariants', () => {
|
||||
it('accepts a complete workflow and child lifecycle', async () => {
|
||||
const ctx = await setup()
|
||||
const run = info()
|
||||
ctx.emit('workflow/start', run)
|
||||
ctx.emit('workflow/phase', run, 'inspect')
|
||||
ctx.emit('workflow/log', run, 'working')
|
||||
ctx.emit('workflow/agent-start', run, agent())
|
||||
ctx.emit('workflow/agent-end', run, agentEnd())
|
||||
ctx.emit('workflow/end', run, result())
|
||||
ctx.emit('tools/change')
|
||||
})
|
||||
|
||||
it('rejects invalid run identity and enclosure', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('workflow/start', info({ id: WorkflowRunId('') })) }).toThrow(/must be non-empty/)
|
||||
const run = info()
|
||||
ctx.emit('workflow/start', run)
|
||||
expect(() => { ctx.emit('workflow/start', run) }).toThrow(/repeated run id/)
|
||||
expect(() => { ctx.emit('workflow/log', info({ meta: { name: 'other', description: 'x' } }), 'x') })
|
||||
.toThrow(/meta diverges/)
|
||||
const fresh = await setup()
|
||||
expect(() => { fresh.emit('workflow/log', info(), 'x') }).toThrow(/no matching workflow\/start/)
|
||||
})
|
||||
|
||||
it('rejects malformed and unpaired child lifecycles', async () => {
|
||||
const ctx = await setup()
|
||||
const run = info()
|
||||
ctx.emit('workflow/start', run)
|
||||
expect(() => { ctx.emit('workflow/agent-start', run, agent({ seq: 0 })) }).toThrow(/seq must be positive/)
|
||||
ctx.emit('workflow/agent-start', run, agent())
|
||||
expect(() => { ctx.emit('workflow/agent-start', run, agent()) }).toThrow(/repeated seq/)
|
||||
expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ seq: 2 })) }).toThrow(/no matching start/)
|
||||
expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ childId: SessionId('other') })) })
|
||||
.toThrow(/identity diverges/)
|
||||
expect(() => { ctx.emit('workflow/agent-end', run, agentEnd({ outcome: 'unknown' as never })) })
|
||||
.toThrow(/unknown outcome/)
|
||||
})
|
||||
|
||||
it('rejects inconsistent terminal results', async () => {
|
||||
const active = await setup()
|
||||
active.emit('workflow/start', info())
|
||||
active.emit('workflow/agent-start', info(), agent())
|
||||
expect(() => { active.emit('workflow/end', info(), result()) }).toThrow(/without workflow\/agent-end/)
|
||||
|
||||
const count = await setup()
|
||||
count.emit('workflow/start', info())
|
||||
count.emit('workflow/agent-start', info(), agent())
|
||||
count.emit('workflow/agent-end', info(), agentEnd())
|
||||
expect(() => { count.emit('workflow/end', info(), result({ agentsStarted: 0 })) })
|
||||
.toThrow(/covering every observed agent start/)
|
||||
|
||||
const completed = await setup()
|
||||
completed.emit('workflow/start', info())
|
||||
expect(() => { completed.emit('workflow/end', info(), result({ error: 'unexpected' })) })
|
||||
.toThrow(/absent exactly for completed/)
|
||||
|
||||
const failed = await setup()
|
||||
failed.emit('workflow/start', info())
|
||||
expect(() => { failed.emit('workflow/end', info(), result({ stopReason: 'error' })) })
|
||||
.toThrow(/absent exactly for completed/)
|
||||
})
|
||||
})
|
||||
@@ -58,8 +58,11 @@ describe('dsh-workflow (interface)', () => {
|
||||
ctx.on('workflow/log', (info, message) => { seen.push([info, message]) })
|
||||
ctx.on('workflow/agent-start', (info, agent) => { seen.push([info, agent]) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
engine.emit('workflow/start', INFO)
|
||||
engine.emit('workflow/log', INFO, 'hello')
|
||||
engine.emit('workflow/agent-start', INFO, { seq: 1, label: 'l', childId: 'c' })
|
||||
engine.emit('workflow/agent-end', INFO, { seq: 1, label: 'l', childId: 'c', outcome: 'completed' })
|
||||
engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 })
|
||||
expect(seen).toEqual([
|
||||
[INFO, 'hello'],
|
||||
[INFO, { seq: 1, label: 'l', childId: 'c' }],
|
||||
@@ -77,8 +80,11 @@ describe('dsh-workflow (interface)', () => {
|
||||
ctx.on('workflow/agent-start', (_info, agent) => { seen.push(agent.label) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
const payload = { seq: 1, label: 'original', childId: 'c' }
|
||||
engine.emit('workflow/start', INFO)
|
||||
engine.emit('workflow/agent-start', INFO, payload)
|
||||
await Promise.resolve()
|
||||
engine.emit('workflow/agent-end', INFO, { ...payload, outcome: 'completed' })
|
||||
engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 1 })
|
||||
expect(seen).toEqual(['original'])
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('listener rejected')
|
||||
})
|
||||
@@ -91,7 +97,9 @@ describe('dsh-workflow (interface)', () => {
|
||||
ctx.on('workflow/phase', () => { throw new Error('bad listener') })
|
||||
ctx.on('workflow/phase', (_info, title) => { reached.push(title) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
engine.emit('workflow/start', INFO)
|
||||
expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow()
|
||||
engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 })
|
||||
expect(reached).toEqual(['Scan'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('workflow/phase listener threw')
|
||||
@@ -107,7 +115,9 @@ describe('dsh-workflow (interface)', () => {
|
||||
})
|
||||
ctx.on('workflow/phase', (_info, title) => { reached.push(title) })
|
||||
const engine = ctx.workflows as StubEngine
|
||||
engine.emit('workflow/start', INFO)
|
||||
expect(() => { engine.emit('workflow/phase', INFO, 'Scan') }).not.toThrow()
|
||||
engine.emit('workflow/end', INFO, { stopReason: 'completed', agentsStarted: 0 })
|
||||
expect(reached).toEqual(['Scan'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]![0])).toContain('[unrenderable thrown value]')
|
||||
|
||||
Reference in New Issue
Block a user