Files
deepseek-harness/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
Tianyi Cui 1d43ea3cd5 workflow: dynamic workflows — script-driven multi-agent orchestration
A new capability family at packages/workflow/ in the bash seam shape,
modeled on Claude Code's dynamic workflows: the model writes a JavaScript
orchestration script (export const meta = {...} + plain-JS body), a runtime
executes it, and the script — not the conversation — holds the loop, the
branching, and the intermediate results.

- dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary
  (WorkflowRun whose result NEVER rejects) + observe-only workflow/* events
  carrying data snapshots (id + meta, never the live run), per-listener
  contained like subagent/*.
- dsh-workflow-vm: in-process node:vm engine. Meta extraction via a
  string/comment-aware scanner (template interpolation rejected; literal
  evaluated alone in an empty timed context; statement blanked line-
  preservingly so stacks keep script line numbers). Hooks: agent(prompt,
  {label, phase, schema, model}) over ctx.subagents, parallel(), pipeline()
  (no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline:
  hook misuse (unknown/deferred options, bad arguments, unsupported
  schemas, tripped caps, seam start failures, cancellation) throws fatal
  WorkflowErrors the combinators RE-THROW — never dissolved into the
  per-item null reserved for child failures. Realm boundary: inbound values
  materialized by descriptor walks that never invoke accessors (defineProperty
  copies, __proto__-safe); outbound values rebuilt in-realm via the
  context's own JSON.parse. Determinism bans (Date.now/Math.random/argless
  new Date) kept so future resume support cannot break scripts. Caps and
  timeouts are validated Config. Every hook promise carries a no-op
  rejection consumer (app-boot exits on unhandled rejections).
- dsh-tool-workflow: the model-facing workflow tool, synchronous like
  dsh-tool-subagent (start → await → try/finally dispose; abort bridged;
  non-completed → isError). Generic render card titled by a textual
  meta.name sniff. The tool description carries the authoring contract.

Wired into examples/{coding-agent,acp-agent} with explicit-ask-only
guidance. Coverage at every tier: unit (meta scanner, materializer incl.
counting-getter and __proto__ regressions, combinator semantics,
concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon),
integration over the real spawn stack, with-key e2e (real two-phase run +
the tool through the registry pipeline), and a recorded ACP snapshot
scenario (workflow-run, 1 child session). RFC:
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred
work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's
layout line.
2026-07-05 13:29:35 +08:00

225 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import * as toolWorkflow from '../src/index.ts'
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
class StubEngine extends WorkflowService {
requests: WorkflowStartRequest[] = []
cancels: string[] = []
disposed = 0
settle!: (result: WorkflowResult) => void
startError: Error | undefined
start(request: WorkflowStartRequest): WorkflowRun {
if (this.startError) throw this.startError
this.requests.push(request)
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
request.signal?.addEventListener('abort', () => {
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
}, { once: true })
return {
id: WorkflowRunId('run-1'),
meta: { name: 'stub-flow', description: 'd' },
result,
cancel: (reason?: string) => {
this.cancels.push(reason ?? 'cancelled')
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
},
dispose: () => {
this.disposed += 1
return Promise.resolve()
},
}
}
}
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(StubEngine)
await ctx.plugin(toolWorkflow, config ?? {})
const engine = ctx.workflows as StubEngine
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
return { ctx, engine, parent }
}
const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1"
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
return ctx.tools.execute({
callId: CallId('call-1'),
name: 'workflow',
arguments: args,
...extra?.agent ? { agent: extra.agent } : {},
...extra?.signal ? { signal: extra.signal } : {},
})
}
describe('dsh-tool-workflow', () => {
it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent })
expect(engine.requests[0]!.signal).toBe(controller.signal)
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
const result = await pending
expect(result.isError).toBe(false)
const rendered = (result.content[0] as { text: string }).text
expect(rendered).toContain('workflow "stub-flow" completed (7 agents)')
expect(rendered).toContain('"findings"')
expect(engine.disposed).toBe(1)
})
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
const result = await pending
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom')
expect(engine.disposed).toBe(1)
})
it('reports a cancelled run distinctly (with and without a reason)', async () => {
const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
const result = await pending
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
const bare = execute(ctx, { script: SCRIPT }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
})
it('an error result without a message renders the unknown-error fallback', async () => {
const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
})
it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
controller.abort()
const result = await pending
expect(result.isError).toBe(true)
expect(engine.cancels).toContain('parent step aborted')
expect(engine.disposed).toBe(1)
})
it('applies raw-config fallbacks when loaded without schemastery defaults (direct apply)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(StubEngine)
// Direct apply with an empty RAW config: the `??` fallbacks resolve the
// tool name and render cap without schemastery having filled them.
toolWorkflow.apply(ctx, {})
expect(ctx.tools.get('workflow')).toBeDefined()
})
it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => {
const { ctx, engine, parent } = await setup()
engine.startError = new Error('script must begin with `export const meta = {...}`')
const result = await execute(ctx, { script: 'nope' }, { agent: parent })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('must begin with')
})
it('requires a calling agent (fails loud without exec.agent)', async () => {
const { ctx, engine } = await setup()
const result = await execute(ctx, { script: SCRIPT })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
expect(engine.requests.length).toBe(0)
})
it('validates its own arguments via the schema DSL (missing script)', async () => {
const { ctx, parent } = await setup()
const result = await execute(ctx, {}, { agent: parent })
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('INVALID_ARGS')
})
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
controller.abort()
const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true)
expect(engine.cancels).toContain('parent step aborted')
expect(engine.disposed).toBe(1)
})
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
const rendered = ((await pending).content[0] as { text: string }).text
expect(rendered).toContain('[truncated:')
expect(rendered.length).toBeLessThan(400)
})
it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(StubEngine)
const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' })
expect(ctx.tools.get('orchestrate')).toBeDefined()
expect(ctx.tools.get('workflow')).toBeUndefined()
await fiber.dispose()
expect(ctx.tools.get('orchestrate')).toBeUndefined()
})
it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => {
const { ctx } = await setup()
const tool = ctx.tools.get('workflow')!
const view = tool.presentCall!({ script: SCRIPT })
expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' })
expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' })
})
it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
const { ctx } = await setup()
const tool = ctx.tools.get('workflow')!
expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' })
// defineTool soft-validates presentation args: a malformed logged shape
// falls back to undefined instead of throwing mid-replay.
expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
})
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in toolWorkflow).toBe(false)
expect(toolWorkflow.name).toBe('tool-workflow')
expect(toolWorkflow.inject).toEqual(['tools', 'workflows'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolWorkflow) as Record<string, unknown>
expect(unwrapped).toBe(toolWorkflow)
expect(typeof unwrapped.apply).toBe('function')
})
})