Merge remote-tracking branch 'origin/master' into codex/enforce-tool-cancellation
# Conflicts: # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/core/agent/src/index.ts # packages/support/invariants/tests/invariants.spec.ts # packages/ui/acp/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
|
||||
30
packages/workflow/tool-ralph/src/invariant.ts
Normal file
30
packages/workflow/tool-ralph/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-ralph`.
|
||||
* @module @deepseek-ai/dsh-tool-ralph/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ralph'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-ralph-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing orchestration adapter owns no independent event stream;
|
||||
* workflow and subagent owners validate the runs and child lifecycles it starts.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -3,7 +3,6 @@ import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
@@ -21,7 +20,6 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
@@ -59,7 +57,6 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
|
||||
toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport),
|
||||
])
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -23,6 +28,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -34,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
|
||||
30
packages/workflow/tool-workflow/src/invariant.ts
Normal file
30
packages/workflow/tool-workflow/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`.
|
||||
* @module @deepseek-ai/dsh-tool-workflow/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-workflow-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./worker": {
|
||||
"types": "./lib/types/worker.d.ts",
|
||||
"default": "./lib/worker.cjs"
|
||||
@@ -20,6 +24,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/worker.cjs",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
@@ -29,6 +34,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
|
||||
30
packages/workflow/workflow-workerthread/src/invariant.ts
Normal file
30
packages/workflow/workflow-workerthread/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workflow-workerthread`.
|
||||
* @module @deepseek-ai/dsh-workflow-workerthread/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workflow-workerthread-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this process-boundary implementation exposes no same-process event relation;
|
||||
* worker protocol and built-worker tests cover it.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -3,7 +3,10 @@ import { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess'
|
||||
@@ -12,6 +15,13 @@ import WorkerWorkflowEngine from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole in-process stack, keyless, with the script in a REAL worker
|
||||
* thread: the engine drives the REAL spawn backend (with its
|
||||
@@ -24,7 +34,7 @@ async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(Invariants)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { defineConfig } from 'tsdown'
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -24,6 +29,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -31,6 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
136
packages/workflow/workflow/src/invariant.ts
Normal file
136
packages/workflow/workflow/src/invariant.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRunInfo,
|
||||
} from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workflow'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workflow-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
interface WorkflowTrace {
|
||||
meta: string
|
||||
agents: Map<number, WorkflowAgentInfo>
|
||||
starts: number
|
||||
}
|
||||
|
||||
/** Require every event for a run to retain its validated identity snapshot. */
|
||||
function traceFor(
|
||||
traces: ReadonlyMap<string, WorkflowTrace>,
|
||||
info: WorkflowRunInfo,
|
||||
fail: InvariantFailure,
|
||||
): WorkflowTrace {
|
||||
const trace = traces.get(info.id)
|
||||
if (trace === undefined) fail(`workflow event has no matching workflow/start for run ${JSON.stringify(info.id)}`)
|
||||
if (trace.meta !== JSON.stringify(info.meta)) {
|
||||
fail(`workflow event meta diverges from workflow/start for run ${JSON.stringify(info.id)}`)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
/** Assert the immutable identity fields shared by an agent pair. */
|
||||
function validateAgentEnd(start: WorkflowAgentInfo, end: WorkflowAgentEndInfo, fail: InvariantFailure): void {
|
||||
if (start.label !== end.label || start.phase !== end.phase || start.childId !== end.childId) {
|
||||
fail(`workflow/agent-end identity diverges from workflow/agent-start for seq ${end.seq}`)
|
||||
}
|
||||
const outcome: string = end.outcome
|
||||
if (outcome !== 'completed' && outcome !== 'failed' && outcome !== 'cancelled') {
|
||||
fail(`workflow/agent-end carries unknown outcome ${JSON.stringify(outcome)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a terminal result against the accumulated run trace. */
|
||||
function validateWorkflowEnd(trace: WorkflowTrace, result: WorkflowResultInfo, fail: InvariantFailure): void {
|
||||
if (trace.agents.size > 0) fail(`workflow/end has ${trace.agents.size} agent call(s) without workflow/agent-end`)
|
||||
if (!Number.isSafeInteger(result.agentsStarted) || result.agentsStarted < trace.starts) {
|
||||
fail('workflow/end agentsStarted must be a safe integer covering every observed agent start')
|
||||
}
|
||||
if (result.stopReason === 'completed' ? result.error !== undefined : typeof result.error !== 'string') {
|
||||
fail('workflow/end error must be absent exactly for completed runs')
|
||||
}
|
||||
}
|
||||
|
||||
/** Install workflow start/end and child-call pairing checks. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
const traces = new Map<string, WorkflowTrace>()
|
||||
const stagedStarts = new WeakSet<WorkflowRunInfo>()
|
||||
const stagedAgentStarts = new WeakSet<WorkflowAgentInfo>()
|
||||
const stagedAgentEnds = new WeakSet<WorkflowAgentEndInfo>()
|
||||
const stagedEnds = new WeakSet<WorkflowResultInfo>()
|
||||
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName === 'workflow/start') {
|
||||
const info = args[0] as WorkflowRunInfo
|
||||
if (String(info.id).length === 0 || info.meta.name.length === 0 || info.meta.description.length === 0) {
|
||||
fail('workflow/start id, meta.name, and meta.description must be non-empty')
|
||||
}
|
||||
if (traces.has(info.id)) fail(`workflow/start repeated run id ${JSON.stringify(info.id)}`)
|
||||
stagedStarts.add(info)
|
||||
return
|
||||
}
|
||||
if (!eventName.startsWith('workflow/')) return
|
||||
const info = args[0] as WorkflowRunInfo
|
||||
const trace = traceFor(traces, info, fail)
|
||||
if (eventName === 'workflow/agent-start') {
|
||||
const agent = args[1] as WorkflowAgentInfo
|
||||
if (!Number.isSafeInteger(agent.seq) || agent.seq < 1 || String(agent.childId).length === 0) {
|
||||
fail('workflow/agent-start seq must be positive and childId must be non-empty')
|
||||
}
|
||||
if (trace.agents.has(agent.seq)) fail(`workflow/agent-start repeated seq ${agent.seq}`)
|
||||
stagedAgentStarts.add(agent)
|
||||
return
|
||||
}
|
||||
if (eventName === 'workflow/agent-end') {
|
||||
const agent = args[1] as WorkflowAgentEndInfo
|
||||
const start = trace.agents.get(agent.seq)
|
||||
if (start === undefined) return fail(`workflow/agent-end has no matching start for seq ${agent.seq}`)
|
||||
validateAgentEnd(start, agent, fail)
|
||||
stagedAgentEnds.add(agent)
|
||||
return
|
||||
}
|
||||
if (eventName === 'workflow/end') {
|
||||
const result = args[1] as WorkflowResultInfo
|
||||
validateWorkflowEnd(trace, result, fail)
|
||||
stagedEnds.add(result)
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('workflow/start', (info) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same run-info object */
|
||||
if (!stagedStarts.delete(info)) return
|
||||
traces.set(info.id, { meta: JSON.stringify(info.meta), agents: new Map(), starts: 0 })
|
||||
}, { global: true })
|
||||
ctx.on('workflow/agent-start', (info, agent) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same agent object */
|
||||
if (!stagedAgentStarts.delete(agent)) return
|
||||
const trace = traceFor(traces, info, fail)
|
||||
trace.agents.set(agent.seq, agent)
|
||||
trace.starts += 1
|
||||
}, { global: true })
|
||||
ctx.on('workflow/agent-end', (info, agent) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same agent object */
|
||||
if (!stagedAgentEnds.delete(agent)) return
|
||||
traceFor(traces, info, fail).agents.delete(agent.seq)
|
||||
}, { global: true })
|
||||
ctx.on('workflow/end', (info, result) => {
|
||||
/* v8 ignore next -- internal/dispatch stages the same result object */
|
||||
if (!stagedEnds.delete(result)) return
|
||||
traces.delete(info.id)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the workflow invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
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]')
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user