|
|
@@ -5,6 +5,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
import SessionProjections from '@deepseek-ai/dsh-session-projection'
|
|
|
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
|
|
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
|
|
+import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
|
|
|
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
|
import PtcWorkflowEngine from '../src/index.ts'
|
|
|
import { fakeParent } from './setup.ts'
|
|
|
@@ -24,7 +25,7 @@ class ControlledRuntime extends PtcRuntime {
|
|
|
run(spec: PtcRunSpec): Promise<PtcRunResult> { return this.execute(spec) }
|
|
|
}
|
|
|
|
|
|
-async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Promise<PtcRunResult>) {
|
|
|
+async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Promise<PtcRunResult>, language = 'typescript') {
|
|
|
const ctx = new Context()
|
|
|
onTestFinished(async () => { await ctx.fiber.dispose() })
|
|
|
await ctx.plugin(SessionStore)
|
|
|
@@ -44,6 +45,7 @@ async function setup(execute?: (bindings: HostBindings, spec: PtcRunSpec) => Pro
|
|
|
})
|
|
|
await ctx.plugin(ControlledRuntime)
|
|
|
const runtime = ctx.ptcRuntime as ControlledRuntime
|
|
|
+ runtime.language = language
|
|
|
if (execute !== undefined) runtime.execute = spec => execute(spec.bindings[0]!.functions, spec)
|
|
|
await ctx.plugin(PtcWorkflowEngine, { provider: 'stub' })
|
|
|
const parent = fakeParent(ctx)
|
|
|
@@ -60,10 +62,11 @@ describe('workflow host callback validation', () => {
|
|
|
['startChild', { prompt: 7 }, 'prompt must be a string'],
|
|
|
['startChild', { prompt: 'p', provider: 7 }, 'provider must be a string'],
|
|
|
['startChild', { prompt: 'p', model: false }, 'model must be a string'],
|
|
|
- ['progress', { type: 'phase', title: null }, 'phase must be a string'],
|
|
|
- ['progress', { type: 'agent-start', info: { seq: 0 } }, 'sequence must be a positive integer'],
|
|
|
- ['progress', { type: 'agent-end', info: { outcome: 'unknown' } }, 'invalid workflow agent outcome'],
|
|
|
- ['progress', { type: 'unknown' }, 'invalid workflow progress event'],
|
|
|
+ ['progress', {}, 'requires an array of events'],
|
|
|
+ ['progress', [{ type: 'phase', title: null }], 'phase must be a string'],
|
|
|
+ ['progress', [{ type: 'agent-start', info: { seq: 0 } }], 'sequence must be a positive integer'],
|
|
|
+ ['progress', [{ type: 'agent-end', info: { outcome: 'unknown' } }], 'invalid workflow agent outcome'],
|
|
|
+ ['progress', [{ type: 'unknown' }], 'invalid workflow progress event'],
|
|
|
['childResult', { callId: '1' }, 'call id must be an integer'],
|
|
|
['disposeChild', { callId: 99 }, 'child call is not active'],
|
|
|
] as const)('rejects malformed %s callback data %j', async (name, input, message) => {
|
|
|
@@ -79,9 +82,9 @@ describe('workflow host callback validation', () => {
|
|
|
it('does not emit duplicate agent-end notifications', async () => {
|
|
|
const info = { seq: 1, label: 'child', childId: 'host-child' }
|
|
|
const { ctx, start } = await setup(async (bindings) => {
|
|
|
- await bindings.progress!({ type: 'agent-start', info })
|
|
|
- await bindings.progress!({ type: 'agent-end', info: { ...info, outcome: 'failed' } })
|
|
|
- await bindings.progress!({ type: 'agent-end', info: { ...info, outcome: 'cancelled' } })
|
|
|
+ await bindings.progress!([{ type: 'agent-start', info }])
|
|
|
+ await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'failed' } }])
|
|
|
+ await bindings.progress!([{ type: 'agent-end', info: { ...info, outcome: 'cancelled' } }])
|
|
|
return completed
|
|
|
})
|
|
|
const ended = vi.fn()
|
|
|
@@ -142,12 +145,35 @@ describe('workflow runtime outcomes', () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- it('rejects a non-TypeScript runtime before publishing workflow/start', async () => {
|
|
|
- const { ctx, runtime, start } = await setup()
|
|
|
- runtime.language = 'python'
|
|
|
- const started = vi.fn()
|
|
|
- ctx.on('workflow/start', started)
|
|
|
- expect(start).toThrow('requires the Node TypeScript PTC runtime')
|
|
|
- expect(started).not.toHaveBeenCalled()
|
|
|
+ it('rejects a non-TypeScript runtime while loading the workflow provider', async () => {
|
|
|
+ await expect(setup(undefined, 'python')).rejects.toThrow('requires the Node TypeScript PTC runtime')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stops waiting for child output after disposal releases the child resources', async () => {
|
|
|
+ const childResult = Promise.withResolvers<SubagentResult>()
|
|
|
+ const disposed = vi.fn(() => Promise.resolve())
|
|
|
+ let outputWait: Promise<unknown> | undefined
|
|
|
+ const { ctx, start } = await setup(async (bindings) => {
|
|
|
+ const child = await bindings.startChild!({ prompt: 'child' })
|
|
|
+ outputWait = bindings.childResult!(child)
|
|
|
+ void outputWait.catch(() => {})
|
|
|
+ return completed
|
|
|
+ })
|
|
|
+ vi.spyOn(ctx.subagents.getProvider('stub')!, 'start').mockResolvedValue({
|
|
|
+ id: SessionId('output-pending'), localAgent: undefined, result: childResult.promise, dispose: disposed,
|
|
|
+ })
|
|
|
+ const handle = start()
|
|
|
+ let settled = false
|
|
|
+ void handle.result.then(() => { settled = true })
|
|
|
+ try {
|
|
|
+ await new Promise(resolve => setImmediate(resolve))
|
|
|
+ expect(settled).toBe(true)
|
|
|
+ expect(disposed).toHaveBeenCalledOnce()
|
|
|
+ await expect(outputWait).rejects.toBe('workflow settled')
|
|
|
+ expect((await handle.result).stopReason).toBe('completed')
|
|
|
+ } finally {
|
|
|
+ childResult.resolve({ output: [], stopReason: 'aborted' })
|
|
|
+ await handle.dispose()
|
|
|
+ }
|
|
|
})
|
|
|
})
|