扣子智能体
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
coze_studio/backend/api/handler/coze/workflow_service_test.go

4761 lines
152 KiB

/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package coze
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/bytedance/mockey"
"github.com/cloudwego/eino/callbacks"
model2 "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/client"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/common/ut"
"github.com/cloudwego/hertz/pkg/protocol"
"github.com/cloudwego/hertz/pkg/protocol/sse"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/coze-dev/coze-studio/backend/domain/workflow/config"
"github.com/coze-dev/coze-studio/backend/api/model/crossdomain/knowledge"
modelknowledge "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/knowledge"
model "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/modelmgr"
plugin2 "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/plugin"
pluginmodel "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/plugin"
workflowModel "github.com/coze-dev/coze-studio/backend/api/model/crossdomain/workflow"
"github.com/coze-dev/coze-studio/backend/api/model/playground"
pluginAPI "github.com/coze-dev/coze-studio/backend/api/model/plugin_develop"
"github.com/coze-dev/coze-studio/backend/api/model/workflow"
"github.com/coze-dev/coze-studio/backend/application/base/ctxutil"
appknowledge "github.com/coze-dev/coze-studio/backend/application/knowledge"
appmemory "github.com/coze-dev/coze-studio/backend/application/memory"
appplugin "github.com/coze-dev/coze-studio/backend/application/plugin"
"github.com/coze-dev/coze-studio/backend/application/user"
appworkflow "github.com/coze-dev/coze-studio/backend/application/workflow"
crossdatabase "github.com/coze-dev/coze-studio/backend/crossdomain/contract/database"
"github.com/coze-dev/coze-studio/backend/crossdomain/contract/database/databasemock"
crossknowledge "github.com/coze-dev/coze-studio/backend/crossdomain/contract/knowledge"
"github.com/coze-dev/coze-studio/backend/crossdomain/contract/knowledge/knowledgemock"
crossmodelmgr "github.com/coze-dev/coze-studio/backend/crossdomain/contract/modelmgr"
mockmodel "github.com/coze-dev/coze-studio/backend/crossdomain/contract/modelmgr/modelmock"
crossplugin "github.com/coze-dev/coze-studio/backend/crossdomain/contract/plugin"
"github.com/coze-dev/coze-studio/backend/crossdomain/contract/plugin/pluginmock"
crossuser "github.com/coze-dev/coze-studio/backend/crossdomain/contract/user"
"github.com/coze-dev/coze-studio/backend/crossdomain/impl/code"
pluginImpl "github.com/coze-dev/coze-studio/backend/crossdomain/impl/plugin"
entity4 "github.com/coze-dev/coze-studio/backend/domain/memory/database/entity"
entity2 "github.com/coze-dev/coze-studio/backend/domain/openauth/openapiauth/entity"
entity3 "github.com/coze-dev/coze-studio/backend/domain/plugin/entity"
entity5 "github.com/coze-dev/coze-studio/backend/domain/plugin/entity"
search "github.com/coze-dev/coze-studio/backend/domain/search/entity"
userentity "github.com/coze-dev/coze-studio/backend/domain/user/entity"
workflow2 "github.com/coze-dev/coze-studio/backend/domain/workflow"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
"github.com/coze-dev/coze-studio/backend/domain/workflow/service"
"github.com/coze-dev/coze-studio/backend/domain/workflow/variable"
mockvar "github.com/coze-dev/coze-studio/backend/domain/workflow/variable/varmock"
"github.com/coze-dev/coze-studio/backend/infra/contract/coderunner"
"github.com/coze-dev/coze-studio/backend/infra/contract/modelmgr"
"github.com/coze-dev/coze-studio/backend/infra/impl/cache/redis"
"github.com/coze-dev/coze-studio/backend/infra/impl/checkpoint"
"github.com/coze-dev/coze-studio/backend/infra/impl/coderunner/direct"
mockCrossUser "github.com/coze-dev/coze-studio/backend/internal/mock/crossdomain/crossuser"
mockPlugin "github.com/coze-dev/coze-studio/backend/internal/mock/domain/plugin"
mockcode "github.com/coze-dev/coze-studio/backend/internal/mock/domain/workflow/crossdomain/code"
mock "github.com/coze-dev/coze-studio/backend/internal/mock/infra/contract/idgen"
storageMock "github.com/coze-dev/coze-studio/backend/internal/mock/infra/contract/storage"
"github.com/coze-dev/coze-studio/backend/internal/testutil"
"github.com/coze-dev/coze-studio/backend/pkg/ctxcache"
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
"github.com/coze-dev/coze-studio/backend/pkg/lang/slices"
"github.com/coze-dev/coze-studio/backend/pkg/lang/ternary"
"github.com/coze-dev/coze-studio/backend/pkg/sonic"
"github.com/coze-dev/coze-studio/backend/types/consts"
"github.com/coze-dev/coze-studio/backend/types/errno"
)
func TestMain(m *testing.M) {
callbacks.AppendGlobalHandlers(service.GetTokenCallbackHandler())
service.RegisterAllNodeAdaptors()
os.Exit(m.Run())
}
type wfTestRunner struct {
t *testing.T
h *server.Hertz
ctrl *gomock.Controller
idGen *mock.MockIDGenerator
appVarS *mockvar.MockStore
userVarS *mockvar.MockStore
varGetter *mockvar.MockVariablesMetaGetter
modelManage *mockmodel.MockManager
plugin *mockPlugin.MockPluginService
tos *storageMock.MockStorage
knowledge *knowledgemock.MockKnowledge
database *databasemock.MockDatabase
pluginSrv *pluginmock.MockPluginService
internalModel *testutil.UTChatModel
publishPatcher *mockey.Mocker
ctx context.Context
closeFn func()
}
var req2URL = map[reflect.Type]string{
reflect.TypeOf(&workflow.NodeTemplateListRequest{}): "/api/workflow_api/node_template_list",
reflect.TypeOf(&workflow.CreateWorkflowRequest{}): "/api/workflow_api/create",
reflect.TypeOf(&workflow.SaveWorkflowRequest{}): "/api/workflow_api/save",
reflect.TypeOf(&workflow.DeleteWorkflowRequest{}): "/api/workflow_api/delete",
reflect.TypeOf(&workflow.GetCanvasInfoRequest{}): "/api/workflow_api/canvas",
reflect.TypeOf(&workflow.WorkFlowTestRunRequest{}): "/api/workflow_api/test_run",
reflect.TypeOf(&workflow.CancelWorkFlowRequest{}): "/api/workflow_api/cancel",
reflect.TypeOf(&workflow.PublishWorkflowRequest{}): "/api/workflow_api/publish",
reflect.TypeOf(&workflow.OpenAPIRunFlowRequest{}): "/v1/workflow/run",
reflect.TypeOf(&workflow.ValidateTreeRequest{}): "/api/workflow_api/validate_tree",
reflect.TypeOf(&workflow.WorkflowTestResumeRequest{}): "/api/workflow_api/test_resume",
reflect.TypeOf(&workflow.WorkflowNodeDebugV2Request{}): "/api/workflow_api/nodeDebug",
reflect.TypeOf(&workflow.QueryWorkflowNodeTypeRequest{}): "/api/workflow_api/node_type",
reflect.TypeOf(&workflow.GetWorkFlowListRequest{}): "/api/workflow_api/workflow_list",
reflect.TypeOf(&workflow.UpdateWorkflowMetaRequest{}): "/api/workflow_api/update_meta",
reflect.TypeOf(&workflow.GetWorkflowDetailRequest{}): "/api/workflow_api/workflow_detail",
reflect.TypeOf(&workflow.GetWorkflowDetailInfoRequest{}): "/api/workflow_api/workflow_detail_info",
reflect.TypeOf(&workflow.GetLLMNodeFCSettingDetailRequest{}): "/api/workflow_api/llm_fc_setting_detail",
reflect.TypeOf(&workflow.GetLLMNodeFCSettingsMergedRequest{}): "/api/workflow_api/llm_fc_setting_merged",
reflect.TypeOf(&workflow.CopyWorkflowRequest{}): "/api/workflow_api/copy",
reflect.TypeOf(&workflow.BatchDeleteWorkflowRequest{}): "/api/workflow_api/batch_delete",
reflect.TypeOf(&workflow.GetHistorySchemaRequest{}): "/api/workflow_api/history_schema",
reflect.TypeOf(&workflow.GetWorkflowReferencesRequest{}): "/api/workflow_api/workflow_references",
}
func newWfTestRunner(t *testing.T) *wfTestRunner {
h := server.Default()
h.Use(func(c context.Context, ctx *app.RequestContext) {
c = ctxcache.Init(c)
ctxcache.Store(c, consts.SessionDataKeyInCtx, &userentity.Session{
UserID: 123,
})
ctx.Next(c)
})
h.POST("/api/workflow_api/node_template_list", NodeTemplateList)
h.POST("/api/workflow_api/create", CreateWorkflow)
h.POST("/api/workflow_api/save", SaveWorkflow)
h.POST("/api/workflow_api/delete", DeleteWorkflow)
h.POST("/api/workflow_api/canvas", GetCanvasInfo)
h.POST("/api/workflow_api/test_run", WorkFlowTestRun)
h.GET("/api/workflow_api/get_process", GetWorkFlowProcess)
h.POST("/api/workflow_api/validate_tree", ValidateTree)
h.POST("/api/workflow_api/test_resume", WorkFlowTestResume)
h.POST("/api/workflow_api/publish", PublishWorkflow)
h.POST("/api/workflow_api/update_meta", UpdateWorkflowMeta)
h.POST("/api/workflow_api/cancel", CancelWorkFlow)
h.POST("/api/workflow_api/workflow_list", GetWorkFlowList)
h.POST("/api/workflow_api/workflow_detail", GetWorkflowDetail)
h.POST("/api/workflow_api/workflow_detail_info", GetWorkflowDetailInfo)
h.POST("/api/workflow_api/llm_fc_setting_detail", GetLLMNodeFCSettingDetail)
h.POST("/api/workflow_api/llm_fc_setting_merged", GetLLMNodeFCSettingsMerged)
h.POST("/v1/workflow/run", OpenAPIRunFlow)
h.POST("/v1/workflow/stream_run", OpenAPIStreamRunFlow)
h.POST("/v1/workflow/stream_resume", OpenAPIStreamResumeFlow)
h.POST("/api/workflow_api/nodeDebug", WorkflowNodeDebugV2)
h.GET("/api/workflow_api/get_node_execute_history", GetNodeExecuteHistory)
h.POST("/api/workflow_api/copy", CopyWorkflow)
h.POST("/api/workflow_api/batch_delete", BatchDeleteWorkflow)
h.POST("/api/workflow_api/node_type", QueryWorkflowNodeTypes)
h.GET("/v1/workflow/get_run_history", OpenAPIGetWorkflowRunHistory)
h.POST("/api/workflow_api/history_schema", GetHistorySchema)
h.POST("/api/workflow_api/workflow_references", GetWorkflowReferences)
ctrl := gomock.NewController(t, gomock.WithOverridableExpectations())
mockIDGen := mock.NewMockIDGenerator(ctrl)
var previousID atomic.Int64
mockIDGen.EXPECT().GenID(gomock.Any()).DoAndReturn(func(_ context.Context) (int64, error) {
newID := time.Now().UnixNano()
for {
if newID == previousID.Load() {
newID = time.Now().UnixNano()
} else {
previousID.Store(newID)
break
}
}
return newID, nil
}).AnyTimes()
mockIDGen.EXPECT().GenMultiIDs(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, count int) ([]int64, error) {
ids := make([]int64, count)
for i := 0; i < count; i++ {
newID := time.Now().UnixNano()
for {
if newID == previousID.Load() {
newID = time.Now().UnixNano()
} else {
previousID.Store(newID)
break
}
}
ids[i] = newID
}
return ids, nil
}).AnyTimes()
dsn := "root:root@tcp(127.0.0.1:3306)/opencoze?charset=utf8mb4&parseTime=True&loc=Local"
if os.Getenv("CI_JOB_NAME") != "" {
dsn = strings.ReplaceAll(dsn, "127.0.0.1", "mysql")
}
db, err := gorm.Open(mysql.Open(dsn))
assert.NoError(t, err)
s, err := miniredis.Run()
if err != nil {
t.Fatalf("Failed to start miniredis: %v", err)
}
redisClient := redis.NewWithAddrAndPassword(s.Addr(), "")
cpStore := checkpoint.NewRedisStore(redisClient)
utChatModel := &testutil.UTChatModel{}
mockTos := storageMock.NewMockStorage(ctrl)
mockTos.EXPECT().GetObjectUrl(gomock.Any(), gomock.Any(), gomock.Any()).Return("", nil).AnyTimes()
workflowRepo, _ := service.NewWorkflowRepository(mockIDGen, db, redisClient, mockTos, cpStore, utChatModel, &config.WorkflowConfig{
NodeOfCodeConfig: &config.NodeOfCodeConfig{},
})
mockey.Mock(appworkflow.GetWorkflowDomainSVC).Return(service.NewWorkflowService(workflowRepo)).Build()
mockey.Mock(workflow2.GetRepository).Return(workflowRepo).Build()
publishPatcher := mockey.Mock(appworkflow.PublishWorkflowResource).Return(nil).Build()
mockCU := mockCrossUser.NewMockUser(ctrl)
mockCU.EXPECT().GetUserSpaceList(gomock.Any(), gomock.Any()).Return([]*crossuser.EntitySpace{
{
ID: 123,
},
}, nil).AnyTimes()
mockGlobalAppVarStore := mockvar.NewMockStore(ctrl)
mockGlobalAppVarStore.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
mockGlobalUserVarStore := mockvar.NewMockStore(ctrl)
mockGlobalUserVarStore.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
vh := mockey.Mock(variable.GetVariableHandler).Return(&variable.Handler{
AppVarStore: mockGlobalAppVarStore,
UserVarStore: mockGlobalUserVarStore,
}).Build()
mockVarGetter := mockvar.NewMockVariablesMetaGetter(ctrl)
m2 := mockey.Mock(variable.GetVariablesMetaGetter).Return(mockVarGetter).Build()
mPlugin := mockPlugin.NewMockPluginService(ctrl)
mockKwOperator := knowledgemock.NewMockKnowledge(ctrl)
crossknowledge.SetDefaultSVC(mockKwOperator)
mockModelManage := mockmodel.NewMockManager(ctrl)
mockModelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(nil, nil, nil).AnyTimes()
m3 := mockey.Mock(crossmodelmgr.DefaultSVC).Return(mockModelManage).Build()
m := mockey.Mock(crossuser.DefaultSVC).Return(mockCU).Build()
m1 := mockey.Mock(ctxutil.GetApiAuthFromCtx).Return(&entity2.ApiKey{
UserID: 123,
ConnectorID: consts.APIConnectorID,
}).Build()
m4 := mockey.Mock(ctxutil.MustGetUIDFromCtx).Return(int64(1)).Build()
m5 := mockey.Mock(ctxutil.GetUIDFromCtx).Return(ptr.Of(int64(1))).Build()
mockDatabaseOperator := databasemock.NewMockDatabase(ctrl)
crossdatabase.SetDefaultSVC(mockDatabaseOperator)
mockPluginSrv := pluginmock.NewMockPluginService(ctrl)
crossplugin.SetDefaultSVC(mockPluginSrv)
mockey.Mock((*user.UserApplicationService).MGetUserBasicInfo).Return(&playground.MGetUserBasicInfoResponse{
UserBasicInfoMap: make(map[string]*playground.UserBasicInfo),
}, nil).Build()
f := func() {
publishPatcher.UnPatch()
m.UnPatch()
m1.UnPatch()
m2.UnPatch()
m3.UnPatch()
m4.UnPatch()
m5.UnPatch()
vh.UnPatch()
ctrl.Finish()
_ = h.Close()
}
return &wfTestRunner{
t: t,
h: h,
ctrl: ctrl,
idGen: mockIDGen,
appVarS: mockGlobalAppVarStore,
userVarS: mockGlobalUserVarStore,
varGetter: mockVarGetter,
modelManage: mockModelManage,
plugin: mPlugin,
tos: mockTos,
knowledge: mockKwOperator,
database: mockDatabaseOperator,
internalModel: utChatModel,
ctx: context.Background(),
closeFn: f,
pluginSrv: mockPluginSrv,
publishPatcher: publishPatcher,
}
}
type PostOption struct {
Headers map[string]string
}
type PostOptionFn func(option *PostOption)
func WithHeaders(hds map[string]string) PostOptionFn {
return func(option *PostOption) {
if option.Headers == nil {
option.Headers = map[string]string{}
}
for k, v := range hds {
option.Headers[k] = v
}
}
}
func post[T any](r *wfTestRunner, req any, opts ...PostOptionFn) *T {
// if req has a field SpaceID, set it's value to "123"
opt := &PostOption{}
for _, fn := range opts {
fn(opt)
}
typ := reflect.TypeOf(req)
if typ.Kind() == reflect.Ptr {
typ1 := typ.Elem()
spaceField, ok := typ1.FieldByName("SpaceID")
if ok {
if spaceField.Type == reflect.TypeOf("") {
reflect.ValueOf(req).Elem().FieldByName("SpaceID").SetString("123")
} else {
reflect.ValueOf(req).Elem().FieldByName("SpaceID").Set(reflect.ValueOf(ptr.Of("123")))
}
}
}
url := req2URL[typ]
m, err := sonic.Marshal(req)
assert.NoError(r.t, err)
headers := make([]ut.Header, 0)
headers = append(headers, ut.Header{
Key: "Content-Type",
Value: "application/json",
})
for k, v := range opt.Headers {
headers = append(headers, ut.Header{Key: k, Value: v})
}
w := ut.PerformRequest(r.h.Engine, "POST", url, &ut.Body{Body: bytes.NewBuffer(m), Len: len(m)},
headers...)
res := w.Result()
if res.StatusCode() != http.StatusOK {
r.t.Fatalf("unexpected status code: %d, body: %s", res.StatusCode(), string(res.Body()))
}
rBody := res.Body()
var resp T
err = sonic.Unmarshal(rBody, &resp)
if err != nil {
r.t.Fatalf("failed to unmarshal response body: %v", err)
}
return &resp
}
func (r *wfTestRunner) postWithError(req any) string {
m, err := sonic.Marshal(req)
assert.NoError(r.t, err)
url := req2URL[reflect.TypeOf(req)]
w := ut.PerformRequest(r.h.Engine, "POST", url, &ut.Body{Body: bytes.NewBuffer(m), Len: len(m)},
ut.Header{Key: "Content-Type", Value: "application/json"})
res := w.Result()
if res.StatusCode() == http.StatusOK {
r.t.Errorf("expected error, but got none")
}
return string(res.Body())
}
type loadOptions struct {
name string
id int64
req *workflow.CreateWorkflowRequest
version string
projectID int64
data []byte
}
func withWorkflowData(data []byte) func(*loadOptions) {
return func(o *loadOptions) {
o.data = data
}
}
func withName(n string) func(*loadOptions) {
return func(o *loadOptions) {
o.name = n
}
}
func withID(id int64) func(*loadOptions) {
return func(o *loadOptions) {
o.id = id
}
}
func withProjectID(id int64) func(*loadOptions) {
return func(o *loadOptions) {
o.projectID = id
}
}
func withPublish(version string) func(*loadOptions) {
return func(o *loadOptions) {
o.version = version
}
}
func (r *wfTestRunner) load(schemaFile string, opts ...func(*loadOptions)) string {
loadOpts := &loadOptions{}
for _, opt := range opts {
opt(loadOpts)
}
if loadOpts.id > 0 {
_, err := appworkflow.GetWorkflowDomainSVC().Get(context.Background(), &vo.GetPolicy{
ID: loadOpts.id,
MetaOnly: true,
})
if err == nil {
return strconv.FormatInt(loadOpts.id, 10)
} else {
r.idGen.EXPECT().GenID(gomock.Any()).DoAndReturn(func(_ context.Context) (int64, error) {
return loadOpts.id, nil
}).Times(3)
defer func() {
var previousID atomic.Int64
r.idGen.EXPECT().GenID(gomock.Any()).DoAndReturn(func(_ context.Context) (int64, error) {
newID := time.Now().UnixNano()
if newID == previousID.Load() {
newID = previousID.Add(1)
}
return newID, nil
}).AnyTimes()
}()
}
}
var createReq *workflow.CreateWorkflowRequest
if loadOpts.req != nil {
createReq = loadOpts.req
} else {
name := "test_wf"
if loadOpts.name != "" {
name = loadOpts.name
}
createReq = &workflow.CreateWorkflowRequest{
Name: name,
Desc: "this is a test wf",
IconURI: "icon/uri",
SpaceID: "123",
FlowMode: ptr.Of(workflow.WorkflowMode_Workflow),
}
if loadOpts.projectID > 0 {
createReq.ProjectID = ptr.Of(strconv.FormatInt(loadOpts.projectID, 10))
}
}
resp := post[workflow.CreateWorkflowResponse](r, createReq)
idStr := resp.Data.WorkflowID
_, err := strconv.ParseInt(idStr, 10, 64)
assert.NoError(r.t, err)
var data []byte
if len(loadOpts.data) > 0 {
data = loadOpts.data
} else {
data, err = os.ReadFile(fmt.Sprintf("../../../domain/workflow/internal/canvas/examples/%s", schemaFile))
assert.NoError(r.t, err)
}
saveReq := &workflow.SaveWorkflowRequest{
WorkflowID: idStr,
Schema: ptr.Of(string(data)),
SpaceID: ptr.Of("123"),
}
_ = post[workflow.SaveWorkflowResponse](r, saveReq)
if loadOpts.version != "" {
r.publish(idStr, loadOpts.version, true)
}
return idStr
}
func getProcess(t *testing.T, h *server.Hertz, idStr string, exeID string) *workflow.GetWorkflowProcessResponse {
getProcessReq := &workflow.GetWorkflowProcessRequest{
WorkflowID: idStr,
SpaceID: "123",
ExecuteID: ptr.Of(exeID),
}
w := ut.PerformRequest(h.Engine, "GET", fmt.Sprintf("/api/workflow_api/get_process?workflow_id=%s&space_id=%s&execute_id=%s", getProcessReq.WorkflowID, getProcessReq.SpaceID, *getProcessReq.ExecuteID), nil,
ut.Header{Key: "Content-Type", Value: "application/json"})
res := w.Result()
if res.StatusCode() != http.StatusOK {
t.Fatalf("unexpected status code: %d, body: %s", res.StatusCode(), string(res.Body()))
}
getProcessResp := &workflow.GetWorkflowProcessResponse{}
err := sonic.Unmarshal(res.Body(), getProcessResp)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
return getProcessResp
}
func (r *wfTestRunner) getNodeExeHistory(id string, exeID string, nodeID string, scene *workflow.NodeHistoryScene) *workflow.NodeResult {
getNodeExeHistoryReq := &workflow.GetNodeExecuteHistoryRequest{
WorkflowID: id,
SpaceID: "123",
ExecuteID: exeID,
NodeID: nodeID,
NodeHistoryScene: scene,
}
w := ut.PerformRequest(r.h.Engine, "GET", fmt.Sprintf("/api/workflow_api/get_node_execute_history?workflow_id=%s&space_id=%s&execute_id=%s"+
"&node_id=%s&node_type=3&node_history_scene=%d", getNodeExeHistoryReq.WorkflowID, getNodeExeHistoryReq.SpaceID, getNodeExeHistoryReq.ExecuteID,
getNodeExeHistoryReq.NodeID, getNodeExeHistoryReq.GetNodeHistoryScene()), nil,
ut.Header{Key: "Content-Type", Value: "application/json"})
res := w.Result()
assert.Equal(r.t, http.StatusOK, res.StatusCode())
getNodeResultResp := &workflow.GetNodeExecuteHistoryResponse{}
err := sonic.Unmarshal(res.Body(), getNodeResultResp)
assert.NoError(r.t, err)
return getNodeResultResp.Data
}
func (r *wfTestRunner) getOpenAPIProcess(id string, exeID string) *workflow.GetWorkflowRunHistoryResponse {
w := ut.PerformRequest(r.h.Engine, "GET", fmt.Sprintf("/v1/workflow/get_run_history?workflow_id=%s&execute_id=%s", id, exeID), nil,
ut.Header{Key: "Content-Type", Value: "application/json"})
res := w.Result()
assert.Equal(r.t, http.StatusOK, res.StatusCode())
getProcessResp := &workflow.GetWorkflowRunHistoryResponse{}
err := sonic.Unmarshal(res.Body(), getProcessResp)
assert.NoError(r.t, err)
return getProcessResp
}
func mustUnmarshalToMap(t *testing.T, s string) map[string]any {
r := make(map[string]any)
err := sonic.UnmarshalString(s, &r)
if err != nil {
t.Fatal(err)
}
return r
}
func mustMarshalToString(t *testing.T, m any) string {
b, err := sonic.MarshalString(m)
if err != nil {
t.Fatal(err)
}
return b
}
func (r *wfTestRunner) testRun(id string, input map[string]string) string {
testRunReq := &workflow.WorkFlowTestRunRequest{
WorkflowID: id,
Input: input,
}
testRunResponse := post[workflow.WorkFlowTestRunResponse](r, testRunReq)
return testRunResponse.Data.ExecuteID
}
type getProcessOptions struct {
previousInterruptEventID string
specificNodeID string
}
func withPreviousEventID(id string) func(options *getProcessOptions) {
return func(options *getProcessOptions) {
options.previousInterruptEventID = id
}
}
func withSpecificNodeID(id string) func(options *getProcessOptions) {
return func(options *getProcessOptions) {
options.specificNodeID = id
}
}
type exeResult struct {
output string
status workflow.WorkflowExeStatus
event *workflow.NodeEvent
token *workflow.TokenAndCost
t *testing.T
reason string
}
func (e *exeResult) assertSuccess() {
assert.Equal(e.t, workflow.WorkflowExeStatus_Success, e.status)
}
func (e *exeResult) tokenEqual(in, out int) {
assert.NotNil(e.t, e.token)
input := strings.TrimSuffix(*e.token.InputTokens, " Tokens")
output := strings.TrimSuffix(*e.token.OutputTokens, " Tokens")
inputI, err := strconv.Atoi(input)
assert.NoError(e.t, err)
outputI, err := strconv.Atoi(output)
assert.NoError(e.t, err)
assert.Equal(e.t, in, inputI)
assert.Equal(e.t, out, outputI)
}
func (r *wfTestRunner) getProcess(id, exeID string, opts ...func(options *getProcessOptions)) *exeResult {
options := &getProcessOptions{}
for _, opt := range opts {
opt(options)
}
workflowStatus := workflow.WorkflowExeStatus_Running
var output string
var nodeEvent *workflow.NodeEvent
var eventID string
var nodeType string
var token *workflow.TokenAndCost
var reason string
for {
if nodeEvent != nil {
if options.previousInterruptEventID != "" {
if options.previousInterruptEventID != nodeEvent.ID {
break
}
} else {
break
}
}
if workflowStatus != workflow.WorkflowExeStatus_Running {
break
}
getProcessResp := getProcess(r.t, r.h, id, exeID)
if len(getProcessResp.Data.NodeResults) == 1 {
output = getProcessResp.Data.NodeResults[0].Output
nodeType = getProcessResp.Data.NodeResults[0].NodeType
} else {
for _, ns := range getProcessResp.Data.NodeResults {
if options.specificNodeID != "" {
if ns.NodeId == options.specificNodeID {
output = ns.Output
nodeType = ns.NodeType
break
}
} else if ns.NodeType == workflow.NodeTemplateType_End.String() {
output = ns.Output
nodeType = ns.NodeType
}
}
}
if len(getProcessResp.Data.NodeEvents) > 0 {
nodeEvent = getProcessResp.Data.NodeEvents[len(getProcessResp.Data.NodeEvents)-1]
}
workflowStatus = getProcessResp.Data.ExecuteStatus
token = getProcessResp.Data.TokenAndCost
if getProcessResp.Data.Reason != nil {
reason = *getProcessResp.Data.Reason
}
if nodeEvent != nil {
eventID = nodeEvent.ID
}
r.t.Logf("getProcess output= %s, status= %v, eventID= %s, nodeType= %s", output, workflowStatus, eventID, nodeType)
}
return &exeResult{
output: output,
status: workflowStatus,
event: nodeEvent,
token: token,
t: r.t,
reason: reason,
}
}
func (r *wfTestRunner) cancel(id, exeID string) {
cancelReq := &workflow.CancelWorkFlowRequest{
WorkflowID: &id,
ExecuteID: exeID,
}
_ = post[workflow.CancelWorkFlowResponse](r, cancelReq)
}
func (r *wfTestRunner) publish(id string, version string, force bool) {
publishReq := &workflow.PublishWorkflowRequest{
WorkflowID: id,
WorkflowVersion: ptr.Of(version),
VersionDescription: ptr.Of("desc"),
Force: ptr.Of(force),
}
_ = post[workflow.PublishWorkflowResponse](r, publishReq)
}
func (r *wfTestRunner) openapiAsyncRun(id string, input any) string {
runReq := &workflow.OpenAPIRunFlowRequest{
WorkflowID: id,
Parameters: ptr.Of(mustMarshalToString(r.t, input)),
IsAsync: ptr.Of(true),
}
runResp := post[workflow.OpenAPIRunFlowResponse](r, runReq)
return runResp.GetExecuteID()
}
func (r *wfTestRunner) openapiSyncRun(id string, input any) (map[string]any, string) {
runReq := &workflow.OpenAPIRunFlowRequest{
WorkflowID: id,
Parameters: ptr.Of(mustMarshalToString(r.t, input)),
IsAsync: ptr.Of(false),
}
runResp := post[workflow.OpenAPIRunFlowResponse](r, runReq)
output := runResp.GetData()
var m map[string]any
err := sonic.UnmarshalString(output, &m)
assert.NoError(r.t, err)
return m, runResp.GetExecuteID()
}
func (r *wfTestRunner) validateTree(schema string) [][]*workflow.ValidateErrorData {
data, err := os.ReadFile(fmt.Sprintf("../../../domain/workflow/internal/canvas/examples/%s", schema))
if err != nil {
r.t.Fatal(err)
}
res := post[workflow.ValidateTreeResponse](r, &workflow.ValidateTreeRequest{
WorkflowID: "1",
Schema: ptr.Of(string(data)),
BindProjectID: "1",
})
if len(res.Data) == 0 {
return nil
}
var errs [][]*workflow.ValidateErrorData
for _, d := range res.Data {
errs = append(errs, d.Errors)
}
return errs
}
func (r *wfTestRunner) testResume(id string, exeID string, eventID string, input any) {
inputStr, ok := input.(string)
if !ok {
inputStr = mustMarshalToString(r.t, input)
}
testResumeReq := &workflow.WorkflowTestResumeRequest{
WorkflowID: id,
SpaceID: ptr.Of("123"),
ExecuteID: exeID,
EventID: eventID,
Data: inputStr,
}
_ = post[workflow.WorkflowTestResumeResponse](r, testResumeReq)
}
type nodeDebugOptions struct {
input map[string]string
batch map[string]string
setting map[string]string
}
func withNDInput(input map[string]string) func(*nodeDebugOptions) {
return func(options *nodeDebugOptions) {
options.input = input
}
}
func withNDBatch(batch map[string]string) func(*nodeDebugOptions) {
return func(options *nodeDebugOptions) {
options.batch = batch
}
}
func withNDSettings(settings map[string]string) func(*nodeDebugOptions) {
return func(options *nodeDebugOptions) {
options.setting = settings
}
}
func (r *wfTestRunner) nodeDebug(id string, nodeID string, opts ...func(*nodeDebugOptions)) string {
options := &nodeDebugOptions{}
for _, opt := range opts {
opt(options)
}
nodeDebugReq := &workflow.WorkflowNodeDebugV2Request{
WorkflowID: id,
NodeID: nodeID,
}
if options.input != nil {
nodeDebugReq.Input = options.input
}
if options.batch != nil {
nodeDebugReq.Batch = options.batch
}
if options.setting != nil {
nodeDebugReq.Setting = options.setting
}
nodeDebugResp := post[workflow.WorkflowNodeDebugV2Response](r, nodeDebugReq)
return nodeDebugResp.Data.ExecuteID
}
func (r *wfTestRunner) save(id string, schema string) {
data, err := os.ReadFile(fmt.Sprintf("../../../domain/workflow/internal/canvas/examples/%s", schema))
assert.NoError(r.t, err)
saveReq := &workflow.SaveWorkflowRequest{
WorkflowID: id,
Schema: ptr.Of(string(data)),
}
_ = post[workflow.SaveWorkflowResponse](r, saveReq)
}
func getCanvas(ctx context.Context, id string) (string, error) {
response, err := appworkflow.SVC.GetCanvasInfo(ctx, &workflow.GetCanvasInfoRequest{
SpaceID: "123",
WorkflowID: ptr.Of(id),
})
if err != nil {
return "", err
}
return response.GetData().GetWorkflow().GetSchemaJSON(), nil
}
func (r *wfTestRunner) openapiStream(id string, input any) *sse.Reader {
inputStr, _ := sonic.MarshalString(input)
req := &workflow.OpenAPIRunFlowRequest{
WorkflowID: id,
Parameters: ptr.Of(inputStr),
}
m, err := sonic.Marshal(req)
assert.NoError(r.t, err)
c, _ := client.NewClient()
hReq, hResp := protocol.AcquireRequest(), protocol.AcquireResponse()
hReq.SetRequestURI("http://localhost:8888" + "/v1/workflow/stream_run")
hReq.SetMethod("POST")
hReq.SetBody(m)
hReq.SetHeader("Content-Type", "application/json")
err = c.Do(context.Background(), hReq, hResp)
assert.NoError(r.t, err)
if hResp.StatusCode() != http.StatusOK {
r.t.Errorf("unexpected status code: %d, body: %s", hResp.StatusCode(), string(hResp.Body()))
}
re, err := sse.NewReader(hResp)
assert.NoError(r.t, err)
return re
}
func (r *wfTestRunner) openapiResume(id string, eventID string, resumeData string) *sse.Reader {
req := &workflow.OpenAPIStreamResumeFlowRequest{
WorkflowID: id,
EventID: eventID,
ResumeData: resumeData,
ConnectorID: ptr.Of(strconv.FormatInt(consts.APIConnectorID, 10)),
}
m, err := sonic.Marshal(req)
assert.NoError(r.t, err)
c, _ := client.NewClient()
hReq, hResp := protocol.AcquireRequest(), protocol.AcquireResponse()
hReq.SetRequestURI("http://localhost:8888" + "/v1/workflow/stream_resume")
hReq.SetMethod("POST")
hReq.SetBody(m)
hReq.SetHeader("Content-Type", "application/json")
err = c.Do(context.Background(), hReq, hResp)
assert.NoError(r.t, err)
if hResp.StatusCode() != http.StatusOK {
r.t.Errorf("unexpected status code: %d, body: %s", hResp.StatusCode(), string(hResp.Body()))
}
re, err := sse.NewReader(hResp)
assert.NoError(r.t, err)
return re
}
func (r *wfTestRunner) runServer() func() {
go func() {
_ = r.h.Run()
}()
return func() {
_ = r.h.Close()
}
}
func TestNodeTemplateList(t *testing.T) {
mockey.PatchConvey("test node cn template list", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
resp := post[workflow.NodeTemplateListResponse](r, &workflow.NodeTemplateListRequest{
NodeTypes: []string{"3", "5", "18"},
})
assert.Equal(t, 3, len(resp.Data.TemplateList))
assert.Equal(t, 3, len(resp.Data.CateList))
id2Name := map[string]string{
"3": "LLM",
"5": "Code",
"18": "Question",
}
for _, tl := range resp.Data.TemplateList {
assert.Equal(t, tl.Name, id2Name[tl.ID])
}
})
mockey.PatchConvey("test node en template list", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
resp := post[workflow.NodeTemplateListResponse](r, &workflow.NodeTemplateListRequest{
NodeTypes: []string{"3", "5", "18"},
}, WithHeaders(map[string]string{
"x-locale": "en-US",
}))
id2Name := map[string]string{
"3": "LLM",
"5": "Code",
"18": "Question",
}
assert.Equal(t, 3, len(resp.Data.TemplateList))
assert.Equal(t, 3, len(resp.Data.CateList))
for _, tl := range resp.Data.TemplateList {
assert.Equal(t, tl.Name, id2Name[tl.ID])
}
})
}
func TestTestRunAndGetProcess(t *testing.T) {
mockey.PatchConvey("test test_run and get_process", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
r.appVarS.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return("1.0", nil).AnyTimes()
id := r.load("entry_exit.json")
input := map[string]string{
"arr": "[\"arr1\", \"arr2\"]",
"obj": "{\"field1\": [\"1234\", \"5678\"]}",
"input": "3.5",
}
mockey.PatchConvey("test run then immediately cancel", func() {
exeID := r.testRun(id, input)
r.cancel(id, exeID)
e := r.getProcess(id, exeID)
// maybe cancel or success, whichever comes first
assert.Contains(t, []workflow.WorkflowExeStatus{workflow.WorkflowExeStatus_Cancel, workflow.WorkflowExeStatus_Success}, e.status)
})
mockey.PatchConvey("test run success, then cancel", func() {
exeID := r.testRun(id, input)
r.getProcess(id, exeID)
// cancel after success, nothing happens
r.cancel(id, exeID)
his := r.getOpenAPIProcess(id, exeID)
assert.Equal(t, exeID, fmt.Sprintf("%d", *his.Data[0].ExecuteID))
assert.Equal(t, workflow.WorkflowRunMode_Async, *his.Data[0].RunMode)
r.publish(id, "v0.0.1", true)
mockey.PatchConvey("openapi async run", func() {
exeID := r.openapiAsyncRun(id, input)
e := r.getProcess(id, exeID)
assert.Equal(t, "1.0_[\"1234\",\"5678\"]", e.output)
})
mockey.PatchConvey("openapi sync run", func() {
output, exeID := r.openapiSyncRun(id, input)
assert.Equal(t, "1.0_[\"1234\",\"5678\"]", output["data"])
his := r.getOpenAPIProcess(id, exeID)
assert.Equal(t, exeID, fmt.Sprintf("%d", *his.Data[0].ExecuteID))
assert.Equal(t, workflow.WorkflowRunMode_Sync, *his.Data[0].RunMode)
})
})
})
}
func TestValidateTree(t *testing.T) {
mockey.PatchConvey("test validate tree", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
Required: true,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
t.Run("workflow_has_loop", func(t *testing.T) {
errs := r.validateTree("validate/workflow_has_loop.json")
paths := map[string]string{
"161668": "101917",
"101917": "177387",
"177387": "161668",
"166209": "102541",
"102541": "109507",
"109507": "166209",
}
for _, i := range errs[0] {
assert.Equal(t, paths[i.PathError.Start], i.PathError.End)
}
})
t.Run("workflow_has_no_connected_nodes", func(t *testing.T) {
errs := r.validateTree("validate/workflow_has_no_connected_nodes.json")
for _, i := range errs[0] {
if i.NodeError != nil {
if i.NodeError.NodeID == "108984" {
assert.Equal(t, i.Message, `node "代码_1" not connected`)
}
if i.NodeError.NodeID == "160892" {
assert.Contains(t, i.Message, `node "意图识别"'s port "branch_1" not connected`)
assert.Contains(t, i.Message, `node "意图识别"'s port "default" not connected`)
}
}
}
})
t.Run("workflow_ref_variable", func(t *testing.T) {
errs := r.validateTree("validate/workflow_ref_variable.json")
for _, i := range errs[0] {
if i.NodeError != nil {
if i.NodeError.NodeID == "118685" {
assert.Equal(t, i.Message, `the node id "118685" on which node id "165568" depends does not exist`)
}
if i.NodeError.NodeID == "128176" {
assert.Equal(t, i.Message, `the node id "128176" on which node id "11384000" depends does not exist`)
}
}
}
})
t.Run("workflow_nested_has_loop_or_batch", func(t *testing.T) {
errs := r.validateTree("validate/workflow_nested_has_loop_or_batch.json")
assert.Equal(t, errs[0][0].Message, `composite nodes such as batch/loop cannot be nested`)
})
t.Run("workflow_variable_assigner", func(t *testing.T) {
errs := r.validateTree("validate/workflow_variable_assigner.json")
assert.Equal(t, errs[0][0].Message, `node name 变量赋值,param [app_list_v2], type mismatch`)
})
t.Run("sub_workflow_terminate_plan_type", func(t *testing.T) {
_ = r.load("validate/workflow_has_no_connected_nodes.json", withID(7498321598097768457))
errs := r.validateTree("validate/sub_workflow_terminate_plan_type.json")
require.Equal(t, 2, len(errs))
assert.Equal(t, errs[0][0].Message, `node name 变量赋值,param [app_list_v2], type mismatch`)
for _, i := range errs[1] {
if i.NodeError != nil {
if i.NodeError.NodeID == "108984" {
assert.Equal(t, i.Message, `node "代码_1" not connected`)
}
if i.NodeError.NodeID == "160892" {
assert.Contains(t, i.Message, `node "意图识别"'s port "branch_1" not connected`)
assert.Contains(t, i.Message, `node "意图识别"'s port "default" not connected`)
}
}
}
})
t.Run("invalid_input_parameter", func(t *testing.T) {
errs := r.validateTree("validate/invalid_input_parameter.json")
assert.Equal(t, len(errs[0]), 2)
msgs := slices.Transform(errs[0], func(item *workflow.ValidateErrorData) string {
return item.Message
})
assert.Contains(t, msgs, `parameter name only allows number or alphabet, and must begin with alphabet, but it's "123"`)
assert.Contains(t, msgs, `ref block error,[blockID] is empty`)
})
})
}
func TestTestResumeWithInputNode(t *testing.T) {
mockey.PatchConvey("test test_resume with input node", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("input_receiver.json")
userInput := map[string]any{
"input": "user input",
"obj": map[string]any{
"field1": []any{"1", "2"},
},
}
userInputStr, err := sonic.MarshalString(userInput)
assert.NoError(t, err)
mockey.PatchConvey("cancel after interrupt", func() {
exeID := r.testRun(id, map[string]string{
"input": "unused initial input",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event) // interrupted
r.cancel(id, exeID)
e = r.getProcess(id, exeID)
assert.Equal(t, workflow.WorkflowExeStatus_Cancel, e.status)
})
mockey.PatchConvey("cancel immediately after resume", func() {
exeID := r.testRun(id, map[string]string{
"input": "unused initial input",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event) // interrupted
r.testResume(id, exeID, e.event.ID, userInputStr)
r.cancel(id, exeID)
e = r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
// maybe cancel or success, whichever comes first
if e.status != workflow.WorkflowExeStatus_Success &&
e.status != workflow.WorkflowExeStatus_Cancel {
t.Errorf("expected to be either success or cancel, got: %v", e.status)
}
})
mockey.PatchConvey("test run, then test resume", func() {
exeID := r.testRun(id, map[string]string{
"input": "unused initial input",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event) // interrupted
r.testResume(id, exeID, e.event.ID, userInputStr)
e = r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
assert.Equal(t, workflow.WorkflowExeStatus_Success, e.status)
assert.Equal(t, map[string]any{
"input": "user input",
"inputArr": nil,
"field1": `["1","2"]`,
}, mustUnmarshalToMap(t, e.output))
})
mockey.PatchConvey("node debug the input node", func() {
exeID := r.nodeDebug(id, "154951")
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event) // interrupted
r.testResume(id, exeID, e.event.ID, userInputStr)
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
e2.assertSuccess()
assert.Equal(t, map[string]any{
"input": "user input",
"inputArr": nil,
"obj": map[string]any{
"field1": `["1","2"]`,
},
}, mustUnmarshalToMap(t, e2.output))
result := r.getNodeExeHistory(id, exeID, "154951", nil)
assert.Equal(t, mustUnmarshalToMap(t, e2.output), mustUnmarshalToMap(t, result.Output))
})
mockey.PatchConvey("sync run does not support interrupt", func() {
r.publish(id, "v1.0.0", true)
syncRunReq := &workflow.OpenAPIRunFlowRequest{
WorkflowID: id,
Parameters: ptr.Of(mustMarshalToString(t, map[string]string{
"input": "unused initial input",
})),
IsAsync: ptr.Of(false),
}
resp := post[workflow.OpenAPIRunFlowResponse](r, syncRunReq)
assert.Equal(t, int64(errno.ErrOpenAPIInterruptNotSupported), resp.Code)
})
})
}
func TestQueryTypes(t *testing.T) {
mockey.PatchConvey("test workflow node types", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
t.Run("not sub workflow", func(t *testing.T) {
id := r.load("query_types/llm_intent_http_nodes.json")
req := &workflow.QueryWorkflowNodeTypeRequest{
WorkflowID: id,
}
response := post[workflow.QueryWorkflowNodeTypeResponse](r, req)
assert.Contains(t, response.Data.NodeTypes, "1")
assert.Contains(t, response.Data.NodeTypes, "2")
assert.Contains(t, response.Data.NodeTypes, "5")
assert.Contains(t, response.Data.NodeTypes, "22")
assert.Contains(t, response.Data.NodeTypes, "45")
for _, prop := range response.Data.NodesProperties {
if prop.ID == "100001" {
assert.False(t, prop.IsEnableChatHistory)
assert.False(t, prop.IsEnableUserQuery)
assert.False(t, prop.IsRefGlobalVariable)
}
if prop.ID == "900001" || prop.ID == "117367" || prop.ID == "133234" || prop.ID == "163493" {
assert.False(t, prop.IsEnableChatHistory)
assert.False(t, prop.IsEnableUserQuery)
assert.True(t, prop.IsRefGlobalVariable)
}
}
})
t.Run("loop conditions", func(t *testing.T) {
id := r.load("query_types/loop_condition.json")
req := &workflow.QueryWorkflowNodeTypeRequest{
WorkflowID: id,
}
response := post[workflow.QueryWorkflowNodeTypeResponse](r, req)
assert.Contains(t, response.Data.NodeTypes, "1")
assert.Contains(t, response.Data.NodeTypes, "2")
assert.Contains(t, response.Data.NodeTypes, "21")
assert.Contains(t, response.Data.NodeTypes, "5")
assert.Contains(t, response.Data.NodeTypes, "8")
for _, prop := range response.Data.NodesProperties {
if prop.ID == "100001" || prop.ID == "900001" || prop.ID == "114884" || prop.ID == "143932" {
assert.False(t, prop.IsEnableChatHistory)
assert.False(t, prop.IsEnableUserQuery)
assert.False(t, prop.IsRefGlobalVariable)
}
if prop.ID == "119585" || prop.ID == "170824" {
assert.False(t, prop.IsEnableChatHistory)
assert.False(t, prop.IsEnableUserQuery)
assert.True(t, prop.IsRefGlobalVariable)
}
}
})
t.Run("has sub workflow", func(t *testing.T) {
_ = r.load("query_types/wf2.json", withID(7498668117704163337), withPublish("v0.0.1"))
_ = r.load("query_types/wf2child.json", withID(7498674832255615002), withPublish("v0.0.1"))
id := r.load("query_types/subworkflows.json")
req := &workflow.QueryWorkflowNodeTypeRequest{
WorkflowID: id,
}
response := post[workflow.QueryWorkflowNodeTypeResponse](r, req)
assert.Contains(t, response.Data.NodeTypes, "1")
assert.Contains(t, response.Data.NodeTypes, "2")
assert.Contains(t, response.Data.NodeTypes, "9")
assert.Contains(t, response.Data.SubWorkflowNodeTypes, "5")
assert.Contains(t, response.Data.SubWorkflowNodeTypes, "1")
assert.Contains(t, response.Data.SubWorkflowNodeTypes, "2")
for _, prop := range response.Data.NodesProperties {
if prop.ID == "143310" {
assert.True(t, prop.IsRefGlobalVariable)
}
}
for _, prop := range response.Data.SubWorkflowNodesProperties {
if prop.ID == "116972" {
assert.True(t, prop.IsRefGlobalVariable)
}
if prop.ID == "124342" {
assert.False(t, prop.IsRefGlobalVariable)
}
}
})
})
}
func TestResumeWithQANode(t *testing.T) {
mockey.PatchConvey("test_resume with qa node", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
chatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"question": "what's your age?"}`,
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 6,
CompletionTokens: 7,
TotalTokens: 13,
},
},
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"fields": {"name": "eino", "age": 1}}`,
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 11,
CompletionTokens: 19,
TotalTokens: 30,
},
},
}, nil
}
return nil, errors.New("not found")
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
id := r.load("qa_with_structured_output.json")
exeID := r.testRun(id, map[string]string{
"input": "what's your name and age?",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event)
e.tokenEqual(0, 0)
r.testResume(id, exeID, e.event.ID, "my name is eino")
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
r.testResume(id, exeID, e2.event.ID, "1 year old")
e3 := r.getProcess(id, exeID, withPreviousEventID(e2.event.ID))
e3.assertSuccess()
assert.Equal(t, map[string]any{
"USER_RESPONSE": "1 year old",
"name": "eino",
"age": int64(1),
}, mustUnmarshalToMap(t, e3.output))
e3.tokenEqual(17, 26)
})
}
func TestNestedSubWorkflowWithInterrupt(t *testing.T) {
mockey.PatchConvey("test nested sub workflow with interrupt", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
chatModel1 := &testutil.UTChatModel{
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
sr := schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 1,
CompletionTokens: 3,
TotalTokens: 4,
},
},
},
{
Role: schema.Assistant,
Content: "don't know.",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 7,
TotalTokens: 7,
},
},
},
})
return sr, nil
},
}
chatModel2 := &testutil.UTChatModel{
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
sr := schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 2,
CompletionTokens: 2,
TotalTokens: 4,
},
},
},
{
Role: schema.Assistant,
Content: "don't know too.",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 11,
TotalTokens: 11,
},
},
},
})
return sr, nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1737521813 {
return chatModel1, nil, nil
} else {
return chatModel2, nil, nil
}
}).AnyTimes()
topID := r.load("subworkflow/top_workflow.json")
defer func() {
post[workflow.DeleteWorkflowResponse](r, &workflow.DeleteWorkflowRequest{
WorkflowID: topID,
})
}()
midID := r.load("subworkflow/middle_workflow.json", withID(7494849202016272435))
bottomID := r.load("subworkflow/bottom_workflow.json", withID(7468899413567684634))
inputID := r.load("input_receiver.json", withID(7469607842648457243))
exeID := r.testRun(topID, map[string]string{
"input": "hello",
})
e := r.getProcess(topID, exeID)
assert.NotNil(t, e.event)
r.testResume(topID, exeID, e.event.ID, map[string]any{
"input": "more info 1",
})
e2 := r.getProcess(topID, exeID, withPreviousEventID(e.event.ID))
assert.NotNil(t, e2.event)
r.testResume(topID, exeID, e2.event.ID, map[string]any{
"input": "more info 2",
})
e3 := r.getProcess(topID, exeID, withPreviousEventID(e2.event.ID))
e3.assertSuccess()
assert.Equal(t, "I don't know.\nI don't know too.\nb\n[\"new_a_more info 1\",\"new_b_more info 2\"]", e3.output)
e3.tokenEqual(3, 23)
r.publish(topID, "v0.0.1", true) // publish the top workflow to
refs := post[workflow.GetWorkflowReferencesResponse](r, &workflow.GetWorkflowReferencesRequest{
WorkflowID: midID,
})
assert.Equal(t, 1, len(refs.Data.WorkflowList))
assert.Equal(t, topID, refs.Data.WorkflowList[0].WorkflowID)
mockey.PatchConvey("verify history schema for all workflows", func() {
// get current draft commit ID of top_workflow
canvas := post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(topID),
})
topCommitID := canvas.Data.VcsData.DraftCommitID
// get history schema of top_workflow
resp := post[workflow.GetHistorySchemaResponse](r, &workflow.GetHistorySchemaRequest{
WorkflowID: topID,
ExecuteID: ptr.Of(exeID),
})
assert.Equal(t, topCommitID, resp.Data.CommitID)
// get sub_executeID for middle_workflow
nodeHis := r.getNodeExeHistory(topID, exeID, "198743", nil)
extra := mustUnmarshalToMap(t, nodeHis.GetExtra())
midExeID := extra["subExecuteID"].(int64)
// do the same for middle_workflow
canvas = post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(midID),
})
resp = post[workflow.GetHistorySchemaResponse](r, &workflow.GetHistorySchemaRequest{
WorkflowID: midID,
ExecuteID: ptr.Of(exeID),
SubExecuteID: ptr.Of(strconv.FormatInt(midExeID, 10)),
})
assert.Equal(t, canvas.Data.VcsData.DraftCommitID, resp.Data.CommitID)
nodeHis = r.getNodeExeHistory(midID, strconv.FormatInt(midExeID, 10), "112956", nil)
extra = mustUnmarshalToMap(t, nodeHis.GetExtra())
bottomExeID := extra["subExecuteID"].(int64)
// do the same for bottom_workflow
canvas = post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(bottomID),
})
resp = post[workflow.GetHistorySchemaResponse](r, &workflow.GetHistorySchemaRequest{
WorkflowID: bottomID,
ExecuteID: ptr.Of(exeID),
SubExecuteID: ptr.Of(strconv.FormatInt(bottomExeID, 10)),
})
assert.Equal(t, canvas.Data.VcsData.DraftCommitID, resp.Data.CommitID)
nodeHis = r.getNodeExeHistory(bottomID, strconv.FormatInt(bottomExeID, 10), "141303", nil)
extra = mustUnmarshalToMap(t, nodeHis.GetExtra())
inputExeID := extra["subExecuteID"].(int64)
// do the same for input_receiver workflow
canvas = post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(inputID),
})
resp = post[workflow.GetHistorySchemaResponse](r, &workflow.GetHistorySchemaRequest{
WorkflowID: inputID,
ExecuteID: ptr.Of(exeID),
SubExecuteID: ptr.Of(strconv.FormatInt(inputExeID, 10)),
})
assert.Equal(t, canvas.Data.VcsData.DraftCommitID, resp.Data.CommitID)
// update the top_workflow's draft, we still can get it's history schema
r.save(topID, "subworkflow/middle_workflow.json")
resp = post[workflow.GetHistorySchemaResponse](r, &workflow.GetHistorySchemaRequest{
WorkflowID: topID,
ExecuteID: ptr.Of(exeID),
})
assert.Equal(t, topCommitID, resp.Data.CommitID)
r.publish(topID, "v0.0.2", true)
refs := post[workflow.GetWorkflowReferencesResponse](r, &workflow.GetWorkflowReferencesRequest{
WorkflowID: midID,
})
assert.Equal(t, 0, len(refs.Data.WorkflowList))
})
})
}
func TestInterruptWithinBatch(t *testing.T) {
mockey.PatchConvey("test interrupt within batch", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("batch/batch_with_inner_interrupt.json")
exeID := r.testRun(id, map[string]string{
"input_array": `["a","b"]`,
"batch_concurrency": "2",
})
e := r.getProcess(id, exeID)
assert.Equal(t, workflow.EventType_InputNode, e.event.Type)
exeIDInt, _ := strconv.ParseInt(exeID, 0, 64)
storeIEs, _ := workflow2.GetRepository().ListInterruptEvents(t.Context(), exeIDInt)
assert.Equal(t, 2, len(storeIEs))
r.testResume(id, exeID, e.event.ID, map[string]any{
"input": "input 1",
})
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
assert.Equal(t, workflow.EventType_InputNode, e2.event.Type)
storeIEs, _ = workflow2.GetRepository().ListInterruptEvents(t.Context(), exeIDInt)
assert.Equal(t, 2, len(storeIEs))
r.testResume(id, exeID, e2.event.ID, map[string]any{
"input": "input 2",
})
e3 := r.getProcess(id, exeID, withPreviousEventID(e2.event.ID))
assert.Equal(t, workflow.EventType_Question, e3.event.Type)
storeIEs, _ = workflow2.GetRepository().ListInterruptEvents(t.Context(), exeIDInt)
assert.Equal(t, 2, len(storeIEs))
r.testResume(id, exeID, e3.event.ID, "answer 1")
e4 := r.getProcess(id, exeID, withPreviousEventID(e3.event.ID))
assert.Equal(t, workflow.EventType_Question, e4.event.Type)
storeIEs, _ = workflow2.GetRepository().ListInterruptEvents(t.Context(), exeIDInt)
assert.Equal(t, 1, len(storeIEs))
r.testResume(id, exeID, e4.event.ID, "answer 2")
e5 := r.getProcess(id, exeID, withPreviousEventID(e4.event.ID))
storeIEs, _ = workflow2.GetRepository().ListInterruptEvents(t.Context(), exeIDInt)
assert.Equal(t, 0, len(storeIEs))
e5.assertSuccess()
outputMap := mustUnmarshalToMap(t, e5.output)
if !reflect.DeepEqual(outputMap, map[string]any{
"output": []any{"answer 1", "answer 2"},
}) && !reflect.DeepEqual(outputMap, map[string]any{
"output": []any{"answer 2", "answer 1"},
}) {
t.Errorf("output map not equal: %v", outputMap)
}
})
}
func TestPublishWorkflow(t *testing.T) {
mockey.PatchConvey("publish work flow", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("publish/publish_workflow.json", withName("pb_we"))
listResponse := post[workflow.GetWorkFlowListResponse](r, &workflow.GetWorkFlowListRequest{
Page: ptr.Of(int32(1)),
Size: ptr.Of(int32(10)),
Type: ptr.Of(workflow.WorkFlowType_User),
Status: ptr.Of(workflow.WorkFlowListStatus_UnPublished),
Name: ptr.Of("pb_we"),
})
assert.Equal(t, 1, len(listResponse.Data.WorkflowList))
r.publish(id, "v0.0.1", true)
listResponse = post[workflow.GetWorkFlowListResponse](r, &workflow.GetWorkFlowListRequest{
Page: ptr.Of(int32(1)),
Size: ptr.Of(int32(10)),
Type: ptr.Of(workflow.WorkFlowType_User),
Status: ptr.Of(workflow.WorkFlowListStatus_HadPublished),
Name: ptr.Of("pb_we"),
})
assert.Equal(t, 1, len(listResponse.Data.WorkflowList))
r.publish(id, "v0.0.2", true)
deleteReq := &workflow.DeleteWorkflowRequest{
WorkflowID: id,
}
_ = post[workflow.DeleteWorkflowResponse](r, deleteReq)
})
}
func TestGetCanvasInfo(t *testing.T) {
mockey.PatchConvey("test get canvas info", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("get_canvas/get_canvas.json")
getCanvas := &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(id),
}
response := post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Status, workflow.WorkFlowDevStatus_CanNotSubmit)
assert.Equal(t, response.Data.VcsData.Type, workflow.VCSCanvasType_Draft)
exeID := r.testRun(id, map[string]string{
"input": "input_v1",
"e": "e",
})
r.getProcess(id, exeID)
response = post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Status, workflow.WorkFlowDevStatus_CanSubmit)
assert.Equal(t, response.Data.VcsData.Type, workflow.VCSCanvasType_Draft)
r.publish(id, "v0.0.1", true)
response = post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Status, workflow.WorkFlowDevStatus_HadSubmit)
assert.Equal(t, response.Data.VcsData.Type, workflow.VCSCanvasType_Publish)
r.save(id, "get_canvas/get_canvas.json")
response = post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Status, workflow.WorkFlowDevStatus_CanSubmit)
assert.Equal(t, response.Data.VcsData.Type, workflow.VCSCanvasType_Draft)
r.save(id, "get_canvas/get_canvas_modify.json")
response = post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Status, workflow.WorkFlowDevStatus_CanNotSubmit)
assert.Equal(t, response.Data.VcsData.Type, workflow.VCSCanvasType_Draft)
})
}
func TestUpdateWorkflowMeta(t *testing.T) {
mockey.PatchConvey("update workflow meta", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("entry_exit.json")
updateMetaReq := &workflow.UpdateWorkflowMetaRequest{
WorkflowID: id,
Name: ptr.Of("modify_name"),
Desc: ptr.Of("modify_desc"),
IconURI: ptr.Of("modify_icon_uri"),
}
_ = post[workflow.UpdateWorkflowMetaResponse](r, updateMetaReq)
getCanvas := &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(id),
}
response := post[workflow.GetCanvasInfoResponse](r, getCanvas)
assert.Equal(t, response.Data.Workflow.Name, "modify_name")
assert.Equal(t, response.Data.Workflow.Desc, "modify_desc")
assert.Equal(t, response.Data.Workflow.IconURI, "modify_icon_uri")
})
}
func TestSimpleInvokableToolWithReturnVariables(t *testing.T) {
mockey.PatchConvey("simple invokable tool with return variables", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
toolID := r.load("function_call/tool_workflow_1.json", withID(7492075279843737651), withPublish("v0.0.1"))
chatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: "ts_test_wf_test_wf",
Arguments: "{}",
},
},
},
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 11,
TotalTokens: 21,
},
},
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: "final_answer",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
id := r.load("function_call/llm_with_workflow_as_tool.json")
defer func() {
post[workflow.DeleteWorkflowResponse](r, &workflow.DeleteWorkflowRequest{
WorkflowID: id,
})
}()
exeID := r.testRun(id, map[string]string{
"input": "this is the user input",
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"output": "final_answer",
}, mustUnmarshalToMap(t, e.output))
e.tokenEqual(15, 17)
mockey.PatchConvey("check behavior if stream run", func() {
chatModel.Reset()
defer r.runServer()()
r.publish(id, "v0.0.1", true)
sseReader := r.openapiStream(id, map[string]any{
"input": "hello",
})
err := sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
return nil
})
assert.NoError(t, err)
// check workflow references are correct
refs := post[workflow.GetWorkflowReferencesResponse](r, &workflow.GetWorkflowReferencesRequest{
WorkflowID: toolID,
})
assert.Equal(t, 1, len(refs.Data.WorkflowList))
assert.Equal(t, id, refs.Data.WorkflowList[0].WorkflowID)
})
})
}
func TestReturnDirectlyStreamableTool(t *testing.T) {
mockey.PatchConvey("return directly streamable tool", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
outerModel := &testutil.UTChatModel{
StreamResultProvider: func(index int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
if index == 0 {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: "ts_test_wf_test_wf",
Arguments: `{"input": "input for inner model"}`,
},
},
},
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 11,
TotalTokens: 21,
},
},
},
}), nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
innerModel := &testutil.UTChatModel{
StreamResultProvider: func(index int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
if index == 0 {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
},
{
Role: schema.Assistant,
Content: "don't know",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 8,
TotalTokens: 8,
},
},
},
{
Role: schema.Assistant,
Content: ".",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 2,
TotalTokens: 2,
},
},
},
}), nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1706077826 {
innerModel.ModelType = strconv.FormatInt(params.ModelType, 10)
return innerModel, nil, nil
} else {
outerModel.ModelType = strconv.FormatInt(params.ModelType, 10)
return outerModel, nil, nil
}
}).AnyTimes()
r.load("function_call/tool_workflow_2.json", withID(7492615435881709608), withPublish("v0.0.1"))
id := r.load("function_call/llm_workflow_stream_tool.json")
exeID := r.testRun(id, map[string]string{
"input": "this is the user input",
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, "this is the streaming output I don't know.", e.output)
e.tokenEqual(15, 27)
mockey.PatchConvey("check behavior if stream run", func() {
outerModel.Reset()
innerModel.Reset()
defer r.runServer()()
r.publish(id, "v0.0.1", true)
sseReader := r.openapiStream(id, map[string]any{
"input": "hello",
})
err := sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
return nil
})
assert.NoError(t, err)
})
})
}
func TestSimpleInterruptibleTool(t *testing.T) {
mockey.PatchConvey("test simple interruptible tool", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
r.load("input_receiver.json", withID(7492075279843737652), withPublish("v0.0.1"))
chatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
t.Logf("[TestSimpleInterruptibleTool] enter chatmodel index= 0")
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: "ts_test_wf_test_wf",
Arguments: "{}",
},
},
},
}, nil
} else if index == 1 {
t.Logf("[TestSimpleInterruptibleTool] enter chatmodel index= 1")
return &schema.Message{
Role: schema.Assistant,
Content: "final_answer",
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
id := r.load("function_call/llm_with_workflow_as_tool_1.json")
exeID := r.testRun(id, map[string]string{
"input": "this is the user input",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event)
r.testResume(id, exeID, e.event.ID, map[string]any{
"input": "user input",
"obj": map[string]any{
"field1": []string{"1", "2"},
},
})
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
e2.assertSuccess()
assert.Equal(t, map[string]any{
"output": "final_answer",
}, mustUnmarshalToMap(t, e2.output))
})
}
func TestStreamableToolWithMultipleInterrupts(t *testing.T) {
mockey.PatchConvey("return directly streamable tool with multiple interrupts", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
outerModel := &testutil.UTChatModel{
StreamResultProvider: func(index int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
if index == 0 {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: "ts_test_wf_test_wf",
Arguments: `{"input": "what's your name and age"}`,
},
},
},
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 6,
CompletionTokens: 7,
TotalTokens: 13,
},
},
},
}), nil
} else if index == 1 {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I now know your ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 8,
TotalTokens: 13,
},
},
},
{
Role: schema.Assistant,
Content: "name is Eino and age is 1.",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 10,
TotalTokens: 17,
},
},
},
}), nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
innerModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"question": "what's your age?"}`,
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 6,
CompletionTokens: 7,
TotalTokens: 13,
},
},
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"fields": {"name": "eino", "age": 1}}`,
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 8,
CompletionTokens: 10,
TotalTokens: 18,
},
},
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1706077827 {
outerModel.ModelType = strconv.FormatInt(params.ModelType, 10)
return outerModel, nil, nil
} else {
innerModel.ModelType = strconv.FormatInt(params.ModelType, 10)
return innerModel, nil, nil
}
}).AnyTimes()
r.load("function_call/tool_workflow_3.json", withID(7492615435881709611), withPublish("v0.0.1"))
id := r.load("function_call/llm_workflow_stream_tool_1.json")
exeID := r.testRun(id, map[string]string{
"input": "this is the user input",
})
e := r.getProcess(id, exeID)
assert.NotNil(t, e.event)
e.tokenEqual(0, 0)
r.testResume(id, exeID, e.event.ID, "my name is eino")
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
assert.NotNil(t, e2.event)
e2.tokenEqual(0, 0)
r.testResume(id, exeID, e2.event.ID, "1 year old")
e3 := r.getProcess(id, exeID, withPreviousEventID(e2.event.ID))
e3.assertSuccess()
assert.Equal(t, "the name is eino, age is 1", e3.output)
e3.tokenEqual(20, 24)
})
}
func TestNodeWithBatchEnabled(t *testing.T) {
mockey.PatchConvey("test node with batch enabled", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
r.load("batch/sub_workflow_as_batch.json", withID(7469707607914217512), withPublish("v0.0.1"))
chatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: "answer。for index 0",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: "answer,for index 1",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
id := r.load("batch/node_batches.json")
exeID := r.testRun(id, map[string]string{
"input": `["first input", "second input"]`,
})
e := r.getProcess(id, exeID)
e.assertSuccess()
outputMap := mustUnmarshalToMap(t, e.output)
assert.Contains(t, outputMap["output"], map[string]any{
"output": []any{
"answer",
"for index 0",
},
"input": "answer。for index 0",
})
assert.Contains(t, outputMap["output"], map[string]any{
"output": []any{
"answer",
"for index 1",
},
"input": "answer,for index 1",
})
assert.Equal(t, 2, len(outputMap["output"].([]any)))
e.tokenEqual(10, 12)
// verify this workflow has previously succeeded a test run
result := r.getNodeExeHistory(id, "", "100001", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.True(t, len(result.Output) > 0)
// verify querying this node's result for a particular test run
result = r.getNodeExeHistory(id, exeID, "178876", nil)
assert.True(t, len(result.Output) > 0)
mockey.PatchConvey("test node debug with batch mode", func() {
exeID = r.nodeDebug(id, "178876", withNDBatch(map[string]string{"item1": `[{"output":"output_1"},{"output":"output_2"}]`}))
e = r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"outputList": []any{
map[string]any{
"input": "output_1",
"output": []any{"output_1"},
},
map[string]any{
"input": "output_2",
"output": []any{"output_2"},
},
},
}, mustUnmarshalToMap(t, e.output))
// verify querying this node's result for this node debug run
result := r.getNodeExeHistory(id, exeID, "178876", nil)
assert.Equal(t, mustUnmarshalToMap(t, e.output), mustUnmarshalToMap(t, result.Output))
// verify querying this node's has succeeded any node debug run
result = r.getNodeExeHistory(id, "", "178876", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.Equal(t, mustUnmarshalToMap(t, e.output), mustUnmarshalToMap(t, result.Output))
})
})
}
func TestStartNodeDefaultValues(t *testing.T) {
mockey.PatchConvey("default values", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
t.Run("no input keys, all fields use default values", func(t *testing.T) {
idStr := r.load("start_node_default_values.json")
r.publish(idStr, "v0.0.1", true)
input := map[string]string{}
result, _ := r.openapiSyncRun(idStr, input)
assert.Equal(t, result, map[string]any{
"ts": "2025-07-09 21:43:34",
"files": "http://imagex.fanlv.fun/tos-cn-i-1heqlfnr21/e81acc11277f421390770618e24e01ce.jpeg~tplv-1heqlfnr21-image.image?x-wf-file_name=20250317-154742.jpeg",
"str": "str",
"object": map[string]any{
"a": "1",
},
"array": []any{"1", "2"},
"inter": int64(100),
"number": 12.4,
"bool": false,
})
})
t.Run("all fields use default values", func(t *testing.T) {
idStr := r.load("start_node_default_values.json")
r.publish(idStr, "v0.0.1", true)
input := map[string]string{
"str": "",
"array": "[]",
"object": "{}",
}
result, _ := r.openapiSyncRun(idStr, input)
assert.Equal(t, result, map[string]any{
"ts": "2025-07-09 21:43:34",
"files": "http://imagex.fanlv.fun/tos-cn-i-1heqlfnr21/e81acc11277f421390770618e24e01ce.jpeg~tplv-1heqlfnr21-image.image?x-wf-file_name=20250317-154742.jpeg",
"str": "str",
"object": map[string]any{
"a": "1",
},
"array": []any{"1", "2"},
"inter": int64(100),
"number": 12.4,
"bool": false,
})
})
t.Run("some use default values and some use user-entered values", func(t *testing.T) {
idStr := r.load("start_node_default_values.json")
r.publish(idStr, "v0.0.1", true)
input := map[string]string{
"str": "value",
"array": `["a","b"]`,
"object": "{}",
"bool": "true",
}
result, _ := r.openapiSyncRun(idStr, input)
assert.Equal(t, result, map[string]any{
"ts": "2025-07-09 21:43:34",
"files": "http://imagex.fanlv.fun/tos-cn-i-1heqlfnr21/e81acc11277f421390770618e24e01ce.jpeg~tplv-1heqlfnr21-image.image?x-wf-file_name=20250317-154742.jpeg",
"str": "value",
"object": map[string]any{
"a": "1",
},
"array": []any{"a", "b"},
"inter": int64(100),
"number": 12.4,
"bool": true,
})
})
})
}
func TestAggregateStreamVariables(t *testing.T) {
mockey.PatchConvey("test aggregate stream variables", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
cm1 := &testutil.UTChatModel{
StreamResultProvider: func(index int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
},
{
Role: schema.Assistant,
Content: "won't tell",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 8,
TotalTokens: 8,
},
},
},
{
Role: schema.Assistant,
Content: " you.",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 2,
TotalTokens: 2,
},
},
},
}), nil
},
}
cm2 := &testutil.UTChatModel{
StreamResultProvider: func(index int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
return schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 5,
CompletionTokens: 6,
TotalTokens: 11,
},
},
},
{
Role: schema.Assistant,
Content: "don't know",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 8,
TotalTokens: 8,
},
},
},
{
Role: schema.Assistant,
Content: ".",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
CompletionTokens: 2,
TotalTokens: 2,
},
},
},
}), nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1737521813 {
cm1.ModelType = strconv.FormatInt(params.ModelType, 10)
return cm1, nil, nil
} else {
cm2.ModelType = strconv.FormatInt(params.ModelType, 10)
return cm2, nil, nil
}
}).AnyTimes()
id := r.load("variable_aggregate/aggregate_streams.json", withPublish("v0.0.1"))
exeID := r.testRun(id, map[string]string{
"input": "I've got an important question",
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, "I won't tell you.\nI won't tell you.\n{\"Group1\":\"I won't tell you.\",\"input\":\"I've got an important question\"}", e.output)
defer r.runServer()()
sseReader := r.openapiStream(id, map[string]any{
"input": "I've got an important question",
})
err := sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
return nil
})
assert.NoError(t, err)
})
}
func TestListWorkflowAsToolData(t *testing.T) {
mockey.PatchConvey("publish list workflow & list workflow as tool data", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
name := "pb_wf" + strconv.FormatInt(time.Now().UnixMilli(), 10)
id := r.load("publish/publish_workflow.json", withName(name))
listResponse := post[workflow.GetWorkFlowListResponse](r, &workflow.GetWorkFlowListRequest{
Page: ptr.Of(int32(1)),
Size: ptr.Of(int32(10)),
Type: ptr.Of(workflow.WorkFlowType_User),
Status: ptr.Of(workflow.WorkFlowListStatus_UnPublished),
Name: ptr.Of(name),
})
assert.Equal(t, 1, len(listResponse.Data.WorkflowList))
r.publish(id, "v0.0.1", true)
res, err := appworkflow.SVC.GetPlaygroundPluginList(t.Context(), &pluginAPI.GetPlaygroundPluginListRequest{
PluginIds: []string{id},
SpaceID: ptr.Of(int64(123)),
})
assert.NoError(t, err)
assert.Equal(t, 1, len(res.Data.PluginList))
assert.Equal(t, "v0.0.1", res.Data.PluginList[0].VersionName)
assert.Equal(t, "input", res.Data.PluginList[0].PluginApis[0].Parameters[0].Name)
assert.Equal(t, "obj", res.Data.PluginList[0].PluginApis[0].Parameters[1].Name)
assert.Equal(t, "field1", res.Data.PluginList[0].PluginApis[0].Parameters[1].SubParameters[0].Name)
assert.Equal(t, "arr", res.Data.PluginList[0].PluginApis[0].Parameters[2].Name)
assert.Equal(t, "string", res.Data.PluginList[0].PluginApis[0].Parameters[2].SubType)
deleteReq := &workflow.DeleteWorkflowRequest{
WorkflowID: id,
}
_ = post[workflow.DeleteWorkflowResponse](r, deleteReq)
})
}
func TestWorkflowDetailAndDetailInfo(t *testing.T) {
mockey.PatchConvey("workflow detail & detail info", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
name := "pb_wf" + strconv.FormatInt(time.Now().UnixMilli(), 10)
id := r.load("publish/publish_workflow.json", withName(name))
detailReq := &workflow.GetWorkflowDetailRequest{
WorkflowIds: []string{id},
}
response := post[map[string]any](r, detailReq)
assert.Equal(t, 1, len((*response)["data"].([]any)))
r.publish(id, "v0.0.1", true)
r.publish(id, "v0.0.2", true)
detailInfoReq := &workflow.GetWorkflowDetailInfoRequest{
WorkflowFilterList: []*workflow.WorkflowFilter{
{WorkflowID: id},
},
}
detailInfoResponse := post[map[string]any](r, detailInfoReq)
assert.Equal(t, 1, len((*detailInfoResponse)["data"].([]any)))
assert.Equal(t, "v0.0.2", (*detailInfoResponse)["data"].([]any)[0].(map[string]any)["latest_flow_version"].(string))
assert.Equal(t, int64(1), (*detailInfoResponse)["data"].([]any)[0].(map[string]any)["end_type"].(int64))
deleteReq := &workflow.DeleteWorkflowRequest{
WorkflowID: id,
}
_ = post[workflow.DeleteWorkflowResponse](r, deleteReq)
})
}
func TestParallelInterrupts(t *testing.T) {
mockey.PatchConvey("test parallel interrupts", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
chatModel1 := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"question": "what's your age?"}`,
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"fields": {"user_name": "eino", "user_age": 1}}`,
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
chatModel2 := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"question": "what's your gender?"}`,
}, nil
} else if index == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"fields": {"nationality": "China", "gender": "prefer not to say"}}`,
}, nil
} else {
return nil, fmt.Errorf("unexpected index: %d", index)
}
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1737521813 {
return chatModel1, nil, nil
} else {
return chatModel2, nil, nil
}
}).AnyTimes()
id := r.load("parallel_interrupt.json")
const (
qa1NodeID = "107234"
qa2NodeID = "157915"
inputNodeID = "162226"
)
var (
interruptSeq []string // total 5 interrupts, the interrupted node ID in sequence
qa1Answers = []string{"my name is eino.", "my age is 1"}
qa2Answers = []string{"I'm from China.", "I prefer not to say my gender"}
inputStr = mustMarshalToString(t, map[string]any{
"input": "this is the user input",
})
)
exeID := r.testRun(id, map[string]string{})
e := r.getProcess(id, exeID)
interruptSeq = append(interruptSeq, e.event.NodeID)
r.testResume(id, exeID, e.event.ID, ternary.IFElse(interruptSeq[0] == qa1NodeID, qa1Answers[0], qa2Answers[0]))
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
interruptSeq = append(interruptSeq, e2.event.NodeID)
assert.Equal(t, interruptSeq[0], interruptSeq[1]) // the first two interrupts must happen at the same QA node
r.testResume(id, exeID, e2.event.ID, ternary.IFElse(interruptSeq[1] == qa1NodeID, qa1Answers[1], qa2Answers[1]))
e3 := r.getProcess(id, exeID, withPreviousEventID(e2.event.ID))
interruptSeq = append(interruptSeq, e3.event.NodeID)
var thirdResumeString string
switch e3.event.NodeID {
case qa1NodeID:
thirdResumeString = qa1Answers[0]
case qa2NodeID:
thirdResumeString = qa2Answers[0]
case inputNodeID:
thirdResumeString = inputStr
default:
}
r.testResume(id, exeID, e3.event.ID, thirdResumeString)
e4 := r.getProcess(id, exeID, withPreviousEventID(e3.event.ID))
interruptSeq = append(interruptSeq, e4.event.NodeID)
assert.Contains(t, []string{qa1NodeID, qa2NodeID}, interruptSeq[2]) // fourth interrupt must be either qa1 or qa2
assert.NotEqual(t, interruptSeq[0], interruptSeq[3]) // fourth interrupt must be different from the first interrupt
var fourthResumeString string
switch e4.event.NodeID {
case qa1NodeID:
fourthResumeString = ternary.IFElse(interruptSeq[3] == interruptSeq[2], qa1Answers[1], qa1Answers[0])
case qa2NodeID:
fourthResumeString = ternary.IFElse(interruptSeq[3] == interruptSeq[2], qa2Answers[1], qa2Answers[0])
}
r.testResume(id, exeID, e4.event.ID, fourthResumeString)
e5 := r.getProcess(id, exeID, withPreviousEventID(e4.event.ID))
interruptSeq = append(interruptSeq, e5.event.NodeID)
var fifthResumeString string
switch e5.event.NodeID {
case qa1NodeID:
fifthResumeString = qa1Answers[1]
case qa2NodeID:
fifthResumeString = qa2Answers[1]
case inputNodeID:
fifthResumeString = inputStr
default:
}
r.testResume(id, exeID, e5.event.ID, fifthResumeString)
e6 := r.getProcess(id, exeID, withPreviousEventID(e5.event.ID))
e6.assertSuccess()
assert.Equal(t, map[string]any{
"gender": "prefer not to say",
"user_input": "this is the user input",
"user_name": "eino",
"user_age": int64(1),
"nationality": "China",
}, mustUnmarshalToMap(t, e6.output))
})
}
func TestInputComplex(t *testing.T) {
mockey.PatchConvey("test input complex", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("input_complex.json")
exeID := r.testRun(id, map[string]string{})
e := r.getProcess(id, exeID)
r.testResume(id, exeID, e.event.ID, mustMarshalToString(t, map[string]any{
"input": `{"name": "eino", "age": 1}`,
"input_list": `[{"name":"user_1"},{"age":2}]`,
}))
e2 := r.getProcess(id, exeID, withPreviousEventID(e.event.ID))
e2.assertSuccess()
assert.Equal(t, map[string]any{
"output": map[string]any{
"name": "eino",
"age": int64(1),
},
"output_list": []any{
map[string]any{
"name": "user_1",
"age": nil,
},
map[string]any{
"name": nil,
"age": int64(2),
},
},
}, mustUnmarshalToMap(t, e2.output))
})
}
func TestLLMWithSkills(t *testing.T) {
mockey.PatchConvey("workflow llm node with plugin", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
utChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
inputs := map[string]any{
"title": "梦到蛇",
"object_input": map[string]any{"t1": "value"},
"string_input": "input_string",
}
args, _ := sonic.MarshalString(inputs)
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: "xz_zgjm",
Arguments: args,
},
},
},
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 11,
TotalTokens: 21,
},
},
}, nil
} else if index == 1 {
toolResult := map[string]any{}
err := sonic.UnmarshalString(in[len(in)-1].Content, &toolResult)
assert.NoError(t, err)
assert.Equal(t, "ok", toolResult["data"])
return &schema.Message{
Role: schema.Assistant,
Content: `黑色通常关联着负面、消极`,
}, nil
}
return nil, fmt.Errorf("unexpected index: %d", index)
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(utChatModel, nil, nil).AnyTimes()
r.plugin.EXPECT().ExecuteTool(gomock.Any(), gomock.Any(), gomock.Any()).Return(&plugin2.ExecuteToolResponse{
TrimmedResp: `{"data":"ok","err_msg":"error","data_structural":{"content":"ok","title":"title","weburl":"weburl"}}`,
}, nil).AnyTimes()
r.plugin.EXPECT().MGetOnlinePlugins(gomock.Any(), gomock.Any()).Return([]*entity3.PluginInfo{
{PluginInfo: &plugin2.PluginInfo{ID: 7509353177339133952}},
}, nil).AnyTimes()
r.plugin.EXPECT().MGetDraftPlugins(gomock.Any(), gomock.Any()).Return([]*entity3.PluginInfo{{
PluginInfo: &plugin2.PluginInfo{ID: 7509353177339133952},
}}, nil).AnyTimes()
operationString := `{
"summary" : "根据输入的解梦标题给出相关对应的解梦内容,如果返回的内容为空,给用户返回固定的话术:如果想了解自己梦境的详细解析,需要给我详细的梦见信息,例如: 梦见XXX",
"operationId" : "xz_zgjm",
"parameters" : [ {
"description" : "查询解梦标题,例如:梦见蛇",
"in" : "query",
"name" : "title",
"required" : true,
"schema" : {
"description" : "查询解梦标题,例如:梦见蛇",
"type" : "string"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"type" : "object"
}
}
}
},
"responses" : {
"200" : {
"content" : {
"application/json" : {
"schema" : {
"properties" : {
"data" : {
"description" : "返回数据",
"type" : "string"
},
"data_structural" : {
"description" : "返回数据结构",
"properties" : {
"content" : {
"description" : "解梦内容",
"type" : "string"
},
"title" : {
"description" : "解梦标题",
"type" : "string"
},
"weburl" : {
"description" : "当前内容关联的页面地址",
"type" : "string"
}
},
"type" : "object"
},
"err_msg" : {
"description" : "错误提示",
"type" : "string"
}
},
"required" : [ "data", "data_structural" ],
"type" : "object"
}
}
},
"description" : "new desc"
},
"default" : {
"description" : ""
}
}
}`
operation := &plugin2.Openapi3Operation{}
_ = sonic.UnmarshalString(operationString, operation)
r.plugin.EXPECT().MGetOnlineTools(gomock.Any(), gomock.Any()).Return([]*entity3.ToolInfo{
{ID: int64(7509353598782816256), Operation: operation},
}, nil).AnyTimes()
r.plugin.EXPECT().MGetDraftTools(gomock.Any(), gomock.Any()).Return([]*entity3.ToolInfo{
{ID: int64(7509353598782816256), Operation: operation},
}, nil).AnyTimes()
pluginSrv := pluginImpl.InitDomainService(r.plugin, r.tos)
crossplugin.SetDefaultSVC(pluginSrv)
t.Run("llm with plugin tool", func(t *testing.T) {
id := r.load("llm_node_with_skills/llm_node_with_plugin_tool.json")
exeID := r.testRun(id, map[string]string{
"e": "mmmm",
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, `{"output":"mmmm"}`, e.output)
})
})
mockey.PatchConvey("workflow llm node with workflow as tool", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
utChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
inputs := map[string]any{
"input_string": "input_string",
"input_object": map[string]any{"t1": "value"},
"input_number": 123,
}
args, _ := sonic.MarshalString(inputs)
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{
{
ID: "1",
Function: schema.FunctionCall{
Name: fmt.Sprintf("ts_%s_%s", "test_wf", "test_wf"),
Arguments: args,
},
},
},
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 11,
TotalTokens: 21,
},
},
}, nil
} else if index == 1 {
result := make(map[string]any)
err := sonic.UnmarshalString(in[len(in)-1].Content, &result)
assert.Nil(t, err)
assert.Equal(t, nil, result["output_object"])
assert.Equal(t, "input_string", result["output_string"])
assert.Equal(t, int64(123), result["output_number"])
return &schema.Message{
Role: schema.Assistant,
Content: `output_data`,
}, nil
}
return nil, fmt.Errorf("unexpected index: %d", index)
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(utChatModel, nil, nil).AnyTimes()
t.Run("llm with workflow tool", func(t *testing.T) {
r.load("llm_node_with_skills/llm_workflow_as_tool.json", withID(7509120431183544356), withPublish("v0.0.1"))
id := r.load("llm_node_with_skills/llm_node_with_workflow_tool.json")
exeID := r.testRun(id, map[string]string{
"input_string": "ok_input_string",
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, `{"output":"output_data"}`, e.output)
})
})
mockey.PatchConvey("workflow llm node with knowledge skill", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
utChatModel := r.internalModel
utChatModel.InvokeResultProvider = func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
assert.Equal(t, 1, len(in))
assert.Contains(t, in[0].Content, "7512369185624686592", "你是一个知识库意图识别AI Agent", "北京有哪些著名的景点")
return &schema.Message{
Role: schema.Assistant,
Content: "7512369185624686592",
ResponseMeta: &schema.ResponseMeta{
Usage: &schema.TokenUsage{
PromptTokens: 10,
CompletionTokens: 11,
TotalTokens: 21,
},
},
}, nil
} else if index == 1 {
assert.Equal(t, 2, len(in))
for _, message := range in {
if message.Role == schema.System {
assert.Equal(t, "你是一个旅游推荐专家,通过用户提出的问题,推荐用户具体城市的旅游景点", message.Content)
}
if message.Role == schema.User {
assert.Contains(t, message.Content, "天安门广场 :中国政治文化中心,见证了近现代重大历史事件", "八达岭长城 :明代长城的精华段,被誉为“不到长城非好汉")
}
}
return &schema.Message{
Role: schema.Assistant,
Content: `八达岭长城 :明代长城的精华段,被誉为“不到长城非好汉`,
}, nil
}
return nil, fmt.Errorf("unexpected index: %d", index)
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(utChatModel, nil, nil).AnyTimes()
r.knowledge.EXPECT().ListKnowledgeDetail(gomock.Any(), gomock.Any()).Return(&knowledge.ListKnowledgeDetailResponse{
KnowledgeDetails: []*knowledge.KnowledgeDetail{
{ID: 7512369185624686592, Name: "旅游景点", Description: "旅游景点介绍"},
},
}, nil).AnyTimes()
// r.knowledge.EXPECT().Retrieve(gomock.Any(), gomock.Any()).Return(&knowledge.RetrieveResponse{
// RetrieveSlices: []*knowledge.RetrieveSlice{
// {Slice: &knowledge.Slice{DocumentID: 1, Output: "天安门广场 :中国政治文化中心,见证了近现代重大历史事件"}, Score: 0.9},
// {Slice: &knowledge.Slice{DocumentID: 2, Output: "八达岭长城 :明代长城的精华段,被誉为“不到长城非好汉"}, Score: 0.8},
// },
// }, nil).AnyTimes()
// t.Run("llm node with knowledge skill", func(t *testing.T) {
// id := r.load("llm_node_with_skills/llm_with_knowledge_skill.json")
// exeID := r.testRun(id, map[string]string{
// "input": "北京有哪些著名的景点",
// })
// e := r.getProcess(id, exeID)
// e.assertSuccess()
// assert.Equal(t, `{"output":"八达岭长城 :明代长城的精华段,被誉为“不到长城非好汉"}`, e.output)
// })
})
}
func TestStreamRun(t *testing.T) {
mockey.PatchConvey("test stream run", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
defer r.runServer()()
chatModel1 := &testutil.UTChatModel{
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
sr := schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
},
{
Role: schema.Assistant,
Content: "don't know.",
},
})
return sr, nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel1, nil, nil).AnyTimes()
id := r.load("sse/llm_emitter.json")
type expectedE struct {
ID string
Event appworkflow.StreamRunEventType
Data *streamRunData
}
expectedEvents := []expectedE{
{
ID: "0",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("198540"),
NodeType: ptr.Of("Message"),
NodeTitle: ptr.Of("输出"),
NodeSeqID: ptr.Of("0"),
NodeIsFinish: ptr.Of(false),
Content: ptr.Of("emitter: "),
ContentType: ptr.Of("text"),
},
},
{
ID: "1",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("198540"),
NodeType: ptr.Of("Message"),
NodeTitle: ptr.Of("输出"),
NodeSeqID: ptr.Of("1"),
NodeIsFinish: ptr.Of(false),
Content: ptr.Of("I "),
ContentType: ptr.Of("text"),
},
},
{
ID: "2",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("198540"),
NodeType: ptr.Of("Message"),
NodeTitle: ptr.Of("输出"),
NodeSeqID: ptr.Of("2"),
NodeIsFinish: ptr.Of(true),
Content: ptr.Of("don't know."),
ContentType: ptr.Of("text"),
},
},
{
ID: "3",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("900001"),
NodeType: ptr.Of("End"),
NodeTitle: ptr.Of("结束"),
NodeSeqID: ptr.Of("0"),
NodeIsFinish: ptr.Of(false),
Content: ptr.Of("pure_output_for_subworkflow exit: "),
ContentType: ptr.Of("text"),
},
},
{
ID: "4",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("900001"),
NodeType: ptr.Of("End"),
NodeTitle: ptr.Of("结束"),
NodeSeqID: ptr.Of("1"),
NodeIsFinish: ptr.Of(false),
Content: ptr.Of("I "),
ContentType: ptr.Of("text"),
},
},
{
ID: "5",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("900001"),
NodeType: ptr.Of("End"),
NodeTitle: ptr.Of("结束"),
NodeSeqID: ptr.Of("2"),
NodeIsFinish: ptr.Of(true),
Content: ptr.Of("don't know."),
ContentType: ptr.Of("text"),
},
},
{
ID: "6",
Event: appworkflow.DoneEvent,
Data: &streamRunData{
DebugURL: ptr.Of(fmt.Sprintf("https://www.coze.cn/work_flow?execute_id={{exeID}}&space_id=123&workflow_id=%s&execute_mode=2", id)),
},
},
}
index := 0
r.publish(id, "v0.0.1", true)
sseReader := r.openapiStream(id, map[string]any{
"input": "hello",
})
err := sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
var streamE streamRunData
err := sonic.Unmarshal(e.Data, &streamE)
assert.NoError(t, err)
debugURL := streamE.DebugURL
if debugURL != nil {
exeID := strings.TrimPrefix(strings.Split(*debugURL, "&")[0], "https://www.coze.cn/work_flow?execute_id=")
expectedEvents[index].Data.DebugURL = ptr.Of(strings.ReplaceAll(*debugURL, "{{exeID}}", exeID))
}
require.Equal(t, expectedEvents[index].Data.Content, streamE.Content)
require.Equal(t, expectedEvents[index], expectedE{
ID: e.ID,
Event: appworkflow.StreamRunEventType(e.Type),
Data: &streamE,
})
index++
return nil
})
assert.NoError(t, err)
mockey.PatchConvey("test llm node debug", func() {
chatModel1.Reset()
chatModel1.InvokeResultProvider = func(index int, in []*schema.Message) (*schema.Message, error) {
if index == 0 {
return &schema.Message{
Role: schema.Assistant,
Content: "I don't know.",
}, nil
}
return nil, fmt.Errorf("unexpected index: %d", index)
}
exeID := r.nodeDebug(id, "156549", withNDInput(map[string]string{"input": "hello"}))
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"output": "I don't know.",
}, mustUnmarshalToMap(t, e.output))
result := r.getNodeExeHistory(id, exeID, "156549", nil)
assert.Equal(t, mustUnmarshalToMap(t, e.output), mustUnmarshalToMap(t, result.Output))
})
})
}
func TestStreamResume(t *testing.T) {
mockey.PatchConvey("test stream resume", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
defer r.runServer()()
id := r.load("input_complex.json")
type expectedE struct {
ID string
Event appworkflow.StreamRunEventType
Data *streamRunData
}
expectedEvents := []expectedE{
{
ID: "0",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("191011"),
NodeType: ptr.Of("Input"),
NodeTitle: ptr.Of("输入"),
NodeSeqID: ptr.Of("0"),
NodeIsFinish: ptr.Of(true),
Content: ptr.Of("{\"content\":\"[{\\\"type\\\":\\\"object\\\",\\\"name\\\":\\\"input\\\",\\\"schema\\\":[{\\\"type\\\":\\\"string\\\",\\\"name\\\":\\\"name\\\",\\\"required\\\":false},{\\\"type\\\":\\\"integer\\\",\\\"name\\\":\\\"age\\\",\\\"required\\\":false}],\\\"required\\\":false},{\\\"type\\\":\\\"list\\\",\\\"name\\\":\\\"input_list\\\",\\\"schema\\\":{\\\"type\\\":\\\"object\\\",\\\"schema\\\":[{\\\"type\\\":\\\"string\\\",\\\"name\\\":\\\"name\\\",\\\"required\\\":false},{\\\"type\\\":\\\"integer\\\",\\\"name\\\":\\\"age\\\",\\\"required\\\":false}]},\\\"required\\\":false}]\",\"content_type\":\"form_schema\"}"),
ContentType: ptr.Of("text"),
},
},
{
ID: "1",
Event: appworkflow.InterruptEvent,
Data: &streamRunData{
DebugURL: ptr.Of(fmt.Sprintf("https://www.coze.cn/work_flow?execute_id={{exeID}}&space_id=123&workflow_id=%s&execute_mode=2", id)),
InterruptData: &interruptData{
EventID: "%s/%s",
Type: 5,
Data: "{\"content\":\"[{\\\"type\\\":\\\"object\\\",\\\"name\\\":\\\"input\\\",\\\"schema\\\":[{\\\"type\\\":\\\"string\\\",\\\"name\\\":\\\"name\\\",\\\"required\\\":false},{\\\"type\\\":\\\"integer\\\",\\\"name\\\":\\\"age\\\",\\\"required\\\":false}],\\\"required\\\":false},{\\\"type\\\":\\\"list\\\",\\\"name\\\":\\\"input_list\\\",\\\"schema\\\":{\\\"type\\\":\\\"object\\\",\\\"schema\\\":[{\\\"type\\\":\\\"string\\\",\\\"name\\\":\\\"name\\\",\\\"required\\\":false},{\\\"type\\\":\\\"integer\\\",\\\"name\\\":\\\"age\\\",\\\"required\\\":false}]},\\\"required\\\":false}]\",\"content_type\":\"form_schema\"}",
},
},
},
}
var (
resumeID string
index int
)
r.publish(id, "v0.0.1", true)
sseReader := r.openapiStream(id, map[string]any{})
err := sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
if e.Type == string(appworkflow.InterruptEvent) {
var event streamRunData
err := sonic.Unmarshal(e.Data, &event)
assert.NoError(t, err)
resumeID = event.InterruptData.EventID
}
var streamE streamRunData
err := sonic.Unmarshal(e.Data, &streamE)
assert.NoError(t, err)
debugURL := streamE.DebugURL
if debugURL != nil {
exeID := strings.TrimPrefix(strings.Split(*debugURL, "&")[0], "https://www.coze.cn/work_flow?execute_id=")
expectedEvents[index].Data.DebugURL = ptr.Of(strings.ReplaceAll(*debugURL, "{{exeID}}", exeID))
}
if streamE.InterruptData != nil {
expectedEvents[index].Data.InterruptData.EventID = streamE.InterruptData.EventID
}
assert.Equal(t, expectedEvents[index], expectedE{
ID: e.ID,
Event: appworkflow.StreamRunEventType(e.Type),
Data: &streamE,
})
index++
return nil
})
assert.NoError(t, err)
expectedEvents = []expectedE{
{
ID: "0",
Event: appworkflow.MessageEvent,
Data: &streamRunData{
NodeID: ptr.Of("900001"),
NodeType: ptr.Of("End"),
NodeTitle: ptr.Of("结束"),
NodeSeqID: ptr.Of("0"),
NodeIsFinish: ptr.Of(true),
Content: ptr.Of("{\"output\":{\"age\":1,\"name\":\"eino\"},\"output_list\":[{\"age\":null,\"name\":\"user_1\"},{\"age\":2,\"name\":null}]}"),
ContentType: ptr.Of("text"),
},
},
{
ID: "1",
Event: appworkflow.DoneEvent,
Data: &streamRunData{
DebugURL: ptr.Of(fmt.Sprintf("https://www.coze.cn/work_flow?execute_id={{exeID}}&space_id=123&workflow_id=%s&execute_mode=2", id)),
},
},
}
index = 0
sseReader = r.openapiResume(id, resumeID, mustMarshalToString(t, map[string]any{
"input": `{"name": "eino", "age": 1}`,
"input_list": `[{"name":"user_1"},{"age":2}]`,
}))
err = sseReader.ForEach(t.Context(), func(e *sse.Event) error {
t.Logf("sse id: %s, type: %s, data: %s", e.ID, e.Type, string(e.Data))
var streamE streamRunData
err := sonic.Unmarshal(e.Data, &streamE)
assert.NoError(t, err)
debugURL := streamE.DebugURL
if debugURL != nil {
exeID := strings.TrimPrefix(strings.Split(*debugURL, "&")[0], "https://www.coze.cn/work_flow?execute_id=")
expectedEvents[index].Data.DebugURL = ptr.Of(strings.ReplaceAll(*debugURL, "{{exeID}}", exeID))
}
if streamE.InterruptData != nil {
expectedEvents[index].Data.InterruptData.EventID = streamE.InterruptData.EventID
}
assert.Equal(t, expectedEvents[index], expectedE{
ID: e.ID,
Event: appworkflow.StreamRunEventType(e.Type),
Data: &streamE,
})
index++
return nil
})
assert.NoError(t, err)
})
}
func TestGetLLMNodeFCSettingsDetailAndMerged(t *testing.T) {
mockey.PatchConvey("fc setting detail", t, func() {
operationString := `{
"summary" : "根据输入的解梦标题给出相关对应的解梦内容,如果返回的内容为空,给用户返回固定的话术:如果想了解自己梦境的详细解析,需要给我详细的梦见信息,例如: 梦见XXX",
"operationId" : "xz_zgjm",
"parameters" : [ {
"description" : "查询解梦标题,例如:梦见蛇",
"in" : "query",
"name" : "title",
"required" : true,
"schema" : {
"description" : "查询解梦标题,例如:梦见蛇",
"type" : "string"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"type" : "object"
}
}
}
},
"responses" : {
"200" : {
"content" : {
"application/json" : {
"schema" : {
"properties" : {
"data" : {
"description" : "返回数据",
"type" : "string"
},
"data_structural" : {
"description" : "返回数据结构",
"properties" : {
"content" : {
"description" : "解梦内容",
"type" : "string"
},
"title" : {
"description" : "解梦标题",
"type" : "string"
},
"weburl" : {
"description" : "当前内容关联的页面地址",
"type" : "string"
}
},
"type" : "object"
},
"err_msg" : {
"description" : "错误提示",
"type" : "string"
}
},
"required" : [ "data", "data_structural" ],
"type" : "object"
}
}
},
"description" : "new desc"
},
"default" : {
"description" : ""
}
}
}`
operation := &plugin2.Openapi3Operation{}
_ = sonic.UnmarshalString(operationString, operation)
r := newWfTestRunner(t)
defer r.closeFn()
r.plugin.EXPECT().MGetOnlinePlugins(gomock.Any(), gomock.Any()).Return([]*entity3.PluginInfo{
{
PluginInfo: &plugin2.PluginInfo{
ID: 123,
SpaceID: 123,
Version: ptr.Of("v0.0.1"),
Manifest: &plugin2.PluginManifest{NameForHuman: "p1", DescriptionForHuman: "desc"},
},
},
}, nil).AnyTimes()
r.plugin.EXPECT().MGetOnlineTools(gomock.Any(), gomock.Any()).Return([]*entity3.ToolInfo{
{ID: 123, Operation: operation},
}, nil).AnyTimes()
pluginSrv := pluginImpl.InitDomainService(r.plugin, r.tos)
crossplugin.SetDefaultSVC(pluginSrv)
t.Run("plugin tool info ", func(t *testing.T) {
fcSettingDetailReq := &workflow.GetLLMNodeFCSettingDetailRequest{
PluginList: []*workflow.PluginFCItem{
{PluginID: "123", APIID: "123"},
},
}
response := post[map[string]any](r, fcSettingDetailReq)
assert.Equal(t, (*response)["plugin_detail_map"].(map[string]any)["123"].(map[string]any)["description"], "desc")
assert.Equal(t, (*response)["plugin_detail_map"].(map[string]any)["123"].(map[string]any)["name"], "p1")
assert.Equal(t, (*response)["plugin_api_detail_map"].(map[string]any)["123"].(map[string]any)["name"], "xz_zgjm")
assert.Equal(t, 1, len((*response)["plugin_api_detail_map"].(map[string]any)["123"].(map[string]any)["parameters"].([]any)))
})
t.Run("workflow tool info ", func(t *testing.T) {
r.load("entry_exit.json", withID(123), withPublish("v0.0.1"))
fcSettingDetailReq := &workflow.GetLLMNodeFCSettingDetailRequest{
WorkflowList: []*workflow.WorkflowFCItem{
{WorkflowID: "123", PluginID: "123", WorkflowVersion: ptr.Of("v0.0.1")},
},
}
response := post[map[string]any](r, fcSettingDetailReq)
assert.Equal(t, (*response)["workflow_detail_map"].(map[string]any)["123"].(map[string]any)["plugin_id"], "123")
assert.Equal(t, (*response)["workflow_detail_map"].(map[string]any)["123"].(map[string]any)["name"], "test_wf")
assert.Equal(t, (*response)["workflow_detail_map"].(map[string]any)["123"].(map[string]any)["description"], "this is a test wf")
})
})
mockey.PatchConvey("fc setting merged", t, func() {
operationString := `{
"summary" : "根据输入的解梦标题给出相关对应的解梦内容,如果返回的内容为空,给用户返回固定的话术:如果想了解自己梦境的详细解析,需要给我详细的梦见信息,例如: 梦见XXX",
"operationId" : "xz_zgjm",
"parameters" : [ {
"description" : "查询解梦标题,例如:梦见蛇",
"in" : "query",
"name" : "title",
"required" : true,
"schema" : {
"description" : "查询解梦标题,例如:梦见蛇",
"type" : "string"
}
} ],
"requestBody" : {
"content" : {
"application/json" : {
"schema" : {
"type" : "object"
}
}
}
},
"responses" : {
"200" : {
"content" : {
"application/json" : {
"schema" : {
"properties" : {
"data" : {
"description" : "返回数据",
"type" : "string"
},
"data_structural" : {
"description" : "返回数据结构",
"properties" : {
"content" : {
"description" : "解梦内容",
"type" : "string"
},
"title" : {
"description" : "解梦标题",
"type" : "string"
},
"weburl" : {
"description" : "当前内容关联的页面地址",
"type" : "string"
}
},
"type" : "object"
},
"err_msg" : {
"description" : "错误提示",
"type" : "string"
}
},
"required" : [ "data", "data_structural" ],
"type" : "object"
}
}
},
"description" : "new desc"
},
"default" : {
"description" : ""
}
}
}`
operation := &plugin2.Openapi3Operation{}
_ = sonic.UnmarshalString(operationString, operation)
r := newWfTestRunner(t)
defer r.closeFn()
r.plugin.EXPECT().MGetOnlinePlugins(gomock.Any(), gomock.Any()).Return([]*entity3.PluginInfo{
{
PluginInfo: &plugin2.PluginInfo{
ID: 123,
SpaceID: 123,
Version: ptr.Of("v0.0.1"),
Manifest: &plugin2.PluginManifest{NameForHuman: "p1", DescriptionForHuman: "desc"},
},
},
}, nil).AnyTimes()
r.plugin.EXPECT().MGetOnlineTools(gomock.Any(), gomock.Any()).Return([]*entity3.ToolInfo{
{ID: 123, Operation: operation},
}, nil).AnyTimes()
pluginSrv := pluginImpl.InitDomainService(r.plugin, r.tos)
crossplugin.SetDefaultSVC(pluginSrv)
t.Run("plugin merge", func(t *testing.T) {
fcSettingMergedReq := &workflow.GetLLMNodeFCSettingsMergedRequest{
PluginFcSetting: &workflow.FCPluginSetting{
PluginID: "123", APIID: "123",
RequestParams: []*workflow.APIParameter{
{Name: "title", LocalDisable: true, LocalDefault: ptr.Of("value")},
},
ResponseParams: []*workflow.APIParameter{
{Name: "data123", LocalDisable: true},
},
},
}
response := post[map[string]any](r, fcSettingMergedReq)
assert.Equal(t, (*response)["plugin_fc_setting"].(map[string]any)["request_params"].([]any)[0].(map[string]any)["local_disable"], true)
names := map[string]bool{
"data": true,
"data_structural": true,
"err_msg": true,
}
assert.Equal(t, 3, len((*response)["plugin_fc_setting"].(map[string]any)["response_params"].([]any)))
for _, mm := range (*response)["plugin_fc_setting"].(map[string]any)["response_params"].([]any) {
n := mm.(map[string]any)["name"].(string)
assert.True(t, names[n])
}
})
t.Run("workflow merge", func(t *testing.T) {
r.load("entry_exit.json", withID(1234), withPublish("v0.0.1"))
fcSettingMergedReq := &workflow.GetLLMNodeFCSettingsMergedRequest{
WorkflowFcSetting: &workflow.FCWorkflowSetting{
WorkflowID: "1234",
PluginID: "1234",
RequestParams: []*workflow.APIParameter{
{Name: "obj", LocalDisable: true, LocalDefault: ptr.Of("{}")},
},
ResponseParams: []*workflow.APIParameter{
{Name: "literal_key", LocalDisable: true},
{Name: "literal_key_bak", LocalDisable: true},
},
},
}
response := post[map[string]any](r, fcSettingMergedReq)
assert.Equal(t, 3, len((*response)["worflow_fc_setting"].(map[string]any)["request_params"].([]any)))
assert.Equal(t, 8, len((*response)["worflow_fc_setting"].(map[string]any)["response_params"].([]any)))
for _, mm := range (*response)["worflow_fc_setting"].(map[string]any)["request_params"].([]any) {
if mm.(map[string]any)["name"].(string) == "obj" {
assert.True(t, mm.(map[string]any)["local_disable"].(bool))
}
}
})
})
}
func TestNodeDebugLoop(t *testing.T) {
mockey.PatchConvey("test node debug loop", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("loop_selector_variable_assign_text_processor.json")
exeID := r.nodeDebug(id, "192046", withNDInput(map[string]string{"input": `["a", "bb", "ccc", "dddd"]`}))
e := r.getProcess(id, exeID, withSpecificNodeID("192046"))
e.assertSuccess()
assert.Equal(t, map[string]any{
"converted": []any{
"new_a",
"new_ccc",
},
"variable_out": "dddd",
}, mustUnmarshalToMap(t, e.output))
result := r.getNodeExeHistory(id, exeID, "192046", nil)
assert.Equal(t, mustUnmarshalToMap(t, e.output), mustUnmarshalToMap(t, result.Output))
// verify this workflow has not been successfully test ran
result = r.getNodeExeHistory(id, "", "100001", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.Equal(t, "", result.Output)
// verify that another node of this workflow is not node debugged
result = r.getNodeExeHistory(id, "", "wrong_node_id", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.Equal(t, "", result.Output)
})
mockey.PatchConvey("test node debug loop", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("loop_selector_variable_assign_text_processor.json")
exeID := r.nodeDebug(id, "192046", withNDInput(map[string]string{"input": `["a", "bb", "ccc", "dddd"]`}))
e := r.getProcess(id, exeID, withSpecificNodeID("192046"))
e.assertSuccess()
assert.Equal(t, map[string]any{
"converted": []any{
"new_a",
"new_ccc",
},
"variable_out": "dddd",
}, mustUnmarshalToMap(t, e.output))
result := r.getNodeExeHistory(id, exeID, "192046", nil)
assert.Equal(t, mustUnmarshalToMap(t, e.output), mustUnmarshalToMap(t, result.Output))
// verify this workflow has not been successfully test ran
result = r.getNodeExeHistory(id, "", "100001", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.Equal(t, "", result.Output)
// verify that another node of this workflow is not node debugged
result = r.getNodeExeHistory(id, "", "wrong_node_id", ptr.Of(workflow.NodeHistoryScene_TestRunInput))
assert.Equal(t, "", result.Output)
})
mockey.PatchConvey("test node debug loop", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
runner := mockcode.NewMockRunner(r.ctrl)
runner.EXPECT().Run(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, request *coderunner.RunRequest) (*coderunner.RunResponse, error) {
return &coderunner.RunResponse{
Result: request.Params,
}, nil
}).AnyTimes()
code.SetCodeRunner(runner)
id := r.load("loop_with_object_input.json")
exeID := r.nodeDebug(id, "122149",
withNDInput(map[string]string{"input": `[{"a":"1"},{"a":"2"}]`}))
e := r.getProcess(id, exeID, withSpecificNodeID("122149"))
e.assertSuccess()
assert.Equal(t, `{"output":["1","2"]}`, e.output)
})
}
func TestCopyWorkflow(t *testing.T) {
mockey.PatchConvey("copy work flow", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("publish/publish_workflow.json", withName("original_workflow"))
response := post[workflow.CopyWorkflowResponse](r, &workflow.CopyWorkflowRequest{
WorkflowID: id,
})
oldCanvasResponse := post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(id),
})
copiedCanvasResponse := post[workflow.GetCanvasInfoResponse](r, &workflow.GetCanvasInfoRequest{
WorkflowID: ptr.Of(response.Data.WorkflowID),
})
assert.Equal(t, ptr.From(oldCanvasResponse.Data.Workflow.SchemaJSON), ptr.From(copiedCanvasResponse.Data.Workflow.SchemaJSON))
assert.Equal(t, "original_workflow_1", copiedCanvasResponse.Data.Workflow.Name)
_ = post[workflow.BatchDeleteWorkflowResponse](r, &workflow.BatchDeleteWorkflowRequest{
WorkflowIDList: []string{id, response.Data.WorkflowID},
})
wid, _ := strconv.ParseInt(id, 10, 64)
_, err := appworkflow.GetWorkflowDomainSVC().Get(context.Background(), &vo.GetPolicy{
ID: wid,
QType: workflowModel.FromDraft,
CommitID: "",
})
assert.NotNil(t, err)
assert.ErrorContains(t, err, strconv.Itoa(errno.ErrWorkflowNotFound))
})
}
func TestReleaseApplicationWorkflows(t *testing.T) {
appID := int64(10001000)
mockey.PatchConvey("normal release application workflow", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
r.load("publish/release_main_workflow.json", withID(100100100100), withProjectID(appID))
r.load("publish/release_c1_workflow.json", withID(7511615200781402118), withProjectID(appID))
r.load("publish/release_cc1_workflow.json", withID(7511616004728815618), withProjectID(appID))
wf, err := appworkflow.GetWorkflowDomainSVC().Get(context.Background(), &vo.GetPolicy{
ID: 7511616004728815618,
MetaOnly: true,
})
assert.NoError(t, err)
version := "v0.0.1"
if wf.LatestPublishedVersion != nil {
versionSchema := strings.Split(*wf.LatestPublishedVersion, ".")
vInt, err := strconv.ParseInt(versionSchema[2], 10, 64)
if err != nil {
return
}
nextVer := strconv.FormatInt(vInt+1, 10)
versionSchema[2] = nextVer
version = strings.Join(versionSchema, ".")
}
vIssues, err := appworkflow.GetWorkflowDomainSVC().ReleaseApplicationWorkflows(context.Background(), appID, &vo.ReleaseWorkflowConfig{
Version: version,
PluginIDs: []int64{7511616454588891136},
ConnectorIDs: []int64{consts.APIConnectorID},
})
assert.NoError(t, err)
assert.Equal(t, 0, len(vIssues))
wf, err = appworkflow.GetWorkflowDomainSVC().Get(context.Background(), &vo.GetPolicy{
ID: 100100100100,
QType: workflowModel.FromSpecificVersion,
Version: version,
})
assert.NoError(t, err)
canvasSchema := wf.Canvas
cv := &vo.Canvas{}
err = sonic.UnmarshalString(canvasSchema, cv)
assert.NoError(t, err)
var validateCv func(ns []*vo.Node)
validateCv = func(ns []*vo.Node) {
for _, n := range ns {
if n.Type == entity.NodeTypeSubWorkflow.IDStr() {
assert.Equal(t, n.Data.Inputs.WorkflowVersion, version)
}
if n.Type == entity.NodeTypePlugin.IDStr() {
for _, apiParam := range n.Data.Inputs.APIParams {
// In the application, the workflow plugin node When the plugin version is equal to 0, the plugin is a plugin created in the application
if apiParam.Name == "pluginVersion" {
assert.Equal(t, apiParam.Input.Value.Content, version)
}
}
}
if n.Type == entity.NodeTypeLLM.IDStr() {
if n.Data.Inputs.FCParam != nil && n.Data.Inputs.FCParam.PluginFCParam != nil {
// In the application, the workflow llm node When the plugin version is equal to 0, the plugin is a plugin created in the application
for _, p := range n.Data.Inputs.FCParam.PluginFCParam.PluginList {
_ = p
// assert.Equal(t, p.PluginVersion, version) TODO: this assert fails
}
}
if n.Data.Inputs.FCParam != nil && n.Data.Inputs.FCParam.WorkflowFCParam != nil {
for _, w := range n.Data.Inputs.FCParam.WorkflowFCParam.WorkflowList {
assert.Equal(t, w.WorkflowVersion, version)
}
}
}
if len(n.Blocks) > 0 {
validateCv(n.Blocks)
}
}
}
validateCv(cv.Nodes)
})
mockey.PatchConvey("has issues release application workflow", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
r.load("publish/release_error_workflow.json", withID(1001001001001), withProjectID(100010001))
wf, err := appworkflow.GetWorkflowDomainSVC().Get(context.Background(), &vo.GetPolicy{
ID: 1001001001001,
MetaOnly: true,
})
assert.NoError(t, err)
version := "v0.0.1"
if wf.LatestPublishedVersion != nil {
versionSchema := strings.Split(*wf.LatestPublishedVersion, ".")
vInt, err := strconv.ParseInt(versionSchema[2], 10, 64)
if err != nil {
return
}
nextVer := strconv.FormatInt(vInt+1, 10)
versionSchema[2] = nextVer
version = strings.Join(versionSchema, ".")
}
vIssues, err := appworkflow.GetWorkflowDomainSVC().ReleaseApplicationWorkflows(context.Background(), 100010001, &vo.ReleaseWorkflowConfig{
Version: version,
PluginIDs: []int64{},
ConnectorIDs: []int64{consts.APIConnectorID},
})
assert.NoError(t, err)
assert.Equal(t, 1, len(vIssues))
assert.Equal(t, 2, len(vIssues[0].IssueMessages))
})
}
func TestLLMException(t *testing.T) {
mockey.PatchConvey("test llm exception", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("exception/llm_default_output_retry_timeout.json")
mainChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
return nil, errors.New("first invoke error")
},
}
fallbackChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: `{"name":"eino","age":1}`,
}, nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1737521813 {
return mainChatModel, nil, nil
} else {
return fallbackChatModel, nil, nil
}
}).AnyTimes()
mockey.PatchConvey("two retries to succeed", func() {
exeID := r.nodeDebug(id, "103929", withNDInput(map[string]string{"input": "hello"}))
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"name": "eino",
"age": int64(1),
"isSuccess": true,
}, mustUnmarshalToMap(t, e.output))
})
mockey.PatchConvey("timeout then use default output", func() {
fallbackChatModel.InvokeResultProvider = func(index int, in []*schema.Message) (*schema.Message, error) {
time.Sleep(500 * time.Millisecond)
return &schema.Message{
Role: schema.Assistant,
Content: `{"name":"eino","age":1}`,
}, nil
}
exeID := r.nodeDebug(id, "103929", withNDInput(map[string]string{"input": "hello"}))
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"name": "zhangsan",
"age": int64(3),
"isSuccess": false,
"errorBody": map[string]any{
"errorMessage": "node timeout",
"errorCode": int64(errno.ErrNodeTimeout),
},
}, mustUnmarshalToMap(t, e.output))
})
})
}
func TestLLMExceptionThenThrow(t *testing.T) {
mockey.PatchConvey("test llm exception then throw", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("exception/llm_timeout_throw.json")
mainChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
return nil, errors.New("first invoke error")
},
}
fallbackChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(index int, in []*schema.Message) (*schema.Message, error) {
time.Sleep(500 * time.Millisecond)
return &schema.Message{
Role: schema.Assistant,
Content: `{"name":"eino","age":1}`,
}, nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelType == 1737521813 {
return mainChatModel, nil, nil
} else {
return fallbackChatModel, nil, nil
}
}).AnyTimes()
exeID := r.nodeDebug(id, "103929", withNDInput(map[string]string{"input": "hello"}))
e := r.getProcess(id, exeID)
assert.Equal(t, workflow.WorkflowExeStatus(entity.WorkflowFailed), e.status)
})
}
func TestCodeExceptionBranch(t *testing.T) {
mockey.PatchConvey("test code exception branch", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
id := r.load("exception/code_exception_branch.json")
mockey.PatchConvey("exception branch", func() {
code.SetCodeRunner(direct.NewRunner())
exeID := r.testRun(id, map[string]string{"input": "hello"})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"output": false,
"output1": "code result: false",
}, mustUnmarshalToMap(t, e.output))
})
mockey.PatchConvey("normal branch", func() {
mockCodeRunner := mockcode.NewMockRunner(r.ctrl)
mockey.Mock(code.GetCodeRunner).Return(mockCodeRunner).Build()
mockCodeRunner.EXPECT().Run(gomock.Any(), gomock.Any()).Return(&coderunner.RunResponse{
Result: map[string]any{
"key0": "value0",
"key1": []string{"value1", "value2"},
"key2": map[string]any{},
},
}, nil).AnyTimes()
exeID := r.testRun(id, map[string]string{"input": "hello"})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, map[string]any{
"output": true,
"output1": "",
}, mustUnmarshalToMap(t, e.output))
mockey.PatchConvey("sync run", func() {
r.publish(id, "v0.0.1", false)
result, _ := r.openapiSyncRun(id, map[string]string{"input": "hello"})
assert.Equal(t, map[string]any{
"output": true,
"output1": "",
}, result)
})
})
})
}
func TestCopyWorkflowAppToLibrary(t *testing.T) {
r := newWfTestRunner(t)
appworkflow.SVC.IDGenerator = r.idGen
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
mockey.PatchConvey("copy with subworkflow, subworkflow with external resource ", t, func() {
var copiedIDs = make([]int64, 0)
var mockPublishWorkflowResource func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error
var ignoreIDs = map[int64]bool{
7515027325977624576: true,
7515027249628708864: true,
7515027182796668928: true,
7515027150387281920: true,
7515027091302121472: true,
}
mockPublishWorkflowResource = func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error {
if ignoreIDs[workflowID] {
return nil
}
wf, err := appworkflow.GetWorkflowDomainSVC().Get(ctx, &vo.GetPolicy{
ID: workflowID,
QType: workflowModel.FromLatestVersion,
})
copiedIDs = append(copiedIDs, workflowID)
assert.NoError(t, err)
assert.Equal(t, "v0.0.1", wf.Version)
canvas := &vo.Canvas{}
err = sonic.UnmarshalString(wf.Canvas, canvas)
assert.NoError(t, err)
copiedIDMap := slices.ToMap(copiedIDs, func(e int64) (string, bool) {
return strconv.FormatInt(e, 10), true
})
var validateSubWorkflowIDs func(nodes []*vo.Node)
validateSubWorkflowIDs = func(nodes []*vo.Node) {
for _, node := range nodes {
switch entity.IDStrToNodeType(node.Type) {
case entity.NodeTypePlugin:
apiParams := slices.ToMap(node.Data.Inputs.APIParams, func(e *vo.Param) (string, *vo.Param) {
return e.Name, e
})
pluginIDParam, ok := apiParams["pluginID"]
assert.True(t, ok)
pID, err := strconv.ParseInt(pluginIDParam.Input.Value.Content.(string), 10, 64)
assert.NoError(t, err)
pluginVersionParam, ok := apiParams["pluginVersion"]
assert.True(t, ok)
pVersion := pluginVersionParam.Input.Value.Content.(string)
if pVersion == "0" {
assert.Equal(t, "100100", pID)
}
case entity.NodeTypeSubWorkflow:
assert.True(t, copiedIDMap[node.Data.Inputs.WorkflowID])
wfId, err := strconv.ParseInt(node.Data.Inputs.WorkflowID, 10, 64)
assert.NoError(t, err)
subWf, err := appworkflow.GetWorkflowDomainSVC().Get(ctx, &vo.GetPolicy{
ID: wfId,
QType: workflowModel.FromLatestVersion,
})
assert.NoError(t, err)
subworkflowCanvas := &vo.Canvas{}
err = sonic.UnmarshalString(subWf.Canvas, subworkflowCanvas)
assert.NoError(t, err)
validateSubWorkflowIDs(subworkflowCanvas.Nodes)
case entity.NodeTypeLLM:
if node.Data.Inputs.LLM != nil && node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.WorkflowFCParam != nil {
for _, w := range node.Data.Inputs.FCParam.WorkflowFCParam.WorkflowList {
assert.True(t, copiedIDMap[w.WorkflowID])
}
}
if node.Data.Inputs.LLM != nil && node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.PluginFCParam != nil {
for _, p := range node.Data.Inputs.FCParam.PluginFCParam.PluginList {
if p.PluginVersion == "0" {
assert.Equal(t, "100100", p.PluginID)
}
}
}
if node.Data.Inputs.LLM != nil && node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.KnowledgeFCParam != nil {
for _, k := range node.Data.Inputs.FCParam.KnowledgeFCParam.KnowledgeList {
assert.Equal(t, "100100", k.ID)
}
}
case entity.NodeTypeKnowledgeIndexer, entity.NodeTypeKnowledgeRetriever:
datasetListInfoParam := node.Data.Inputs.DatasetParam[0]
knowledgeIDs := datasetListInfoParam.Input.Value.Content.([]any)
for idx := range knowledgeIDs {
assert.Equal(t, "100100", knowledgeIDs[idx].(string))
}
case entity.NodeTypeDatabaseCustomSQL, entity.NodeTypeDatabaseQuery, entity.NodeTypeDatabaseInsert, entity.NodeTypeDatabaseDelete, entity.NodeTypeDatabaseUpdate:
for _, d := range node.Data.Inputs.DatabaseInfoList {
assert.Equal(t, "100100", d.DatabaseInfoID)
}
}
}
}
validateSubWorkflowIDs(canvas.Nodes)
return nil
}
defer mockey.Mock(appworkflow.PublishWorkflowResource).To(mockPublishWorkflowResource).Build().UnPatch()
appID := "7513788954458456064"
appIDInt64, _ := strconv.ParseInt(appID, 10, 64)
r.load("copy_to_app/child_4.json", withID(7515027325977624576), withProjectID(appIDInt64))
r.load("copy_to_app/child_3.json", withID(7515027249628708864), withProjectID(appIDInt64))
r.load("copy_to_app/child_2.json", withID(7515027182796668928), withProjectID(appIDInt64))
r.load("copy_to_app/child_1.json", withID(7515027150387281920), withProjectID(appIDInt64))
r.load("copy_to_app/main.json", withID(7515027091302121472), withProjectID(appIDInt64))
defer mockey.Mock((*appknowledge.KnowledgeApplicationService).CopyKnowledge).Return(&modelknowledge.CopyKnowledgeResponse{
TargetKnowledgeID: 100100,
}, nil).Build().UnPatch()
mockCopyDatabase := func(ctx context.Context, req *appmemory.CopyDatabaseRequest) (*appmemory.CopyDatabaseResponse, error) {
es := make(map[int64]*entity4.Database)
for _, id := range req.DatabaseIDs {
es[id] = &entity4.Database{ID: 100100}
}
return &appmemory.CopyDatabaseResponse{
Databases: es,
}, nil
}
defer mockey.Mock((*appmemory.DatabaseApplicationService).CopyDatabase).To(mockCopyDatabase).Build().UnPatch()
defer mockey.Mock((*appplugin.PluginApplicationService).CopyPlugin).Return(&appplugin.CopyPluginResponse{
Plugin: &entity5.PluginInfo{
PluginInfo: &pluginmodel.PluginInfo{
ID: 100100,
Version: ptr.Of("v0.0.1"),
},
},
}, nil).Build().UnPatch()
_, is, err := appworkflow.SVC.CopyWorkflowFromAppToLibrary(t.Context(), 7515027091302121472, appIDInt64, appIDInt64)
assert.NoError(t, err)
assert.Equal(t, 0, len(is))
})
mockey.PatchConvey("copy only with external resource", t, func() {
var copiedIDs = make([]int64, 0)
var mockPublishWorkflowResource func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error
var ignoreIDs = map[int64]bool{
7516518409656336384: true,
7516516198096306176: true,
}
mockPublishWorkflowResource = func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error {
if ignoreIDs[workflowID] {
return nil
}
wf, err := appworkflow.GetWorkflowDomainSVC().Get(ctx, &vo.GetPolicy{
ID: workflowID,
QType: workflowModel.FromLatestVersion,
})
copiedIDs = append(copiedIDs, workflowID)
assert.NoError(t, err)
assert.Equal(t, "v0.0.1", wf.Version)
canvas := &vo.Canvas{}
err = sonic.UnmarshalString(wf.Canvas, canvas)
assert.NoError(t, err)
copiedIDMap := slices.ToMap(copiedIDs, func(e int64) (string, bool) {
return strconv.FormatInt(e, 10), true
})
var validateSubWorkflowIDs func(nodes []*vo.Node)
validateSubWorkflowIDs = func(nodes []*vo.Node) {
for _, node := range nodes {
switch entity.IDStrToNodeType(node.Type) {
case entity.NodeTypeSubWorkflow:
assert.True(t, copiedIDMap[node.Data.Inputs.WorkflowID])
case entity.NodeTypeLLM:
if node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.WorkflowFCParam != nil {
for _, w := range node.Data.Inputs.FCParam.WorkflowFCParam.WorkflowList {
assert.True(t, copiedIDMap[w.WorkflowID])
}
}
if node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.PluginFCParam != nil {
for _, p := range node.Data.Inputs.FCParam.PluginFCParam.PluginList {
_ = p
}
}
if node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.KnowledgeFCParam != nil {
for _, k := range node.Data.Inputs.FCParam.KnowledgeFCParam.KnowledgeList {
assert.Equal(t, "100100", k.ID)
}
}
case entity.NodeTypeKnowledgeIndexer, entity.NodeTypeKnowledgeRetriever:
datasetListInfoParam := node.Data.Inputs.DatasetParam[0]
knowledgeIDs := datasetListInfoParam.Input.Value.Content.([]any)
for idx := range knowledgeIDs {
assert.Equal(t, "100100", knowledgeIDs[idx].(string))
}
case entity.NodeTypeDatabaseCustomSQL, entity.NodeTypeDatabaseQuery, entity.NodeTypeDatabaseInsert, entity.NodeTypeDatabaseDelete, entity.NodeTypeDatabaseUpdate:
for _, d := range node.Data.Inputs.DatabaseInfoList {
assert.Equal(t, "100100", d.DatabaseInfoID)
}
}
}
}
validateSubWorkflowIDs(canvas.Nodes)
return nil
}
defer mockey.Mock(appworkflow.PublishWorkflowResource).To(mockPublishWorkflowResource).Build().UnPatch()
defer mockey.Mock((*appknowledge.KnowledgeApplicationService).CopyKnowledge).Return(&modelknowledge.CopyKnowledgeResponse{
TargetKnowledgeID: 100100,
}, nil).Build().UnPatch()
mockCopyDatabase := func(ctx context.Context, req *appmemory.CopyDatabaseRequest) (*appmemory.CopyDatabaseResponse, error) {
es := make(map[int64]*entity4.Database)
for _, id := range req.DatabaseIDs {
es[id] = &entity4.Database{ID: 100100}
}
return &appmemory.CopyDatabaseResponse{
Databases: es,
}, nil
}
defer mockey.Mock((*appmemory.DatabaseApplicationService).CopyDatabase).To(mockCopyDatabase).Build().UnPatch()
defer mockey.Mock((*appplugin.PluginApplicationService).CopyPlugin).Return(&appplugin.CopyPluginResponse{
Plugin: &entity5.PluginInfo{
PluginInfo: &pluginmodel.PluginInfo{
ID: time.Now().Unix(),
Version: ptr.Of("v0.0.1"),
},
},
}, nil).Build().UnPatch()
appIDInt64 := int64(7516515408422109184)
r.load("copy_to_app/child2_1.json", withID(7516518409656336384), withProjectID(appIDInt64))
r.load("copy_to_app/main2.json", withID(7516516198096306176), withProjectID(appIDInt64))
_, ret, err := appworkflow.SVC.CopyWorkflowFromAppToLibrary(t.Context(), 7516516198096306176, 123, appIDInt64)
assert.NoError(t, err)
assert.Equal(t, 0, len(ret))
})
}
func TestMoveWorkflowAppToLibrary(t *testing.T) {
mockey.PatchConvey("test move workflow", t, func() {
r := newWfTestRunner(t)
r.publishPatcher.UnPatch()
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
t.Run("move workflow", func(t *testing.T) {
var mockPublishWorkflowResource func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error
named2Idx := []string{"c1", "c2", "cc1", "main"}
callCount := 0
initialWf2ID := map[string]int64{}
old2newID := map[int64]int64{}
mockPublishWorkflowResource = func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error {
if callCount <= 3 {
initialWf2ID[named2Idx[callCount]] = workflowID
callCount++
return nil
}
if op == search.Created {
if oldID, ok := initialWf2ID[*r.Name]; ok {
old2newID[oldID] = workflowID
}
}
return nil
}
defer mockey.Mock(appworkflow.PublishWorkflowResource).To(mockPublishWorkflowResource).Build().UnPatch()
defer mockey.Mock((*appknowledge.KnowledgeApplicationService).MoveKnowledgeToLibrary).Return(nil).Build().UnPatch()
defer mockey.Mock((*appmemory.DatabaseApplicationService).MoveDatabaseToLibrary).Return(&appmemory.MoveDatabaseToLibraryResponse{}, nil).Build().UnPatch()
defer mockey.Mock((*appplugin.PluginApplicationService).MoveAPPPluginToLibrary).Return(&entity5.PluginInfo{
PluginInfo: &pluginmodel.PluginInfo{
ID: time.Now().Unix(),
Version: ptr.Of("v0.0.1"),
},
}, nil).Build().UnPatch()
ctx := t.Context()
appIDInt64 := time.Now().UnixNano()
c1IdStr := r.load("move_to_app/c1.json", withName("c1"), withProjectID(appIDInt64))
c2IdStr := r.load("move_to_app/c2.json", withName("c2"), withProjectID(appIDInt64))
data, err := os.ReadFile("../../../domain/workflow/internal/canvas/examples/move_to_app/main.json")
assert.NoError(t, err)
mainCanvas := &vo.Canvas{}
err = sonic.Unmarshal(data, mainCanvas)
assert.NoError(t, err)
for _, node := range mainCanvas.Nodes {
if node.Type == entity.NodeTypeSubWorkflow.IDStr() {
if node.Data.Inputs.WorkflowID == "7516826260387921920" {
node.Data.Inputs.WorkflowID = c1IdStr
}
if node.Data.Inputs.WorkflowID == "7516826283318181888" {
node.Data.Inputs.WorkflowID = c2IdStr
}
}
}
cc1Data, err := os.ReadFile("../../../domain/workflow/internal/canvas/examples/move_to_app/cc1.json")
assert.NoError(t, err)
cc1Canvas := &vo.Canvas{}
err = sonic.Unmarshal(cc1Data, cc1Canvas)
assert.NoError(t, err)
for _, node := range cc1Canvas.Nodes {
if node.Type == entity.NodeTypeSubWorkflow.IDStr() {
if node.Data.Inputs.WorkflowID == "7516826283318181888" {
node.Data.Inputs.WorkflowID = c2IdStr
}
}
}
cc1Data, _ = sonic.Marshal(cc1Canvas)
cc1IdStr := r.load("", withName("cc1"), withProjectID(appIDInt64), withWorkflowData(cc1Data))
data, _ = sonic.Marshal(mainCanvas)
mIdStr := r.load("", withName("main"), withProjectID(appIDInt64), withWorkflowData(data))
mId, err := strconv.ParseInt(mIdStr, 10, 64)
id, vs, err := appworkflow.SVC.MoveWorkflowFromAppToLibrary(ctx, mId, 123, appIDInt64)
assert.NoError(t, err)
assert.Equal(t, 0, len(vs))
assert.Equal(t, id, old2newID[mId])
_, err = getCanvas(ctx, mIdStr)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "record not found")
_, err = getCanvas(ctx, c1IdStr)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "record not found")
_, err = getCanvas(ctx, c2IdStr)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "record not found")
mIdInt64, _ := strconv.ParseInt(mIdStr, 10, 64)
newMainID := old2newID[mIdInt64]
schemaJson, err := getCanvas(ctx, strconv.FormatInt(newMainID, 10))
assert.NoError(t, err)
c1IDInt64, _ := strconv.ParseInt(c1IdStr, 10, 64)
c2IDInt64, _ := strconv.ParseInt(c2IdStr, 10, 64)
newC1ID := old2newID[c1IDInt64]
newC2ID := old2newID[c2IDInt64]
newSubWorkflowID := map[string]bool{
strconv.FormatInt(newC1ID, 10): true,
strconv.FormatInt(newC2ID, 10): true,
}
newMainCanvas := &vo.Canvas{}
err = sonic.UnmarshalString(schemaJson, newMainCanvas)
assert.NoError(t, err)
for _, node := range newMainCanvas.Nodes {
if node.Type == entity.NodeTypeSubWorkflow.IDStr() {
assert.True(t, newSubWorkflowID[node.Data.Inputs.WorkflowID])
assert.Equal(t, "v0.0.1", node.Data.Inputs.WorkflowVersion)
}
}
schemaJson, err = getCanvas(ctx, cc1IdStr)
assert.NoError(t, err)
cc1Canvas = &vo.Canvas{}
err = sonic.UnmarshalString(schemaJson, cc1Canvas)
assert.NoError(t, err)
for _, node := range cc1Canvas.Nodes {
if node.Type == entity.NodeTypeSubWorkflow.IDStr() {
assert.True(t, newSubWorkflowID[node.Data.Inputs.WorkflowID])
assert.Equal(t, "v0.0.1", node.Data.Inputs.WorkflowVersion)
}
}
})
})
}
func TestDuplicateWorkflowsByAppID(t *testing.T) {
mockey.PatchConvey("test duplicate work", t, func() {
r := newWfTestRunner(t)
r.publishPatcher.UnPatch()
defer r.closeFn()
vars := map[string]*vo.TypeInfo{
"app_v1": {
Type: vo.DataTypeString,
},
"app_list_v1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"app_list_v2": {
Type: vo.DataTypeString,
},
}
r.varGetter.EXPECT().GetAppVariablesMeta(gomock.Any(), gomock.Any(), gomock.Any()).Return(vars, nil).AnyTimes()
var copiedIDs = make([]int64, 0)
var mockPublishWorkflowResource func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error
var ignoreIDs = map[int64]bool{
7515027325977624576: true,
7515027249628708864: true,
7515027182796668928: true,
7515027150387281920: true,
7515027091302121472: true,
7515027325977624579: true,
}
mockPublishWorkflowResource = func(ctx context.Context, workflowID int64, mode *int32, op search.OpType, r *search.ResourceDocument) error {
if ignoreIDs[workflowID] {
return nil
}
copiedIDs = append(copiedIDs, workflowID)
return nil
}
defer mockey.Mock(appworkflow.PublishWorkflowResource).To(mockPublishWorkflowResource).Build().UnPatch()
appIDInt64 := int64(7513788954458456064)
r.load("copy_to_app/child_5.json", withID(7515027325977624579), withProjectID(appIDInt64))
r.load("copy_to_app/child_4.json", withID(7515027325977624576), withProjectID(appIDInt64))
r.load("copy_to_app/child_3.json", withID(7515027249628708864), withProjectID(appIDInt64))
r.load("copy_to_app/child_2.json", withID(7515027182796668928), withProjectID(appIDInt64))
r.load("copy_to_app/child_1.json", withID(7515027150387281920), withProjectID(appIDInt64))
r.load("copy_to_app/main.json", withID(7515027091302121472), withProjectID(appIDInt64))
targetAppID := int64(7513788954458456066)
err := appworkflow.SVC.DuplicateWorkflowsByAppID(t.Context(), appIDInt64, targetAppID, appworkflow.ExternalResource{})
assert.NoError(t, err)
copiedIDMap := slices.ToMap(copiedIDs, func(e int64) (string, bool) {
return strconv.FormatInt(e, 10), true
})
var validateSubWorkflowIDs func(nodes []*vo.Node)
validateSubWorkflowIDs = func(nodes []*vo.Node) {
for _, node := range nodes {
if node.Type == entity.NodeTypeSubWorkflow.IDStr() {
assert.True(t, copiedIDMap[node.Data.Inputs.WorkflowID])
}
if node.Type == entity.NodeTypeLLM.IDStr() {
if node.Data.Inputs.FCParam != nil && node.Data.Inputs.FCParam.WorkflowFCParam != nil {
for _, w := range node.Data.Inputs.FCParam.WorkflowFCParam.WorkflowList {
assert.True(t, copiedIDMap[w.WorkflowID])
}
}
}
}
}
for id := range copiedIDMap {
schemaString, err := getCanvas(t.Context(), id)
assert.NoError(t, err)
cs := &vo.Canvas{}
err = sonic.UnmarshalString(schemaString, cs)
assert.NoError(t, err)
validateSubWorkflowIDs(cs.Nodes)
}
})
}
func TestMismatchedTypeConvert(t *testing.T) {
mockey.PatchConvey("test mismatched type convert", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
chatModel := &testutil.UTChatModel{
StreamResultProvider: func(_ int, in []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
sr := schema.StreamReaderFromArray([]*schema.Message{
{
Role: schema.Assistant,
Content: "I ",
},
{
Role: schema.Assistant,
Content: "don't know.",
},
})
return sr, nil
},
}
r.modelManage.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).AnyTimes()
id := r.load("type_convert/mismatched_types.json")
exeID := r.testRun(id, map[string]string{
"input": "what's the meaning of life",
"arr_str": `[
"{\"a\":1}"
]`,
"bool_a": "True",
"int_a": "2",
"num_a": "3.5",
"obj": `{"b":true}`,
"obj_str": `{"s":[2,false]}`,
})
e := r.getProcess(id, exeID)
e.assertSuccess()
assert.Equal(t, "false [] {\"s\":[2,false]} [{\"a\":1}] 3 0 {\"b\":true}\nI don't know. {\"a\":1} false", e.output)
})
}
func TestJsonSerializationDeserialization(t *testing.T) {
mockey.PatchConvey("test JSON serialization and deserialization workflow", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
idStr := r.load("json/json_test.json")
mockey.PatchConvey("no type conversion", func() {
testInput := map[string]string{
"person": `{"int":123,"string":"hello","bool":true}`,
}
exeID := r.testRun(idStr, testInput)
e := r.getProcess(idStr, exeID)
output := e.output
t.Logf("JSON deserialization result (no conversion): %s", output)
var result map[string]any
err := sonic.Unmarshal([]byte(output), &result)
assert.NoError(t, err, "Failed to unmarshal output JSON")
outputData, ok := result["output"].(map[string]any)
assert.True(t, ok, "output field is not a map[string]any")
assert.Equal(t, int64(123), outputData["int"], "int field mismatch")
assert.Equal(t, "hello", outputData["string"], "string field mismatch")
assert.Equal(t, true, outputData["bool"], "bool field mismatch")
})
mockey.PatchConvey("legal type conversion", func() {
testInput := map[string]string{
"person": `{"int":"123","string":456,"bool":"true"}`,
}
exeID := r.testRun(idStr, testInput)
e := r.getProcess(idStr, exeID)
output := e.output
t.Logf("JSON deserialization result (legal conversion): %s", output)
var result map[string]any
err := sonic.Unmarshal([]byte(output), &result)
assert.NoError(t, err, "Failed to unmarshal output JSON")
outputData, ok := result["output"].(map[string]any)
assert.True(t, ok, "output field is not a map[string]any")
assert.Equal(t, int64(123), outputData["int"], "int field mismatch")
assert.Equal(t, "456", outputData["string"], "string field mismatch")
assert.Equal(t, true, outputData["bool"], "bool field mismatch")
})
})
}
func TestJsonSerializationDeserializationWithWarning(t *testing.T) {
mockey.PatchConvey("test JSON serialization and deserialization with warning", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
idStr := r.load("json/json_test_warning.json")
testInput := map[string]string{
"person": `{"int":1,"string":"abc","bool":true}`,
}
exeID := r.testRun(idStr, testInput)
e := r.getProcess(idStr, exeID)
output := e.output
t.Logf("JSON deserialization result (legal conversion): %s", output)
var result map[string]any
err := sonic.Unmarshal([]byte(output), &result)
assert.NoError(t, err, "Failed to unmarshal output JSON")
outputData, ok := result["output"].(map[string]any)
assert.True(t, ok, "output field is not a map[string]any")
assert.Equal(t, nil, outputData["int"], "int field mismatch")
assert.Equal(t, "abc", outputData["string"], "string field mismatch")
assert.Equal(t, true, outputData["bool"], "bool field mismatch")
})
}
func TestSetAppVariablesForSubProcesses(t *testing.T) {
mockey.PatchConvey("app variables for sub_process", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
r.appVarS.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return("1.0", nil).AnyTimes()
idStr := r.load("app_variables_for_sub_process.json")
r.publish(idStr, "v0.0.1", true)
result, _ := r.openapiSyncRun(idStr, map[string]any{
"input": "ax",
})
assert.Equal(t, result, map[string]any{
"output": "ax",
})
})
}
func TestHttpImplicitDependencies(t *testing.T) {
mockey.PatchConvey("test http implicit dependencies", t, func() {
r := newWfTestRunner(t)
defer r.closeFn()
r.appVarS.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return("1.0", nil).AnyTimes()
idStr := r.load("httprequester/http_implicit_dependencies.json")
r.publish(idStr, "v0.0.1", true)
runner := mockcode.NewMockRunner(r.ctrl)
runner.EXPECT().Run(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, request *coderunner.RunRequest) (*coderunner.RunResponse, error) {
in := request.Params["input"]
_ = in
result := make(map[string]any)
err := sonic.UnmarshalString(in.(string), &result)
if err != nil {
return nil, err
}
return &coderunner.RunResponse{
Result: result,
}, nil
}).AnyTimes()
code.SetCodeRunner(runner)
mockey.PatchConvey("test http node implicit dependencies", func() {
input := map[string]string{
"input": "a",
}
result, _ := r.openapiSyncRun(idStr, input)
batchRets := result["batch"].([]any)
loopRets := result["loop"].([]any)
for _, r := range batchRets {
assert.Contains(t, []any{
"http://echo.apifox.com/anything?aa=1.0&cc=1",
"http://echo.apifox.com/anything?aa=1.0&cc=2",
}, r)
}
for _, r := range loopRets {
assert.Contains(t, []any{
"http://echo.apifox.com/anything?a=1&m=123",
"http://echo.apifox.com/anything?a=2&m=123",
}, r)
}
})
mockey.PatchConvey("node debug http node implicit dependencies", func() {
exeID := r.nodeDebug(idStr, "109387",
withNDInput(map[string]string{
"__apiInfo_url_87fc7c69536cae843fa7f5113cf0067b": "m",
"__apiInfo_url_ac86361e3cd503952e71986dc091fa6f": "a",
"__body_bodyData_json_ac86361e3cd503952e71986dc091fa6f": "b",
"__body_bodyData_json_f77817a7cf8441279e1cfd8af4eeb1da": "1",
}))
e := r.getProcess(idStr, exeID, withSpecificNodeID("109387"))
e.assertSuccess()
ret := make(map[string]any)
err := sonic.UnmarshalString(e.output, &ret)
assert.Nil(t, err)
err = sonic.UnmarshalString(ret["body"].(string), &ret)
assert.Nil(t, err)
assert.Equal(t, ret["url"].(string), "http://echo.apifox.com/anything?a=a&m=m")
})
})
}