refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const builtIndex = join(packageRoot, 'lib', 'index.js')
|
||||
const builtWorker = join(packageRoot, 'lib', 'worker.cjs')
|
||||
const run = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Keyless built-artifact guard: plain Node loads `lib/index.js` and its sibling
|
||||
* `lib/worker.cjs` without tsx. Skips until the build produces both bundles.
|
||||
*/
|
||||
describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.cjs)', () => {
|
||||
it('the built engine spawns its built worker under plain node and completes a run', async () => {
|
||||
// Keep the driver in-package so bare imports resolve its node_modules.
|
||||
const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`)
|
||||
try {
|
||||
await writeFile(driver, `
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
let selectedStarts = 0
|
||||
ctx.subagents.registerProvider({
|
||||
name: 'built-selected',
|
||||
capabilities: { outputSchema: true, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
async start() {
|
||||
selectedStarts += 1
|
||||
return {
|
||||
id: 'built-child',
|
||||
result: Promise.resolve({ output: [], structured: { answer: 42 }, stopReason: 'completed' }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
},
|
||||
})
|
||||
await ctx.plugin(WorkerThreadWorkflowEngine, { provider: 'must-not-be-used' })
|
||||
const run = ctx.workflowEngine.start({
|
||||
script: "const value = await agent('answer', { schema: { type: 'object', properties: { answer: { type: 'number' } }, required: ['answer'] } }); return value.answer",
|
||||
meta: { name: 'built-smoke', description: 'built worker smoke' },
|
||||
subagentProvider: 'built-selected',
|
||||
parent: { id: 'built-smoke-parent', options: {} },
|
||||
})
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
if (result.stopReason !== 'completed' || result.value !== 42 || selectedStarts !== 1) {
|
||||
console.error('unexpected result: ' + JSON.stringify(result))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('built-worker-smoke-ok')
|
||||
`, 'utf8')
|
||||
const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 })
|
||||
expect(stdout).toContain('built-worker-smoke-ok')
|
||||
} finally {
|
||||
await rm(driver, { force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/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 InvariantRegistry 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 SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import WorkerThreadWorkflowEngine from '../src/index.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
async function mountInvariants(ctx: Context): Promise<void> {
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
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
|
||||
* structured runtime) on a real agent loop; the scripted mock MODEL is the
|
||||
* only mocked boundary. This is the guard the unit suites structurally
|
||||
* cannot give — the MessageChannel suite fakes the host, and the host suite
|
||||
* stubs the subagent seam.
|
||||
*/
|
||||
async function setup(script: Script) {
|
||||
const ctx = new Context()
|
||||
const adapter = new MockAdapter(script)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(spawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(WorkerThreadWorkflowEngine, {})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent, adapter }
|
||||
}
|
||||
|
||||
describe('dsh-workflow-worker-thread over the real in-process stack', () => {
|
||||
it('runs a two-stage workflow: a plain child, then a schema child through the structured runtime', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('the file list is a.ts'),
|
||||
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { verdict: 'real', confidence: 0.9 }),
|
||||
])
|
||||
const childIds: string[] = []
|
||||
ctx.on('workflow/agent-start', (_info, agent) => {
|
||||
// The workflow bridge must await asynchronous provider start: an observer
|
||||
// sees the real spawn child already published, never a reserved id.
|
||||
expect(ctx.agents.get(agent.childId)).toBeDefined()
|
||||
childIds.push(agent.childId)
|
||||
})
|
||||
const run = ctx.workflowEngine.start({
|
||||
meta: { name: 'integration', description: 'plain + structured children' },
|
||||
script: `phase('Read')
|
||||
const prose = await agent('read the repo')
|
||||
phase('Judge')
|
||||
const judged = await agent('judge: ' + prose, {
|
||||
schema: { type: 'object', properties: { verdict: { type: 'string', enum: ['real', 'bogus'] }, confidence: { type: 'number' } }, required: ['verdict'] },
|
||||
})
|
||||
return { prose, verdict: judged.verdict, confidence: judged.confidence }`,
|
||||
parent,
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toEqual({ prose: 'the file list is a.ts', verdict: 'real', confidence: 0.9 })
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
await run.dispose()
|
||||
// Both children were disposed to quiescence — no live child agents remain.
|
||||
expect(childIds.length).toBe(2)
|
||||
for (const childId of childIds) {
|
||||
expect(ctx.agents.get(SessionId(childId))).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('a child that fails against its schema (nudges exhausted) reaches the script as null', async () => {
|
||||
const { ctx, parent } = await setup([
|
||||
textResponse('prose only'),
|
||||
textResponse('still prose after the nudge'),
|
||||
])
|
||||
const run = ctx.workflowEngine.start({
|
||||
meta: { name: 'null-path', description: 'schema failure maps to null' },
|
||||
script: `const judged = await agent('judge it', { schema: { type: 'object', properties: { v: { type: 'string' } } } })
|
||||
return { got: judged === null ? 'null' : 'value' }`,
|
||||
parent,
|
||||
})
|
||||
const result = await run.result
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toEqual({ got: 'null' })
|
||||
await run.dispose()
|
||||
})
|
||||
})
|
||||
89
packages/workflow/workflow-worker-thread/tests/meta.spec.ts
Normal file
89
packages/workflow/workflow-worker-thread/tests/meta.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import { validateMeta } from '../src/meta.ts'
|
||||
|
||||
/** Assert a META_INVALID throw whose message matches every given fragment. */
|
||||
function expectInvalid(value: unknown, ...fragments: string[]): void {
|
||||
let thrown: unknown
|
||||
try {
|
||||
validateMeta(value)
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(WorkflowError)
|
||||
expect((thrown as WorkflowError).code).toBe('META_INVALID')
|
||||
for (const fragment of fragments) {
|
||||
expect((thrown as WorkflowError).message).toContain(fragment)
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateMeta', () => {
|
||||
it('accepts a minimal meta and returns a normalized copy (no aliasing of the input)', () => {
|
||||
const input = { name: 'audit', description: 'audit the repo' }
|
||||
const meta = validateMeta(input)
|
||||
expect(meta).toEqual({ name: 'audit', description: 'audit the repo' })
|
||||
expect(meta).not.toBe(input)
|
||||
input.name = 'mutated'
|
||||
expect(meta.name).toBe('audit')
|
||||
})
|
||||
|
||||
it('accepts the full shape and rebuilds phases entry by entry', () => {
|
||||
const meta = validateMeta({
|
||||
name: 'migrate',
|
||||
description: 'migrate call sites',
|
||||
whenToUse: 'large mechanical sweeps',
|
||||
phases: [
|
||||
{ title: 'Discover', provider: 'openai' },
|
||||
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
|
||||
],
|
||||
})
|
||||
expect(meta).toEqual({
|
||||
name: 'migrate',
|
||||
description: 'migrate call sites',
|
||||
whenToUse: 'large mechanical sweeps',
|
||||
phases: [
|
||||
{ title: 'Discover', provider: 'openai' },
|
||||
{ title: 'Transform', detail: 'one agent per file', model: 'deepseek-v4-pro' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-object values loud', () => {
|
||||
expectInvalid(undefined, 'meta must be an object')
|
||||
expectInvalid('a string', 'meta must be an object')
|
||||
expectInvalid(null, 'meta must be an object')
|
||||
expectInvalid([{ name: 'x', description: 'd' }], 'meta must be an object')
|
||||
})
|
||||
|
||||
it('rejects unknown fields by name (accepted-then-ignored is banned)', () => {
|
||||
expectInvalid({ name: 'x', description: 'd', color: 'red' }, 'meta.color is not a recognized field')
|
||||
})
|
||||
|
||||
it('rejects missing or mistyped name/description/whenToUse', () => {
|
||||
expectInvalid({ description: 'd' }, 'meta.name must be a non-empty string')
|
||||
expectInvalid({ name: '', description: 'd' }, 'meta.name must be a non-empty string')
|
||||
expectInvalid({ name: 'x' }, 'meta.description must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 42 }, 'meta.description must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 'd', whenToUse: 3 }, 'meta.whenToUse must be a string')
|
||||
})
|
||||
|
||||
it('rejects malformed phases, entry by entry', () => {
|
||||
expectInvalid({ name: 'x', description: 'd', phases: 'Scan' }, 'meta.phases must be an array')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: ['Scan'] }, 'meta.phases[0] must be an object')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: '' }] }, 'meta.phases[0].title must be a non-empty string')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', order: 1 }] }, 'meta.phases[0].order is not a recognized field')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', detail: 9 }] }, 'meta.phases[0].detail must be a string')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', provider: 9 }] }, 'meta.phases[0].provider must be a string')
|
||||
expectInvalid({ name: 'x', description: 'd', phases: [{ title: 'Scan', model: 9 }] }, 'meta.phases[0].model must be a string')
|
||||
})
|
||||
|
||||
it('names EVERY violation in one throw, not just the first', () => {
|
||||
expectInvalid(
|
||||
{ description: 7, extra: true, phases: [{ title: 'Scan' }, 'bad'] },
|
||||
'meta.extra is not a recognized field',
|
||||
'meta.name must be a non-empty string',
|
||||
'meta.description must be a non-empty string',
|
||||
'meta.phases[1] must be an object',
|
||||
)
|
||||
})
|
||||
})
|
||||
145
packages/workflow/workflow-worker-thread/tests/realm.spec.ts
Normal file
145
packages/workflow/workflow-worker-thread/tests/realm.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as vm from 'node:vm'
|
||||
import { materializeFromRealm, MaterializeError, renderThrown } from '../src/realm.ts'
|
||||
|
||||
/** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
|
||||
function inRealm(expression: string): unknown {
|
||||
return vm.runInNewContext(`(${expression})`)
|
||||
}
|
||||
|
||||
/** The MaterializeError message for a value that must be rejected (throws if accepted). */
|
||||
function rejection(value: unknown): string {
|
||||
try {
|
||||
materializeFromRealm(value)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof MaterializeError) return error.message
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the value to be rejected')
|
||||
}
|
||||
|
||||
describe('materializeFromRealm', () => {
|
||||
it('copies realm objects/arrays/scalars into host plain data', () => {
|
||||
const value = inRealm("{ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] }")
|
||||
const out = materializeFromRealm(value) as Record<string, unknown>
|
||||
expect(out).toEqual({ a: 1, b: 'x', c: true, d: null, list: [1, [2, { deep: 'y' }]] })
|
||||
// The copy is HOST data: prototypes are the host intrinsics.
|
||||
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
|
||||
expect(Array.isArray(out.list)).toBe(true)
|
||||
// And it round-trips through JSON byte-identically (the whole point).
|
||||
expect(JSON.parse(JSON.stringify(out))).toEqual(out)
|
||||
})
|
||||
|
||||
it('accepts undefined ONLY at the root (a valueless script return)', () => {
|
||||
expect(materializeFromRealm(undefined)).toBeUndefined()
|
||||
expect(rejection(inRealm('{ a: undefined }'))).toContain('value.a')
|
||||
})
|
||||
|
||||
it('invokes getters ordinarily — the getter RESULT is what crosses (trust premise)', () => {
|
||||
const counter = inRealm(`
|
||||
(() => {
|
||||
globalThis.reads = 0
|
||||
return { get x() { globalThis.reads += 1; return globalThis.reads } }
|
||||
})()
|
||||
`)
|
||||
expect(materializeFromRealm(counter)).toEqual({ x: 1 })
|
||||
})
|
||||
|
||||
it('a getter that THROWS surfaces as a MaterializeError carrying the rendered failure', () => {
|
||||
const hostile = inRealm("{ get x() { throw new Error('read failed') } }")
|
||||
const message = rejection(hostile)
|
||||
expect(message).toContain('reading the value threw')
|
||||
expect(message).toContain('read failed')
|
||||
})
|
||||
|
||||
it('a "__proto__" key becomes an OWN data property of the copy, never a prototype mutation', () => {
|
||||
const value: unknown = vm.runInNewContext('JSON.parse(\'{"__proto__": {"polluted": 1}, "ok": 2}\')')
|
||||
const out = materializeFromRealm(value) as Record<string, unknown>
|
||||
expect(Object.getPrototypeOf(out)).toBe(Object.prototype)
|
||||
expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(true)
|
||||
expect(out.ok).toBe(2)
|
||||
// The host Object.prototype was NOT touched.
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects functions, symbols (keys and values), and bigints with path-qualified messages', () => {
|
||||
expect(rejection(inRealm('{ fn: () => 1 }'))).toContain('value.fn')
|
||||
expect(rejection(inRealm("{ [Symbol('k')]: 1 }"))).toContain('symbol-keyed')
|
||||
expect(rejection(inRealm("{ s: Symbol('v') }"))).toContain('value.s')
|
||||
expect(rejection(inRealm('{ big: 1n }'))).toContain('value.big')
|
||||
expect(rejection(inRealm("[Symbol('x')]"))).toContain('value[0]')
|
||||
const taggedArray = inRealm("(() => { const a = [1]; a[Symbol('t')] = 1; return a })()")
|
||||
expect(rejection(taggedArray)).toContain('symbol-keyed')
|
||||
})
|
||||
|
||||
it('rejects non-finite numbers and undefined values inside containers', () => {
|
||||
expect(rejection(inRealm('{ n: NaN }'))).toContain('non-finite')
|
||||
expect(rejection(inRealm('[Infinity]'))).toContain('non-finite')
|
||||
})
|
||||
|
||||
it('rejects exotic prototypes (Date, Map, class instances) but accepts null-prototype data', () => {
|
||||
expect(rejection(inRealm('{ d: new Date(0) }'))).toContain('exotic prototype')
|
||||
expect(rejection(inRealm('new Map()'))).toContain('exotic prototype')
|
||||
expect(rejection(inRealm('(() => { class C { constructor() { this.x = 1 } } return new C() })()')))
|
||||
.toContain('exotic prototype')
|
||||
expect(materializeFromRealm(inRealm('Object.assign(Object.create(null), { a: 1 })'))).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
it('rejects cycles and accepts the same object reused as a sibling (a DAG)', () => {
|
||||
expect(rejection(inRealm('(() => { const o = {}; o.self = o; return o })()'))).toContain('circular')
|
||||
const dag = inRealm('(() => { const leaf = { v: 1 }; return { a: leaf, b: leaf } })()')
|
||||
expect(materializeFromRealm(dag)).toEqual({ a: { v: 1 }, b: { v: 1 } })
|
||||
})
|
||||
|
||||
it('rejects sparse arrays and non-index array properties; an array getter element materializes its value', () => {
|
||||
expect(rejection(inRealm('[1, , 3]'))).toContain('sparse')
|
||||
expect(rejection(inRealm('(() => { const a = [1]; a.total = 3; return a })()')))
|
||||
.toContain('non-index')
|
||||
expect(materializeFromRealm(inRealm('(() => { const a = [1]; Object.defineProperty(a, 0, { get: () => 7, enumerable: true }); return a })()')))
|
||||
.toEqual([7])
|
||||
})
|
||||
|
||||
it('skips non-enumerable own properties (matching JSON.stringify exactly)', () => {
|
||||
const value = inRealm(`(() => {
|
||||
const o = { visible: 1 }
|
||||
Object.defineProperty(o, 'hidden', { value: () => 1, enumerable: false })
|
||||
return o
|
||||
})()`)
|
||||
expect(materializeFromRealm(value)).toEqual({ visible: 1 })
|
||||
})
|
||||
|
||||
it('works on plain host values too (the boundary is realm-agnostic)', () => {
|
||||
expect(materializeFromRealm({ a: [1, 'x'] })).toEqual({ a: [1, 'x'] })
|
||||
expect(materializeFromRealm('str')).toBe('str')
|
||||
expect(materializeFromRealm(3)).toBe(3)
|
||||
expect(materializeFromRealm(false)).toBe(false)
|
||||
expect(materializeFromRealm(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderThrown', () => {
|
||||
it('prefers the stack, for host and realm errors alike', () => {
|
||||
const host = renderThrown(new Error('host failure'))
|
||||
expect(host).toContain('host failure')
|
||||
expect(host).toContain('at ') // a real stack, not just the message
|
||||
const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
|
||||
expect(renderThrown(realmError)).toContain('realm failure')
|
||||
})
|
||||
|
||||
it('falls back from stack to message to String()', () => {
|
||||
expect(renderThrown({ stack: 'custom data stack' })).toBe('custom data stack')
|
||||
const stackless = new Error('stackless failure')
|
||||
delete stackless.stack
|
||||
expect(renderThrown(stackless)).toBe('stackless failure')
|
||||
expect(renderThrown({ code: 42 })).toBe('[object Object]')
|
||||
expect(renderThrown('plain')).toBe('plain')
|
||||
expect(renderThrown(42)).toBe('42')
|
||||
expect(renderThrown(undefined)).toBe('undefined')
|
||||
expect(renderThrown(null)).toBe('null')
|
||||
})
|
||||
|
||||
it('is total: a value whose accessors/toString throw renders as a fixed label', () => {
|
||||
expect(renderThrown({ get stack() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
|
||||
expect(renderThrown({ [Symbol.toPrimitive]() { throw new Error('nope') } })).toBe('[unrenderable thrown value]')
|
||||
})
|
||||
})
|
||||
511
packages/workflow/workflow-worker-thread/tests/session.spec.ts
Normal file
511
packages/workflow/workflow-worker-thread/tests/session.spec.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MessageChannel } from 'node:worker_threads'
|
||||
import type { MessagePort } from 'node:worker_threads'
|
||||
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
|
||||
import type { HostToWorkerMessage, WorkerToHostMessage } from '../src/protocol.ts'
|
||||
import { requireParentPort, runWorkerSession } from '../src/session.ts'
|
||||
import type { ChildResult, WorkerInit } from '../src/types.ts'
|
||||
|
||||
/** Default limits for in-process sessions (concurrency pinned; auto is machine-derived). */
|
||||
function limits(overrides?: Partial<WorkerInit['limits']>): WorkerInit['limits'] {
|
||||
return { maxConcurrentAgents: 8, maxTotalAgents: 1000, maxItemsPerCall: 4096, syncTimeoutMs: 5000, ...overrides }
|
||||
}
|
||||
|
||||
/** Wrap a body in the minimal valid meta header (the session receives it pre-extracted). */
|
||||
function init(body: string, args?: unknown, limitOverrides?: Partial<WorkerInit['limits']>): WorkerInit {
|
||||
return {
|
||||
meta: { name: 'test-flow', description: 'a test workflow' },
|
||||
body,
|
||||
...args !== undefined ? { args } : {},
|
||||
limits: limits(limitOverrides),
|
||||
}
|
||||
}
|
||||
|
||||
/** One scripted host over the other end of a MessageChannel. */
|
||||
interface FakeHost {
|
||||
port: MessagePort
|
||||
messages: WorkerToHostMessage[]
|
||||
/** Messages of one type, as they arrive. */
|
||||
ofType<T extends WorkerToHostMessage['type']>(type: T): Extract<WorkerToHostMessage, { type: T }>[]
|
||||
send(message: HostToWorkerMessage): void
|
||||
/** Resolves with the terminal result message. */
|
||||
result(): Promise<Extract<WorkerToHostMessage, { type: 'result' }>['result']>
|
||||
close(): void
|
||||
}
|
||||
|
||||
interface FakeHostOptions {
|
||||
/** Auto-respond to child-start: reply started + settled per child index. Omit a reply to leave the child pending. */
|
||||
reply?: (request: { prompt: string; schema?: unknown; provider?: string; model?: string }, index: number) => ChildResult | undefined
|
||||
/** Reject the start instead (child-start-error) when returning a string. */
|
||||
refuse?: (index: number) => string | undefined
|
||||
/** Auto-send `go` on `ready` (default true). */
|
||||
go?: boolean
|
||||
/** Manual mode: do NOT auto-answer child-start at all (the test scripts the replies). */
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive runWorkerSession IN-PROCESS over a MessageChannel: this is where the
|
||||
* worker-side files earn their coverage — code inside a real Worker is
|
||||
* invisible to main-process coverage. The fake host mirrors the real host's
|
||||
* protocol discipline (one started/start-error per start; settled/disposed
|
||||
* follow).
|
||||
*/
|
||||
function fakeHost(options?: FakeHostOptions): FakeHost {
|
||||
const channel = new MessageChannel()
|
||||
const messages: WorkerToHostMessage[] = []
|
||||
const resultGate = Promise.withResolvers<Extract<WorkerToHostMessage, { type: 'result' }>['result']>()
|
||||
let childIndex = 0
|
||||
channel.port1.on('message', (message: WorkerToHostMessage) => {
|
||||
messages.push(message)
|
||||
switch (message.type) {
|
||||
case WorkerToHostType.Ready:
|
||||
if (options?.go !== false) channel.port1.postMessage({ type: HostToWorkerType.Go } satisfies HostToWorkerMessage)
|
||||
break
|
||||
case WorkerToHostType.ChildStart: {
|
||||
if (options?.manual) break
|
||||
const index = childIndex
|
||||
childIndex += 1
|
||||
const refusal = options?.refuse?.(index)
|
||||
if (refusal !== undefined) {
|
||||
channel.port1.postMessage(
|
||||
{ type: HostToWorkerType.ChildStartError, callId: message.callId, rendered: refusal } satisfies HostToWorkerMessage,
|
||||
)
|
||||
break
|
||||
}
|
||||
channel.port1.postMessage({ type: HostToWorkerType.ChildStarted, callId: message.callId, childId: `child-${index}` } satisfies HostToWorkerMessage)
|
||||
const reply = options?.reply?.(message.request, index)
|
||||
if (reply !== undefined) {
|
||||
channel.port1.postMessage(
|
||||
{ type: HostToWorkerType.ChildSettled, callId: message.callId, result: reply } satisfies HostToWorkerMessage,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case WorkerToHostType.ChildDispose:
|
||||
channel.port1.postMessage({ type: HostToWorkerType.ChildDisposed, callId: message.callId } satisfies HostToWorkerMessage)
|
||||
break
|
||||
case WorkerToHostType.Result:
|
||||
resultGate.resolve(message.result)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return {
|
||||
port: channel.port2,
|
||||
messages,
|
||||
ofType: type => messages.filter((message): message is never => message.type === type),
|
||||
send: (message) => { channel.port1.postMessage(message) },
|
||||
result: () => resultGate.promise,
|
||||
close: () => { channel.port1.close() },
|
||||
}
|
||||
}
|
||||
|
||||
/** A completed text child result. */
|
||||
function text(reply: string): ChildResult {
|
||||
return { output: [{ type: 'text', text: reply }], stopReason: 'completed' }
|
||||
}
|
||||
|
||||
describe('runWorkerSession over an in-process MessageChannel', () => {
|
||||
it('runs a script end to end: ready/go handshake, phases, log, agents, result', async () => {
|
||||
const host = fakeHost({ reply: (_request, index) => text(`answer-${index}`) })
|
||||
const session = runWorkerSession(host.port, init(`
|
||||
phase('Scan')
|
||||
log('starting with ' + args.files.length + ' files')
|
||||
const answers = await pipeline(args.files, (prev, item) => agent('read ' + item))
|
||||
return { answers }
|
||||
`, { files: ['a.ts', 'b.ts'] }))
|
||||
const result = await host.result()
|
||||
await session
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
expect(result.value).toEqual({ answers: ['answer-0', 'answer-1'] })
|
||||
expect(host.messages[0]!.type).toBe('ready')
|
||||
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['Scan'])
|
||||
expect(host.ofType(WorkerToHostType.Log).map(m => m.message)).toEqual(['starting with 2 files'])
|
||||
expect(host.ofType(WorkerToHostType.AgentStart).map(m => m.info.childId)).toEqual(['child-0', 'child-1'])
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd).every(m => m.info.outcome === 'completed')).toBe(true)
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('agent({schema}) forwards the schema on the start request and returns the structured value', async () => {
|
||||
const host = fakeHost({ reply: () => ({ output: [], structured: { files: ['x.ts'] }, stopReason: 'completed' }) })
|
||||
void runWorkerSession(host.port, init(`
|
||||
const found = await agent('list files', { schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } }, model: 'deepseek-v4-pro' })
|
||||
return { first: found.files[0] }
|
||||
`))
|
||||
const result = await host.result()
|
||||
expect(result.value).toEqual({ first: 'x.ts' })
|
||||
const start = host.ofType(WorkerToHostType.ChildStart)[0]!
|
||||
expect(start.request.schema).toEqual({ type: 'object', properties: { files: { type: 'array', items: { type: 'string' } } } })
|
||||
expect(start.request.model).toBe('deepseek-v4-pro')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('agent({provider}) forwards a provider without inventing a model', async () => {
|
||||
const host = fakeHost({ reply: () => text('ok') })
|
||||
void runWorkerSession(host.port, init("return await agent('route me', { provider: 'openai' })"))
|
||||
const result = await host.result()
|
||||
expect(result.value).toBe('ok')
|
||||
const start = host.ofType(WorkerToHostType.ChildStart)[0]!
|
||||
expect(start.request.provider).toBe('openai')
|
||||
expect(start.request.model).toBeUndefined()
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a schema child completing WITHOUT a structured value resolves null with a failed outcome', async () => {
|
||||
const host = fakeHost({ reply: () => text('prose, no structure') })
|
||||
void runWorkerSession(host.port, init("return await agent('p', { schema: { type: 'object' } })"))
|
||||
const result = await host.result()
|
||||
expect(result.value).toBeNull()
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a child settling non-completed resolves null (scripts filter), never throwing into the script', async () => {
|
||||
const host = fakeHost({ reply: (_request, index) => index === 0 ? { output: [], stopReason: 'error' } : text('ok') })
|
||||
void runWorkerSession(host.port, init("return await parallel([() => agent('one'), () => agent('two')])"))
|
||||
const result = await host.result()
|
||||
expect(result.value).toEqual([null, 'ok'])
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd).map(m => m.info.outcome)).toEqual(expect.arrayContaining(['failed', 'completed']))
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a start refusal (child-start-error) is a fatal AGENT_START that kills the script through a combinator', async () => {
|
||||
const host = fakeHost({ refuse: () => 'no provider here' })
|
||||
void runWorkerSession(host.port, init("return await pipeline([1], () => agent('p'))"))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('agent() could not start a child')
|
||||
expect(result.error).toContain('no provider here')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a child-failed message (infrastructure rejection) is fatal AGENT_RESULT with the paired failed outcome', async () => {
|
||||
const host = fakeHost()
|
||||
void runWorkerSession(host.port, init(`
|
||||
try { await agent('p'); return 'unreachable' } catch (e) { return { name: e.name, code: e.code, fatal: e.fatal } }
|
||||
`))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
|
||||
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend exploded' })
|
||||
const result = await host.result()
|
||||
expect(result.value).toMatchObject({ name: 'WorkflowError', code: 'AGENT_RESULT', fatal: true })
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('failed')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('cancel before go: the body never runs at all and the result is cancelled (a second cancel is a no-op)', async () => {
|
||||
const host = fakeHost({ go: false })
|
||||
const session = runWorkerSession(host.port, init("log('ran')\nreturn 123"))
|
||||
await vi.waitFor(() => { expect(host.messages.some(m => m.type === WorkerToHostType.Ready)).toBe(true) })
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'aborted before start' })
|
||||
// Idempotence: the first reason wins; a duplicate cancel changes nothing.
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'a later reason that must lose' })
|
||||
const result = await host.result()
|
||||
await session
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(result.error).toContain('aborted before start')
|
||||
expect(result.error).not.toContain('must lose')
|
||||
expect(result.value).toBeNull()
|
||||
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a script with no return value resolves value: null', async () => {
|
||||
const host = fakeHost({ reply: () => text('ok') })
|
||||
void runWorkerSession(host.port, init("await agent('p')"))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBeNull()
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('cancel mid-run: hooks throw at entry and the run reports cancelled', async () => {
|
||||
const host = fakeHost()
|
||||
void runWorkerSession(host.port, init(`
|
||||
phase('before')
|
||||
try { await agent('x') } catch (e) {}
|
||||
try { phase('after') } catch (e) {}
|
||||
try { log('after') } catch (e) {}
|
||||
try { await parallel([() => 'ran']) } catch (e) {}
|
||||
try { await pipeline(['item'], p => p) } catch (e) {}
|
||||
return 'survived by catching'
|
||||
`))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'stop everything' })
|
||||
// The real host settles the aborted child; mirror it.
|
||||
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(result.error).toContain('stop everything')
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
|
||||
// No post-cancel narration left the runtime (the hooks threw at entry).
|
||||
expect(host.ofType(WorkerToHostType.Phase).map(m => m.title)).toEqual(['before'])
|
||||
expect(host.ofType(WorkerToHostType.Log)).toEqual([])
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('cancellation between a queued waiter and its slot: the waiter rejects without a child-start', async () => {
|
||||
const host = fakeHost({ go: true })
|
||||
void runWorkerSession(host.port, init(
|
||||
"return await parallel([() => agent('a'), () => agent('b')])",
|
||||
undefined,
|
||||
{ maxConcurrentAgents: 1 },
|
||||
))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'raced' })
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
// Only the first agent ever reached the host.
|
||||
expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1)
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a stray (never-awaited) agent is reaped after settlement: cancel + dispose RPCs flow, no unhandled rejection', async () => {
|
||||
const unhandled: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const host = fakeHost()
|
||||
void runWorkerSession(host.port, init(`
|
||||
agent('stray, never awaited')
|
||||
return 'done without awaiting'
|
||||
`))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('completed')
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
|
||||
host.send({ type: HostToWorkerType.ChildSettled, callId, result: { output: [], stopReason: 'aborted' } })
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) })
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(unhandled).toEqual([])
|
||||
host.close()
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('an unparseable body settles an error result instead of dying without one (host pre-parse skew guard)', async () => {
|
||||
const host = fakeHost()
|
||||
await runWorkerSession(host.port, init('return ((('))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('does not parse')
|
||||
expect(result.agentsStarted).toBe(0)
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a synchronous spin in the initial slice dies by the in-worker vm timeout', async () => {
|
||||
const host = fakeHost()
|
||||
void runWorkerSession(host.port, init('while (true) {}', undefined, { syncTimeoutMs: 50 }))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error?.toLowerCase()).toContain('timed out')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a non-JSON return value fails loud as RESULT_UNSERIALIZABLE', async () => {
|
||||
const host = fakeHost()
|
||||
void runWorkerSession(host.port, init('return { when: new Date(0) }'))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('not plain JSON data')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('tolerates replies for unknown callIds (a teardown race): nothing crashes, the run completes', async () => {
|
||||
const host = fakeHost({ reply: () => text('fine') })
|
||||
void runWorkerSession(host.port, init("return await agent('p')"))
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId: 999, childId: 'ghost' })
|
||||
host.send({ type: HostToWorkerType.ChildStartError, callId: 999, rendered: 'ghost' })
|
||||
host.send({ type: HostToWorkerType.ChildSettled, callId: 999, result: text('ghost') })
|
||||
host.send({ type: HostToWorkerType.ChildFailed, callId: 999, rendered: 'ghost' })
|
||||
host.send({ type: HostToWorkerType.ChildDisposed, callId: 999 })
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toBe('fine')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('caps and malformed hook arguments reject loud (the runtime runs unchanged inside the session)', async () => {
|
||||
const cases: [string, string][] = [
|
||||
['return await agent(42)', 'non-empty prompt string'],
|
||||
["return await agent('')", 'non-empty prompt string'],
|
||||
["return await agent('p', 'opts')", 'options must be an object'],
|
||||
["return await agent('p', { label: 3 })", '"label" must be a string'],
|
||||
["return await agent('p', { get label() { throw new Error('read failed') } })", 'options must be plain JSON data'],
|
||||
["return await agent('p', { bogus: true })", '"bogus" is not recognized'],
|
||||
["return await agent('p', { effort: 'high' })", '"effort" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)'],
|
||||
["return await agent('p', { schema: { type: 'object', oneOf: [] } })", 'outside the supported subset'],
|
||||
['return await parallel([() => 1, () => 2, () => 3])', 'over the per-call cap (2)'],
|
||||
['return await pipeline([1, 2, 3], (x) => x)', 'maxItemsPerCall'],
|
||||
["return await parallel('no')", 'parallel() requires an array'],
|
||||
['return await parallel([3])', 'item 0 is not a function'],
|
||||
["return await pipeline('no', () => 1)", 'pipeline() requires an items array'],
|
||||
['return await pipeline([1])', 'at least one stage'],
|
||||
["return await pipeline([1], 'x')", 'stage 0 is not a function'],
|
||||
["phase('')", 'phase() requires a non-empty title string'],
|
||||
['log(3)', 'log() requires a message string'],
|
||||
]
|
||||
for (const [body, expected] of cases) {
|
||||
const host = fakeHost({ reply: () => text('ok') })
|
||||
void runWorkerSession(host.port, init(body, undefined, { maxItemsPerCall: 2 }))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain(expected)
|
||||
host.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('combinator semantics: thunk/stage throws null the item; a forged fatal-shaped object stays null; real fatals propagate', async () => {
|
||||
const host = fakeHost({ reply: () => text('fine') })
|
||||
void runWorkerSession(host.port, init(`
|
||||
const viaParallel = await parallel([
|
||||
() => { throw new Error('boom') },
|
||||
() => agent('fine'),
|
||||
() => 'plain value',
|
||||
() => { throw { name: 'WorkflowError', fatal: true, message: 'forged fatal' } },
|
||||
])
|
||||
const viaPipeline = await pipeline([10, 20],
|
||||
(prev, item, index) => { if (item === 10) throw new Error('ordinary failure'); return 'kept-' + item + '-' + index },
|
||||
)
|
||||
return { viaParallel, viaPipeline }
|
||||
`))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.value).toEqual({
|
||||
viaParallel: [null, 'fine', 'plain value', null],
|
||||
viaPipeline: [null, 'kept-20-1'],
|
||||
})
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('trips the total-agent cap with a message naming the config knob', async () => {
|
||||
const host = fakeHost({ reply: () => text('ok') })
|
||||
void runWorkerSession(host.port, init("await agent('1'); await agent('2'); await agent('3')", undefined, { maxTotalAgents: 2 }))
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('error')
|
||||
expect(result.error).toContain('total agent cap (2)')
|
||||
expect(result.error).toContain('applicable maxTotalAgents limit')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('queued agents proceed through the concurrency semaphore in FIFO order', async () => {
|
||||
const host = fakeHost({ reply: request => text(`ok:${request.prompt}`) })
|
||||
void runWorkerSession(host.port, init(
|
||||
"return await parallel([1, 2, 3].map((n) => () => agent('job ' + n)))",
|
||||
undefined,
|
||||
{ maxConcurrentAgents: 1 },
|
||||
))
|
||||
const result = await host.result()
|
||||
expect(result.value).toEqual(['ok:job 1', 'ok:job 2', 'ok:job 3'])
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('labels default from the prompt first line, truncated; explicit label/phase options win', async () => {
|
||||
const host = fakeHost({ reply: () => text('ok') })
|
||||
void runWorkerSession(host.port, init(`
|
||||
phase('Find')
|
||||
await agent('a prompt that is quite long and will surely get truncated down to a display label\\n'
|
||||
+ 'with a second line the label must not include')
|
||||
await agent('short', { label: 'named', phase: 'Custom' })
|
||||
return null
|
||||
`))
|
||||
await host.result()
|
||||
const starts = host.ofType(WorkerToHostType.AgentStart).map(m => m.info)
|
||||
expect(starts[0]).toMatchObject({ seq: 1, phase: 'Find' })
|
||||
expect(starts[0]!.label.length).toBeLessThanOrEqual(48)
|
||||
expect(starts[0]!.label).not.toContain('second line')
|
||||
expect(starts[1]).toMatchObject({ seq: 2, label: 'named', phase: 'Custom' })
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('non-text output blocks are filtered out of the text result', async () => {
|
||||
const host = fakeHost({
|
||||
reply: () => ({
|
||||
output: [
|
||||
{ type: 'text', text: 'first ' },
|
||||
{ type: 'tool_call', id: 'c1', name: 'x', arguments: {} } as never,
|
||||
{ type: 'text', text: 'second' },
|
||||
],
|
||||
stopReason: 'completed',
|
||||
}),
|
||||
})
|
||||
void runWorkerSession(host.port, init("return await agent('p')"))
|
||||
const result = await host.result()
|
||||
expect(result.value).toBe('first second')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a cancel landing DURING the start round-trip disposes the fresh child and dies cancelled', async () => {
|
||||
const host = fakeHost({ manual: true })
|
||||
void runWorkerSession(host.port, init("return await agent('p')"))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
// Simulate a teardown race by delivering cancellation before a stale start reply.
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' })
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
await vi.waitFor(() => {
|
||||
expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId)
|
||||
})
|
||||
// The unpublished child is disposed without a lifecycle announcement.
|
||||
expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([])
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a start refusal arriving after a cancel reads as the cancellation, not a broken seam', async () => {
|
||||
const host = fakeHost({ manual: true })
|
||||
void runWorkerSession(host.port, init(`
|
||||
try { await agent('p'); return 'unreachable' } catch (e) { return { code: e.code } }
|
||||
`))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'stopping' })
|
||||
host.send({ type: HostToWorkerType.ChildStartError, callId, rendered: 'workflow run cancelled: stopping' })
|
||||
const result = await host.result()
|
||||
// The run reports cancelled (the script died of CANCELLED, not AGENT_START).
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
host.close()
|
||||
})
|
||||
|
||||
it('a child result rejection while cancelled pairs a cancelled agent-end, and the run reports cancelled', async () => {
|
||||
const host = fakeHost({ manual: true })
|
||||
void runWorkerSession(host.port, init("return await agent('doomed')"))
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) })
|
||||
const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId
|
||||
host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' })
|
||||
await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.AgentStart).length).toBe(1) })
|
||||
host.send({ type: HostToWorkerType.Cancel, reason: 'user aborted' })
|
||||
host.send({ type: HostToWorkerType.ChildFailed, callId, rendered: 'backend crashed on abort' })
|
||||
const result = await host.result()
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
expect(host.ofType(WorkerToHostType.AgentEnd)[0]!.info.outcome).toBe('cancelled')
|
||||
host.close()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('the worker bootstrap', () => {
|
||||
it('requireParentPort narrows a real port and throws on the main thread', () => {
|
||||
const channel = new MessageChannel()
|
||||
expect(requireParentPort(channel.port1)).toBe(channel.port1)
|
||||
channel.port1.close()
|
||||
expect(() => requireParentPort(null)).toThrow(/inside a worker thread/)
|
||||
})
|
||||
|
||||
it('the entry module itself throws when loaded on the main thread (no parentPort)', async () => {
|
||||
// This import EXECUTES ../src/worker.ts on the main thread, which is what
|
||||
// covers the bootstrap file: requireParentPort throws before
|
||||
// runWorkerSession is reached.
|
||||
await expect(import('../src/worker.ts')).rejects.toThrow(/inside a worker thread/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Keyless runtime smoke for the source-mode workflow worker. The Node
|
||||
* compatibility matrix runs this WHOLE file, so renaming or removing its test
|
||||
* cannot turn the runtime proof into a successful zero-match filter.
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import WorkerThreadWorkflowEngine from '../src/index.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
// A fresh thread compiles the source runtime. Leave contention headroom on
|
||||
// shared CI runners without weakening any engine-level timeout assertion.
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
it('runs the default config through the source worker', async () => {
|
||||
const ctx = new Context()
|
||||
const subagents = await ctx.plugin(SubagentRuntime)
|
||||
const provider: SubagentProvider = {
|
||||
name: 'spawn',
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('source-worker compat script must not start a child')),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
const engine = await ctx.plugin(WorkerThreadWorkflowEngine, {})
|
||||
const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent
|
||||
try {
|
||||
const run = ctx.workflowEngine.start({
|
||||
script: 'return 6 * 7',
|
||||
meta: { name: 'source-worker-compat', description: 'exercise the unbuilt worker entry' },
|
||||
parent,
|
||||
})
|
||||
try {
|
||||
await expect(run.result).resolves.toMatchObject({ value: 42, stopReason: 'completed', agentsStarted: 0 })
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
} finally {
|
||||
await engine.dispose()
|
||||
await subagents.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import WorkerThreadWorkflowEngine from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* With-key e2e: a REAL script in a REAL worker thread
|
||||
* drives REAL spawn children against the live DeepSeek API — one plain child
|
||||
* and one schema'd child through the real structured-output runtime — and
|
||||
* the run's value, events, and child sessions are asserted from the outside
|
||||
* (never the script's self-report alone). Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
})
|
||||
|
||||
async function harness(): Promise<Context> {
|
||||
const built = new Context()
|
||||
await built.plugin(LlmRuntime)
|
||||
await built.plugin(SessionStore)
|
||||
await built.plugin(SystemPrompt)
|
||||
await built.plugin(ToolRuntime)
|
||||
await built.plugin(AgentRegistry)
|
||||
await built.plugin(AgentLoop, { agents: [] })
|
||||
await built.plugin(LlmDeepSeek)
|
||||
await built.plugin(SubagentRuntime)
|
||||
await built.plugin(Spawn, { providerName: 'spawn' })
|
||||
await built.plugin(WorkerThreadWorkflowEngine, { provider: 'spawn' })
|
||||
return built
|
||||
}
|
||||
|
||||
const META = {
|
||||
name: 'e2e-worker-arithmetic',
|
||||
description: 'two real children through a worker thread: one prose, one structured',
|
||||
phases: [{ title: 'Ask' }, { title: 'Judge' }],
|
||||
}
|
||||
const SCRIPT = `phase('Ask')
|
||||
log('asking the prose child')
|
||||
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
|
||||
phase('Judge')
|
||||
const judged = await agent(
|
||||
'Here is an answer to the question "what is 2+2": ' + prose
|
||||
+ ' — report whether it contains the number 4 and your confidence between 0 and 1.',
|
||||
{ schema: { type: 'object', properties: { containsFour: { type: 'boolean' }, confidence: { type: 'number' } }, required: ['containsFour'] } },
|
||||
)
|
||||
return { prose, containsFour: judged === null ? null : judged.containsFour }`
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key e2e', () => {
|
||||
it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => {
|
||||
ctx = await harness()
|
||||
const parentHandle = await ctx.agents.create({
|
||||
sessionId: 'wf-worker-e2e-session' as never,
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
|
||||
const events: string[] = []
|
||||
const childIds: string[] = []
|
||||
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
|
||||
ctx.on(name, (...payload: unknown[]) => {
|
||||
events.push(name)
|
||||
if (name === 'workflow/agent-start') childIds.push((payload[1] as { childId: string }).childId)
|
||||
})
|
||||
}
|
||||
|
||||
const run = ctx.workflowEngine.start({ script: SCRIPT, meta: META, parent: parentHandle.agent })
|
||||
const result = await run.result
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
expect(result.agentsStarted).toBe(2)
|
||||
const value = result.value as { prose: string; containsFour: boolean | null }
|
||||
// World checks: the prose child really answered (a real completion), and
|
||||
// the structured child judged it against the REAL schema-forced tool.
|
||||
expect(value.prose.length).toBeGreaterThan(0)
|
||||
expect(value.containsFour).toBe(true)
|
||||
|
||||
expect(events[0]).toBe('workflow/start')
|
||||
expect(events.at(-1)).toBe('workflow/end')
|
||||
expect(events.filter(name => name === 'workflow/phase').length).toBe(2)
|
||||
expect(events.filter(name => name === 'workflow/agent-start').length).toBe(2)
|
||||
expect(childIds.length).toBe(2)
|
||||
// The children were disposed to quiescence after collection.
|
||||
for (const childId of childIds) {
|
||||
expect(ctx.agents.get(SessionId(childId))).toBeUndefined()
|
||||
}
|
||||
await parentHandle.dispose()
|
||||
}, 240_000)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user