feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
/*
* 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 test
import (
"context"
"fmt"
"testing"
"github.com/cloudwego/eino/compose"
"github.com/stretchr/testify/assert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/batch"
)
func TestBatch(t *testing.T) {
ctx := context.Background()
lambda1 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
if in["index"].(int64) > 2 {
return nil, fmt.Errorf("index= %d is too large", in["index"].(int64))
}
out = make(map[string]any)
out["output_1"] = fmt.Sprintf("%s_%v_%d", in["array_1"].(string), in["from_parent_wf"].(bool), in["index"].(int64))
return out, nil
}
lambda2 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
return map[string]any{"index": in["index"]}, nil
}
lambda3 := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
t.Log(in["consumer_1"].(string), in["array_2"].(int64), in["static_source"].(string))
return in, nil
}
lambdaNode1 := &compose2.NodeSchema{
Key: "lambda",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda1),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"index"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"index"},
},
},
},
{
Path: compose.FieldPath{"array_1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"array_1"},
},
},
},
{
Path: compose.FieldPath{"from_parent_wf"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "parent_predecessor_1",
FromPath: compose.FieldPath{"success"},
},
},
},
},
}
lambdaNode2 := &compose2.NodeSchema{
Key: "index",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda2),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"index"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"index"},
},
},
},
},
}
lambdaNode3 := &compose2.NodeSchema{
Key: "consumer",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda3),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"consumer_1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "lambda",
FromPath: compose.FieldPath{"output_1"},
},
},
},
{
Path: compose.FieldPath{"array_2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"array_2"},
},
},
},
{
Path: compose.FieldPath{"static_source"},
Source: vo.FieldSource{
Val: "this is a const",
},
},
},
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
ns := &compose2.NodeSchema{
Key: "batch_node_key",
Type: entity.NodeTypeBatch,
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"array_1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"array_1"},
},
},
},
{
Path: compose.FieldPath{"array_2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"array_2"},
},
},
},
{
Path: compose.FieldPath{batch.ConcurrentSizeKey},
Source: vo.FieldSource{
Val: int64(2),
},
},
{
Path: compose.FieldPath{batch.MaxBatchSizeKey},
Source: vo.FieldSource{
Val: int64(5),
},
},
},
InputTypes: map[string]*vo.TypeInfo{
"array_1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeString,
},
},
"array_2": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{
Type: vo.DataTypeInteger,
},
},
},
OutputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"assembled_output_1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "lambda",
FromPath: compose.FieldPath{"output_1"},
},
},
},
{
Path: compose.FieldPath{"assembled_output_2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "index",
FromPath: compose.FieldPath{"index"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"assembled_output_1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"assembled_output_1"},
},
},
},
{
Path: compose.FieldPath{"assembled_output_2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "batch_node_key",
FromPath: compose.FieldPath{"assembled_output_2"},
},
},
},
},
}
parentLambda := func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
return map[string]any{"success": true}, nil
}
parentLambdaNode := &compose2.NodeSchema{
Key: "parent_predecessor_1",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(parentLambda),
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
parentLambdaNode,
ns,
exit,
lambdaNode1,
lambdaNode2,
lambdaNode3,
},
Hierarchy: map[vo.NodeKey]vo.NodeKey{
"lambda": "batch_node_key",
"index": "batch_node_key",
"consumer": "batch_node_key",
},
Connections: []*compose2.Connection{
{
FromNode: entity.EntryNodeKey,
ToNode: "parent_predecessor_1",
},
{
FromNode: "parent_predecessor_1",
ToNode: "batch_node_key",
},
{
FromNode: "batch_node_key",
ToNode: "lambda",
},
{
FromNode: "lambda",
ToNode: "index",
},
{
FromNode: "lambda",
ToNode: "consumer",
},
{
FromNode: "index",
ToNode: "batch_node_key",
},
{
FromNode: "consumer",
ToNode: "batch_node_key",
},
{
FromNode: "batch_node_key",
ToNode: entity.ExitNodeKey,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(ctx, map[string]any{
"array_1": []any{"a", "b", "c"},
"array_2": []any{int64(1), int64(2), int64(3), int64(4)},
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"assembled_output_1": []any{"a_true_0", "b_true_1", "c_true_2"},
"assembled_output_2": []any{int64(0), int64(1), int64(2)},
}, out)
// input array is empty
out, err = wf.Runner.Invoke(ctx, map[string]any{
"array_1": []any{},
"array_2": []any{int64(1)},
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"assembled_output_1": []any{},
"assembled_output_2": []any{},
}, out)
// less than concurrency
out, err = wf.Runner.Invoke(ctx, map[string]any{
"array_1": []any{"a"},
"array_2": []any{int64(1), int64(2)},
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"assembled_output_1": []any{"a_true_0"},
"assembled_output_2": []any{int64(0)},
}, out)
// err by inner node
_, err = wf.Runner.Invoke(ctx, map[string]any{
"array_1": []any{"a", "b", "c", "d", "e", "f"},
"array_2": []any{int64(1), int64(2), int64(3), int64(4), int64(5), int64(6), int64(7)},
})
assert.ErrorContains(t, err, "is too large")
}

View File

@@ -0,0 +1,679 @@
/*
* 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 test
import (
"context"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/bytedance/mockey"
"github.com/cloudwego/eino-ext/components/model/deepseek"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/callbacks"
model2 "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"github.com/coze-dev/coze-studio/backend/api/model/crossdomain/modelmgr"
"github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model"
mockmodel "github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model/modelmock"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/llm"
"github.com/coze-dev/coze-studio/backend/internal/testutil"
"github.com/coze-dev/coze-studio/backend/pkg/ctxcache"
)
func TestLLM(t *testing.T) {
mockey.PatchConvey("test llm", t, func() {
accessKey := os.Getenv("OPENAI_API_KEY")
baseURL := os.Getenv("OPENAI_BASE_URL")
modelName := os.Getenv("OPENAI_MODEL_NAME")
var (
openaiModel, deepSeekModel model2.BaseChatModel
err error
)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModelManager := mockmodel.NewMockManager(ctrl)
mockey.Mock(model.GetManager).Return(mockModelManager).Build()
if len(accessKey) > 0 && len(baseURL) > 0 && len(modelName) > 0 {
openaiModel, err = openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
APIKey: accessKey,
ByAzure: true,
BaseURL: baseURL,
Model: modelName,
})
assert.NoError(t, err)
}
deepSeekModelName := os.Getenv("DEEPSEEK_MODEL_NAME")
if len(accessKey) > 0 && len(baseURL) > 0 && len(deepSeekModelName) > 0 {
deepSeekModel, err = deepseek.NewChatModel(context.Background(), &deepseek.ChatModelConfig{
APIKey: accessKey,
BaseURL: baseURL,
Model: deepSeekModelName,
})
assert.NoError(t, err)
}
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, params *model.LLMParams) (model2.BaseChatModel, *modelmgr.Model, error) {
if params.ModelName == modelName {
return openaiModel, nil, nil
} else if params.ModelName == deepSeekModelName {
return deepSeekModel, nil, nil
} else {
return nil, nil, fmt.Errorf("invalid model name: %s", params.ModelName)
}
}).AnyTimes()
ctx := ctxcache.Init(context.Background())
t.Run("plain text output, non-streaming mode", func(t *testing.T) {
if openaiModel == nil {
defer func() {
openaiModel = nil
}()
openaiModel = &testutil.UTChatModel{
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: "I don't know",
}, nil
},
}
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
llmNode := &compose2.NodeSchema{
Key: "llm_node_key",
Type: entity.NodeTypeLLM,
Configs: map[string]any{
"SystemPrompt": "{{sys_prompt}}",
"UserPrompt": "{{query}}",
"OutputFormat": llm.FormatText,
"LLMParams": &model.LLMParams{
ModelName: modelName,
},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"sys_prompt"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"sys_prompt"},
},
},
},
{
Path: compose.FieldPath{"query"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"query"},
},
},
},
},
InputTypes: map[string]*vo.TypeInfo{
"sys_prompt": {
Type: vo.DataTypeString,
},
"query": {
Type: vo.DataTypeString,
},
},
OutputTypes: map[string]*vo.TypeInfo{
"output": {
Type: vo.DataTypeString,
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: llmNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
llmNode,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: llmNode.Key,
},
{
FromNode: llmNode.Key,
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(ctx, map[string]any{
"sys_prompt": "you are a helpful assistant",
"query": "what's your name",
})
assert.NoError(t, err)
assert.Greater(t, len(out), 0)
assert.Greater(t, len(out["output"].(string)), 0)
})
t.Run("json output", func(t *testing.T) {
if openaiModel == nil {
defer func() {
openaiModel = nil
}()
openaiModel = &testutil.UTChatModel{
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: `{"country_name": "Russia", "area_size": 17075400}`,
}, nil
},
}
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
llmNode := &compose2.NodeSchema{
Key: "llm_node_key",
Type: entity.NodeTypeLLM,
Configs: map[string]any{
"SystemPrompt": "you are a helpful assistant",
"UserPrompt": "what's the largest country in the world and it's area size in square kilometers?",
"OutputFormat": llm.FormatJSON,
"IgnoreException": true,
"DefaultOutput": map[string]any{
"country_name": "unknown",
"area_size": int64(0),
},
"LLMParams": &model.LLMParams{
ModelName: modelName,
},
},
OutputTypes: map[string]*vo.TypeInfo{
"country_name": {
Type: vo.DataTypeString,
Required: true,
},
"area_size": {
Type: vo.DataTypeInteger,
Required: true,
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"country_name"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: llmNode.Key,
FromPath: compose.FieldPath{"country_name"},
},
},
},
{
Path: compose.FieldPath{"area_size"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: llmNode.Key,
FromPath: compose.FieldPath{"area_size"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
llmNode,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: llmNode.Key,
},
{
FromNode: llmNode.Key,
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(ctx, map[string]any{})
assert.NoError(t, err)
assert.Equal(t, out["country_name"], "Russia")
assert.Greater(t, out["area_size"], int64(1000))
})
t.Run("markdown output", func(t *testing.T) {
if openaiModel == nil {
defer func() {
openaiModel = nil
}()
openaiModel = &testutil.UTChatModel{
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: `#Top 5 Largest Countries in the World ## 1. Russia 2. Canada 3. United States 4. Brazil 5. Japan`,
}, nil
},
}
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
llmNode := &compose2.NodeSchema{
Key: "llm_node_key",
Type: entity.NodeTypeLLM,
Configs: map[string]any{
"SystemPrompt": "you are a helpful assistant",
"UserPrompt": "list the top 5 largest countries in the world",
"OutputFormat": llm.FormatMarkdown,
"LLMParams": &model.LLMParams{
ModelName: modelName,
},
},
OutputTypes: map[string]*vo.TypeInfo{
"output": {
Type: vo.DataTypeString,
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: llmNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
llmNode,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: llmNode.Key,
},
{
FromNode: llmNode.Key,
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(ctx, map[string]any{})
assert.NoError(t, err)
assert.Greater(t, len(out["output"].(string)), 0)
})
t.Run("plain text output, streaming mode", func(t *testing.T) {
// start -> fan out to openai LLM and deepseek LLM -> fan in to output emitter -> end
if openaiModel == nil || deepSeekModel == nil {
if openaiModel == nil {
defer func() {
openaiModel = nil
}()
openaiModel = &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
},
}
}
if deepSeekModel == nil {
defer func() {
deepSeekModel = nil
}()
deepSeekModel = &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 too.",
},
})
return sr, nil
},
}
}
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
openaiNode := &compose2.NodeSchema{
Key: "openai_llm_node_key",
Type: entity.NodeTypeLLM,
Configs: map[string]any{
"SystemPrompt": "you are a helpful assistant",
"UserPrompt": "plan a 10 day family visit to China.",
"OutputFormat": llm.FormatText,
"LLMParams": &model.LLMParams{
ModelName: modelName,
},
},
OutputTypes: map[string]*vo.TypeInfo{
"output": {
Type: vo.DataTypeString,
},
},
}
deepseekNode := &compose2.NodeSchema{
Key: "deepseek_llm_node_key",
Type: entity.NodeTypeLLM,
Configs: map[string]any{
"SystemPrompt": "you are a helpful assistant",
"UserPrompt": "thoroughly plan a 10 day family visit to China. Use your reasoning ability.",
"OutputFormat": llm.FormatText,
"LLMParams": &model.LLMParams{
ModelName: modelName,
},
},
OutputTypes: map[string]*vo.TypeInfo{
"output": {
Type: vo.DataTypeString,
},
"reasoning_content": {
Type: vo.DataTypeString,
},
},
}
emitterNode := &compose2.NodeSchema{
Key: "emitter_node_key",
Type: entity.NodeTypeOutputEmitter,
Configs: map[string]any{
"Template": "prefix {{inputObj.field1}} {{input2}} {{deepseek_reasoning}} \n\n###\n\n {{openai_output}} \n\n###\n\n {{deepseek_output}} {{inputObj.field2}} suffix",
"Mode": nodes.Streaming,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"openai_output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: openaiNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
{
Path: compose.FieldPath{"deepseek_output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: deepseekNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
{
Path: compose.FieldPath{"deepseek_reasoning"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: deepseekNode.Key,
FromPath: compose.FieldPath{"reasoning_content"},
},
},
},
{
Path: compose.FieldPath{"inputObj"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"inputObj"},
},
},
},
{
Path: compose.FieldPath{"input2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"input2"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.UseAnswerContent,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"openai_output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: openaiNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
{
Path: compose.FieldPath{"deepseek_output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: deepseekNode.Key,
FromPath: compose.FieldPath{"output"},
},
},
},
{
Path: compose.FieldPath{"deepseek_reasoning"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: deepseekNode.Key,
FromPath: compose.FieldPath{"reasoning_content"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
openaiNode,
deepseekNode,
emitterNode,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: openaiNode.Key,
},
{
FromNode: openaiNode.Key,
ToNode: emitterNode.Key,
},
{
FromNode: entry.Key,
ToNode: deepseekNode.Key,
},
{
FromNode: deepseekNode.Key,
ToNode: emitterNode.Key,
},
{
FromNode: emitterNode.Key,
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(ctx, ws)
if err != nil {
t.Fatal(err)
}
var fullOutput string
cbHandler := callbacks.NewHandlerBuilder().OnEndWithStreamOutputFn(
func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
defer output.Close()
for {
chunk, e := output.Recv()
if e != nil {
if e == io.EOF {
break
}
assert.NoError(t, e)
}
s, ok := chunk.(map[string]any)
assert.True(t, ok)
out := s["output"].(string)
if out != nodes.KeyIsFinished {
fmt.Print(s["output"])
fullOutput += s["output"].(string)
}
}
return ctx
}).Build()
outStream, err := wf.Runner.Stream(ctx, map[string]any{
"inputObj": map[string]any{
"field1": "field1",
"field2": 1.1,
},
"input2": 23.5,
}, compose.WithCallbacks(cbHandler).DesignateNode(string(emitterNode.Key)))
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(fullOutput, "prefix field1 23.5"))
assert.True(t, strings.HasSuffix(fullOutput, "1.1 suffix"))
outStream.Close()
})
})
}

View File

@@ -0,0 +1,495 @@
/*
* 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 test
import (
"context"
"testing"
"github.com/cloudwego/eino/compose"
"github.com/stretchr/testify/assert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/loop"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/variableassigner"
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
)
func TestLoop(t *testing.T) {
t.Run("by iteration", func(t *testing.T) {
// start-> loop_node_key[innerNode->continue] -> end
innerNode := &compose2.NodeSchema{
Key: "innerNode",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
index := in["index"].(int64)
return map[string]any{"output": index}, nil
}),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"index"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"index"},
},
},
},
},
}
continueNode := &compose2.NodeSchema{
Key: "continueNode",
Type: entity.NodeTypeContinue,
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
loopNode := &compose2.NodeSchema{
Key: "loop_node_key",
Type: entity.NodeTypeLoop,
Configs: map[string]any{
"LoopType": loop.ByIteration,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{loop.Count},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"count"},
},
},
},
},
OutputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "innerNode",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
loopNode,
exit,
innerNode,
continueNode,
},
Hierarchy: map[vo.NodeKey]vo.NodeKey{
"innerNode": "loop_node_key",
"continueNode": "loop_node_key",
},
Connections: []*compose2.Connection{
{
FromNode: "loop_node_key",
ToNode: "innerNode",
},
{
FromNode: "innerNode",
ToNode: "continueNode",
},
{
FromNode: "continueNode",
ToNode: "loop_node_key",
},
{
FromNode: entry.Key,
ToNode: "loop_node_key",
},
{
FromNode: "loop_node_key",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
"count": int64(3),
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"output": []any{int64(0), int64(1), int64(2)},
}, out)
})
t.Run("infinite", func(t *testing.T) {
// start-> loop_node_key[innerNode->break] -> end
innerNode := &compose2.NodeSchema{
Key: "innerNode",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
index := in["index"].(int64)
return map[string]any{"output": index}, nil
}),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"index"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"index"},
},
},
},
},
}
breakNode := &compose2.NodeSchema{
Key: "breakNode",
Type: entity.NodeTypeBreak,
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
loopNode := &compose2.NodeSchema{
Key: "loop_node_key",
Type: entity.NodeTypeLoop,
Configs: map[string]any{
"LoopType": loop.Infinite,
},
OutputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "innerNode",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
loopNode,
exit,
innerNode,
breakNode,
},
Hierarchy: map[vo.NodeKey]vo.NodeKey{
"innerNode": "loop_node_key",
"breakNode": "loop_node_key",
},
Connections: []*compose2.Connection{
{
FromNode: "loop_node_key",
ToNode: "innerNode",
},
{
FromNode: "innerNode",
ToNode: "breakNode",
},
{
FromNode: "breakNode",
ToNode: "loop_node_key",
},
{
FromNode: entry.Key,
ToNode: "loop_node_key",
},
{
FromNode: "loop_node_key",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"output": []any{int64(0)},
}, out)
})
t.Run("by array", func(t *testing.T) {
// start-> loop_node_key[innerNode->variable_assign] -> end
innerNode := &compose2.NodeSchema{
Key: "innerNode",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
item1 := in["item1"].(string)
item2 := in["item2"].(string)
count := in["count"].(int)
return map[string]any{"total": int(count) + len(item1) + len(item2)}, nil
}),
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"item1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"items1"},
},
},
},
{
Path: compose.FieldPath{"item2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"items2"},
},
},
},
{
Path: compose.FieldPath{"count"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromPath: compose.FieldPath{"count"},
VariableType: ptr.Of(vo.ParentIntermediate),
},
},
},
},
}
assigner := &compose2.NodeSchema{
Key: "assigner",
Type: entity.NodeTypeVariableAssignerWithinLoop,
Configs: []*variableassigner.Pair{
{
Left: vo.Reference{
FromPath: compose.FieldPath{"count"},
VariableType: ptr.Of(vo.ParentIntermediate),
},
Right: compose.FieldPath{"total"},
},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"total"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "innerNode",
FromPath: compose.FieldPath{"total"},
},
},
},
},
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "loop_node_key",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
loopNode := &compose2.NodeSchema{
Key: "loop_node_key",
Type: entity.NodeTypeLoop,
Configs: map[string]any{
"LoopType": loop.ByArray,
"IntermediateVars": map[string]*vo.TypeInfo{
"count": {
Type: vo.DataTypeInteger,
},
},
},
InputTypes: map[string]*vo.TypeInfo{
"items1": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeString},
},
"items2": {
Type: vo.DataTypeArray,
ElemTypeInfo: &vo.TypeInfo{Type: vo.DataTypeString},
},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"items1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"items1"},
},
},
},
{
Path: compose.FieldPath{"items2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"items2"},
},
},
},
{
Path: compose.FieldPath{"count"},
Source: vo.FieldSource{
Val: 0,
},
},
},
OutputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromPath: compose.FieldPath{"count"},
VariableType: ptr.Of(vo.ParentIntermediate),
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
loopNode,
exit,
innerNode,
assigner,
},
Hierarchy: map[vo.NodeKey]vo.NodeKey{
"innerNode": "loop_node_key",
"assigner": "loop_node_key",
},
Connections: []*compose2.Connection{
{
FromNode: "loop_node_key",
ToNode: "innerNode",
},
{
FromNode: "innerNode",
ToNode: "assigner",
},
{
FromNode: "assigner",
ToNode: "loop_node_key",
},
{
FromNode: entry.Key,
ToNode: "loop_node_key",
},
{
FromNode: "loop_node_key",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
"items1": []any{"a", "b"},
"items2": []any{"a1", "b1", "c1"},
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"output": 6,
}, out)
})
}

View File

@@ -0,0 +1,673 @@
/*
* 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 test
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/bytedance/mockey"
"github.com/cloudwego/eino-ext/components/model/openai"
model2 "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/coze-dev/coze-studio/backend/domain/workflow"
"github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model"
mockmodel "github.com/coze-dev/coze-studio/backend/domain/workflow/crossdomain/model/modelmock"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/qa"
repo2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/repo"
"github.com/coze-dev/coze-studio/backend/infra/impl/checkpoint"
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/lang/ptr"
)
func TestQuestionAnswer(t *testing.T) {
mockey.PatchConvey("test qa", t, func() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModelManager := mockmodel.NewMockManager(ctrl)
mockey.Mock(model.GetManager).Return(mockModelManager).Build()
accessKey := os.Getenv("OPENAI_API_KEY")
baseURL := os.Getenv("OPENAI_BASE_URL")
modelName := os.Getenv("OPENAI_MODEL_NAME")
var (
chatModel model2.BaseChatModel
err error
)
if len(accessKey) > 0 && len(baseURL) > 0 && len(modelName) > 0 {
chatModel, err = openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
APIKey: accessKey,
ByAzure: true,
BaseURL: baseURL,
Model: modelName,
})
assert.NoError(t, err)
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, 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)
}
defer s.Close()
redisClient := redis.NewClient(&redis.Options{
Addr: s.Addr(),
})
mockIDGen := mock.NewMockIDGenerator(ctrl)
mockIDGen.EXPECT().GenID(gomock.Any()).Return(time.Now().UnixNano(), nil).AnyTimes()
mockTos := storageMock.NewMockStorage(ctrl)
mockTos.EXPECT().GetObjectUrl(gomock.Any(), gomock.Any(), gomock.Any()).Return("", nil).AnyTimes()
repo := repo2.NewRepository(mockIDGen, db, redisClient, mockTos,
checkpoint.NewRedisStore(redisClient))
mockey.Mock(workflow.GetRepository).Return(repo).Build()
t.Run("answer directly, no structured output", func(t *testing.T) {
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
}}
ns := &compose2.NodeSchema{
Key: "qa_node_key",
Type: entity.NodeTypeQuestionAnswer,
Configs: map[string]any{
"QuestionTpl": "{{input}}",
"AnswerType": qa.AnswerDirectly,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"input"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"query"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"answer"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.UserResponseKey},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "qa_node_key",
},
{
FromNode: "qa_node_key",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
"query": "what's your name?",
}, compose.WithCheckPointID(checkPointID))
assert.Error(t, err)
info, existed := compose.ExtractInterruptInfo(err)
assert.True(t, existed)
assert.Equal(t, "what's your name?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
answer := "my name is eino"
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
return nil
}
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"answer": answer,
}, out)
})
t.Run("answer with fixed choices", func(t *testing.T) {
if chatModel == nil {
oneChatModel := &testutil.UTChatModel{
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
return &schema.Message{
Role: schema.Assistant,
Content: "-1",
}, nil
},
}
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(oneChatModel, nil, nil).Times(1)
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
ns := &compose2.NodeSchema{
Key: "qa_node_key",
Type: entity.NodeTypeQuestionAnswer,
Configs: map[string]any{
"QuestionTpl": "{{input}}",
"AnswerType": qa.AnswerByChoices,
"ChoiceType": qa.FixedChoices,
"FixedChoices": []string{"{{choice1}}", "{{choice2}}"},
"LLMParams": &model.LLMParams{},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"input"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"query"},
},
},
},
{
Path: compose.FieldPath{"choice1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"choice1"},
},
},
},
{
Path: compose.FieldPath{"choice2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"choice2"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"option_id"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.OptionIDKey},
},
},
},
{
Path: compose.FieldPath{"option_content"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.OptionContentKey},
},
},
},
},
}
lambda := &compose2.NodeSchema{
Key: "lambda",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
return out, nil
}),
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
exit,
lambda,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "qa_node_key",
},
{
FromNode: "qa_node_key",
ToNode: exit.Key,
FromPort: ptr.Of("branch_0"),
},
{
FromNode: "qa_node_key",
ToNode: exit.Key,
FromPort: ptr.Of("branch_1"),
},
{
FromNode: "qa_node_key",
ToNode: "lambda",
FromPort: ptr.Of("default"),
},
{
FromNode: "lambda",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
"query": "what's would you make in Coze?",
"choice1": "make agent",
"choice2": "make workflow",
}, compose.WithCheckPointID(checkPointID))
assert.Error(t, err)
info, existed := compose.ExtractInterruptInfo(err)
assert.True(t, existed)
assert.Equal(t, "what's would you make in Coze?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
assert.Equal(t, "make agent", info.State.(*compose2.State).Questions[ns.Key][0].Choices[0])
assert.Equal(t, "make workflow", info.State.(*compose2.State).Questions[ns.Key][0].Choices[1])
chosenContent := "I would make all kinds of stuff"
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], chosenContent)
return nil
}
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"option_id": "other",
"option_content": chosenContent,
}, out)
})
t.Run("answer with dynamic choices", func(t *testing.T) {
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
ns := &compose2.NodeSchema{
Key: "qa_node_key",
Type: entity.NodeTypeQuestionAnswer,
Configs: map[string]any{
"QuestionTpl": "{{input}}",
"AnswerType": qa.AnswerByChoices,
"ChoiceType": qa.DynamicChoices,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"input"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"query"},
},
},
},
{
Path: compose.FieldPath{qa.DynamicChoicesKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"choices"},
},
},
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"option_id"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.OptionIDKey},
},
},
},
{
Path: compose.FieldPath{"option_content"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.OptionContentKey},
},
},
},
},
}
lambda := &compose2.NodeSchema{
Key: "lambda",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(func(ctx context.Context, in map[string]any) (out map[string]any, err error) {
return out, nil
}),
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
exit,
lambda,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "qa_node_key",
},
{
FromNode: "qa_node_key",
ToNode: exit.Key,
FromPort: ptr.Of("branch_0"),
},
{
FromNode: "lambda",
ToNode: exit.Key,
},
{
FromNode: "qa_node_key",
ToNode: "lambda",
FromPort: ptr.Of("default"),
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
_, err = wf.Runner.Invoke(context.Background(), map[string]any{
"query": "what's the capital city of China?",
"choices": []any{"beijing", "shanghai"},
}, compose.WithCheckPointID(checkPointID))
assert.Error(t, err)
info, existed := compose.ExtractInterruptInfo(err)
assert.True(t, existed)
assert.Equal(t, "what's the capital city of China?", info.State.(*compose2.State).Questions[ns.Key][0].Question)
assert.Equal(t, "beijing", info.State.(*compose2.State).Questions[ns.Key][0].Choices[0])
assert.Equal(t, "shanghai", info.State.(*compose2.State).Questions[ns.Key][0].Choices[1])
chosenContent := "beijing"
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], chosenContent)
return nil
}
out, err := wf.Runner.Invoke(context.Background(), nil, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"option_id": "A",
"option_content": chosenContent,
}, out)
})
t.Run("answer directly, extract structured output", func(t *testing.T) {
ctx := context.Background()
qaCount := 0
if chatModel == nil {
defer func() {
chatModel = nil
}()
chatModel = &testutil.UTChatModel{
InvokeResultProvider: func(_ int, in []*schema.Message) (*schema.Message, error) {
if qaCount == 1 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"question": "what's your age?"}`,
}, nil
} else if qaCount == 2 {
return &schema.Message{
Role: schema.Assistant,
Content: `{"fields": {"name": "eino", "age": 1}}`,
}, nil
}
return nil, errors.New("not found")
},
}
mockModelManager.EXPECT().GetModel(gomock.Any(), gomock.Any()).Return(chatModel, nil, nil).Times(1)
}
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
ns := &compose2.NodeSchema{
Key: "qa_node_key",
Type: entity.NodeTypeQuestionAnswer,
Configs: map[string]any{
"QuestionTpl": "{{input}}",
"AnswerType": qa.AnswerDirectly,
"ExtractFromAnswer": true,
"AdditionalSystemPromptTpl": "{{prompt}}",
"MaxAnswerCount": 2,
"LLMParams": &model.LLMParams{},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"input"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"query"},
},
},
},
{
Path: compose.FieldPath{"prompt"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"prompt"},
},
},
},
},
OutputTypes: map[string]*vo.TypeInfo{
"name": {
Type: vo.DataTypeString,
Required: true,
},
"age": {
Type: vo.DataTypeInteger,
Required: true,
},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"name"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{"name"},
},
},
},
{
Path: compose.FieldPath{"age"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{"age"},
},
},
},
{
Path: compose.FieldPath{qa.UserResponseKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "qa_node_key",
FromPath: compose.FieldPath{qa.UserResponseKey},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "qa_node_key",
},
{
FromNode: "qa_node_key",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
assert.NoError(t, err)
checkPointID := fmt.Sprintf("%d", time.Now().Nanosecond())
_, err = wf.Runner.Invoke(ctx, map[string]any{
"query": "what's your name?",
"prompt": "You are a helpful assistant.",
}, compose.WithCheckPointID(checkPointID))
assert.Error(t, err)
info, existed := compose.ExtractInterruptInfo(err)
assert.True(t, existed)
assert.Equal(t, "what's your name?", info.State.(*compose2.State).Questions["qa_node_key"][0].Question)
qaCount++
answer := "my name is eino"
stateModifier := func(ctx context.Context, path compose.NodePath, state any) error {
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
return nil
}
_, err = wf.Runner.Invoke(ctx, map[string]any{}, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
assert.Error(t, err)
info, existed = compose.ExtractInterruptInfo(err)
assert.True(t, existed)
qaCount++
answer = "my age is 1 years old"
stateModifier = func(ctx context.Context, path compose.NodePath, state any) error {
state.(*compose2.State).Answers[ns.Key] = append(state.(*compose2.State).Answers[ns.Key], answer)
return nil
}
out, err := wf.Runner.Invoke(ctx, map[string]any{}, compose.WithCheckPointID(checkPointID), compose.WithStateModifier(stateModifier))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
qa.UserResponseKey: answer,
"name": "eino",
"age": int64(1),
}, out)
})
})
}

View File

@@ -0,0 +1,634 @@
/*
* 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 test
import (
"context"
"testing"
"github.com/cloudwego/eino/compose"
"github.com/stretchr/testify/assert"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity"
"github.com/coze-dev/coze-studio/backend/domain/workflow/entity/vo"
compose2 "github.com/coze-dev/coze-studio/backend/domain/workflow/internal/compose"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/selector"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/textprocessor"
"github.com/coze-dev/coze-studio/backend/domain/workflow/internal/nodes/variableaggregator"
"github.com/coze-dev/coze-studio/backend/pkg/lang/ptr"
)
func TestAddSelector(t *testing.T) {
// start -> selector, selector.condition1 -> lambda1 -> end, selector.condition2 -> [lambda2, lambda3] -> end, selector default -> end
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
}}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "lambda1",
FromPath: compose.FieldPath{"lambda1"},
},
},
Path: compose.FieldPath{"lambda1"},
},
{
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "lambda2",
FromPath: compose.FieldPath{"lambda2"},
},
},
Path: compose.FieldPath{"lambda2"},
},
{
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "lambda3",
FromPath: compose.FieldPath{"lambda3"},
},
},
Path: compose.FieldPath{"lambda3"},
},
},
}
lambda1 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
return map[string]any{
"lambda1": "v1",
}, nil
}
lambdaNode1 := &compose2.NodeSchema{
Key: "lambda1",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda1),
}
lambda2 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
return map[string]any{
"lambda2": "v2",
}, nil
}
LambdaNode2 := &compose2.NodeSchema{
Key: "lambda2",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda2),
}
lambda3 := func(ctx context.Context, in map[string]any) (map[string]any, error) {
return map[string]any{
"lambda3": "v3",
}, nil
}
lambdaNode3 := &compose2.NodeSchema{
Key: "lambda3",
Type: entity.NodeTypeLambda,
Lambda: compose.InvokableLambda(lambda3),
}
ns := &compose2.NodeSchema{
Key: "selector",
Type: entity.NodeTypeSelector,
Configs: map[string]any{"Clauses": []*selector.OneClauseSchema{
{
Single: ptr.Of(selector.OperatorEqual),
},
{
Multi: &selector.MultiClauseSchema{
Clauses: []*selector.Operator{
ptr.Of(selector.OperatorGreater),
ptr.Of(selector.OperatorIsTrue),
},
Relation: selector.ClauseRelationAND,
},
},
}},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"0", selector.LeftKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"key1"},
},
},
},
{
Path: compose.FieldPath{"0", selector.RightKey},
Source: vo.FieldSource{
Val: "value1",
},
},
{
Path: compose.FieldPath{"1", "0", selector.LeftKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"key2"},
},
},
},
{
Path: compose.FieldPath{"1", "0", selector.RightKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"key3"},
},
},
},
{
Path: compose.FieldPath{"1", "1", selector.LeftKey},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"key4"},
},
},
},
},
InputTypes: map[string]*vo.TypeInfo{
"0": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
selector.LeftKey: {
Type: vo.DataTypeString,
},
selector.RightKey: {
Type: vo.DataTypeString,
},
},
},
"1": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"0": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
selector.LeftKey: {
Type: vo.DataTypeInteger,
},
selector.RightKey: {
Type: vo.DataTypeInteger,
},
},
},
"1": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
selector.LeftKey: {
Type: vo.DataTypeBoolean,
},
},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
lambdaNode1,
LambdaNode2,
lambdaNode3,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "selector",
},
{
FromNode: "selector",
ToNode: "lambda1",
FromPort: ptr.Of("branch_0"),
},
{
FromNode: "selector",
ToNode: "lambda2",
FromPort: ptr.Of("branch_1"),
},
{
FromNode: "selector",
ToNode: "lambda3",
FromPort: ptr.Of("branch_1"),
},
{
FromNode: "selector",
ToNode: exit.Key,
FromPort: ptr.Of("default"),
},
{
FromNode: "lambda1",
ToNode: exit.Key,
},
{
FromNode: "lambda2",
ToNode: exit.Key,
},
{
FromNode: "lambda3",
ToNode: exit.Key,
},
},
}
ws.Init()
ctx := context.Background()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(ctx, map[string]any{
"key1": "value1",
"key2": int64(2),
"key3": int64(3),
"key4": true,
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"lambda1": "v1",
}, out)
out, err = wf.Runner.Invoke(ctx, map[string]any{
"key1": "value2",
"key2": int64(3),
"key3": int64(2),
"key4": true,
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"lambda2": "v2",
"lambda3": "v3",
}, out)
out, err = wf.Runner.Invoke(ctx, map[string]any{
"key1": "value2",
"key2": int64(2),
"key3": int64(3),
"key4": true,
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{}, out)
}
func TestVariableAggregator(t *testing.T) {
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"Group1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "va",
FromPath: compose.FieldPath{"Group1"},
},
},
},
{
Path: compose.FieldPath{"Group2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "va",
FromPath: compose.FieldPath{"Group2"},
},
},
},
},
}
ns := &compose2.NodeSchema{
Key: "va",
Type: entity.NodeTypeVariableAggregator,
Configs: map[string]any{
"MergeStrategy": variableaggregator.FirstNotNullValue,
"GroupToLen": map[string]int{
"Group1": 1,
"Group2": 1,
},
"GroupOrder": []string{
"Group1",
"Group2",
},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"Group1", "0"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Str1"},
},
},
},
{
Path: compose.FieldPath{"Group2", "0"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Int1"},
},
},
},
},
InputTypes: map[string]*vo.TypeInfo{
"Group1": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"0": {
Type: vo.DataTypeString,
},
},
},
"Group2": {
Type: vo.DataTypeObject,
Properties: map[string]*vo.TypeInfo{
"0": {
Type: vo.DataTypeInteger,
},
},
},
},
OutputTypes: map[string]*vo.TypeInfo{
"Group1": {
Type: vo.DataTypeString,
},
"Group2": {
Type: vo.DataTypeInteger,
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
entry,
ns,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "va",
},
{
FromNode: "va",
ToNode: exit.Key,
},
},
}
ws.Init()
ctx := context.Background()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
"Str1": "str_v1",
"Int1": int64(1),
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"Group1": "str_v1",
"Group2": int64(1),
}, out)
out, err = wf.Runner.Invoke(context.Background(), map[string]any{
"Str1": "str_v1",
"Int1": nil,
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"Group1": "str_v1",
"Group2": nil,
}, out)
}
func TestTextProcessor(t *testing.T) {
t.Run("split", func(t *testing.T) {
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "tp",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ns := &compose2.NodeSchema{
Key: "tp",
Type: entity.NodeTypeTextProcessor,
Configs: map[string]any{
"Type": textprocessor.SplitText,
"Separators": []string{"|"},
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"String"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Str"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
ns,
entry,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "tp",
},
{
FromNode: "tp",
ToNode: exit.Key,
},
},
}
ws.Init()
wf, err := compose2.NewWorkflow(context.Background(), ws)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
"Str": "a|b|c",
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"output": []any{"a", "b", "c"},
}, out)
})
t.Run("concat", func(t *testing.T) {
entry := &compose2.NodeSchema{
Key: entity.EntryNodeKey,
Type: entity.NodeTypeEntry,
Configs: map[string]any{
"DefaultValues": map[string]any{},
},
}
exit := &compose2.NodeSchema{
Key: entity.ExitNodeKey,
Type: entity.NodeTypeExit,
Configs: map[string]any{
"TerminalPlan": vo.ReturnVariables,
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"output"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: "tp",
FromPath: compose.FieldPath{"output"},
},
},
},
},
}
ns := &compose2.NodeSchema{
Key: "tp",
Type: entity.NodeTypeTextProcessor,
Configs: map[string]any{
"Type": textprocessor.ConcatText,
"Tpl": "{{String1}}_{{String2.f1}}_{{String3.f2[1]}}",
"ConcatChar": "\t",
},
InputSources: []*vo.FieldInfo{
{
Path: compose.FieldPath{"String1"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Str1"},
},
},
},
{
Path: compose.FieldPath{"String2"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Str2"},
},
},
},
{
Path: compose.FieldPath{"String3"},
Source: vo.FieldSource{
Ref: &vo.Reference{
FromNodeKey: entry.Key,
FromPath: compose.FieldPath{"Str3"},
},
},
},
},
}
ws := &compose2.WorkflowSchema{
Nodes: []*compose2.NodeSchema{
ns,
entry,
exit,
},
Connections: []*compose2.Connection{
{
FromNode: entry.Key,
ToNode: "tp",
},
{
FromNode: "tp",
ToNode: exit.Key,
},
},
}
ws.Init()
ctx := context.Background()
wf, err := compose2.NewWorkflow(ctx, ws)
assert.NoError(t, err)
out, err := wf.Runner.Invoke(context.Background(), map[string]any{
"Str1": true,
"Str2": map[string]any{
"f1": 1.0,
},
"Str3": map[string]any{
"f2": []any{1, "a"},
},
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"output": "true_1_a",
}, out)
})
}