xyflow change to flowgraam

This commit is contained in:
mlogclub
2026-06-27 21:27:57 +08:00
parent 228ad1902f
commit 689acc997c
32 changed files with 3154 additions and 5004 deletions
+124 -295
View File
@@ -10,7 +10,7 @@ import (
"agent-desk/internal/ai/workflow/validator"
)
func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
func TestValidateDefinitionAcceptsMinimalFlowGramStyleFlow(t *testing.T) {
result := validator.ValidateDefinition(minimalDefinition(), registry.DefaultRegistry())
if !result.Valid {
@@ -21,8 +21,8 @@ func TestValidateDefinitionAcceptsMinimalConversationFlow(t *testing.T) {
func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`)},
{ID: "end_1", Type: "end"},
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), nil),
node("end_1", "end", nil, nil),
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -35,148 +35,9 @@ func TestValidateDefinitionRejectsMissingStart(t *testing.T) {
}
}
func TestValidateDefinitionRejectsUnknownNodeType(t *testing.T) {
func TestValidateDefinitionRejectsMissingRequiredInputValue(t *testing.T) {
def := minimalDefinition()
def.Nodes = append(def.Nodes, dsl.Node{ID: "unknown_1", Type: "unknown_node"})
def.Edges = append(def.Edges, dsl.Edge{ID: "e3", Source: "reply_1", Target: "unknown_1"})
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown node type to be invalid")
}
if !hasValidationMessage(result, "unknown node type") {
t.Fatalf("expected unknown-node error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnguardedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "draft_1", Type: "prepare_ticket_draft"},
{ID: "create_1", Type: "create_ticket"},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "draft_1"},
{ID: "e2", Source: "draft_1", Target: "create_1"},
{ID: "e3", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unguarded create_ticket to be invalid")
}
if !hasValidationMessage(result, "requires human_confirm") {
t.Fatalf("expected confirmation guard error, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
"confirmed": {NodeID: "confirm_1", Field: "confirmed"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "draft_1"},
{ID: "e2", Source: "draft_1", Target: "confirm_1"},
{ID: "e3", Source: "confirm_1", Target: "create_1"},
{ID: "e4", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionAcceptsDirectHandoffToHuman(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "handoff_1", Type: "handoff_to_human", Inputs: map[string]dsl.VariableSelector{
"reason": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "handoff_1"},
{ID: "e2", Source: "handoff_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected direct handoff_to_human to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "analysis_1", Type: "analyze_conversation", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "draft_1", Type: "prepare_ticket_draft", Inputs: map[string]dsl.VariableSelector{
"issue": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "confirm_1", Type: "human_confirm", Inputs: map[string]dsl.VariableSelector{
"prompt": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "create_1", Type: "create_ticket", Inputs: map[string]dsl.VariableSelector{
"ticketDraft": {NodeID: "draft_1", Field: "ticketDraft"},
"confirmed": {NodeID: "analysis_1", Field: "needTicket"},
}},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "analysis_1"},
{ID: "e2", Source: "analysis_1", Target: "draft_1"},
{ID: "e3", Source: "draft_1", Target: "confirm_1"},
{ID: "e4", Source: "confirm_1", Target: "create_1"},
{ID: "e5", Source: "create_1", Target: "end_1"},
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
}
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
def := minimalDefinition()
def.Nodes[1].Inputs = nil
def.Nodes[1].Data.InputsValues = nil
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -189,8 +50,8 @@ func TestValidateDefinitionRejectsMissingRequiredInputMapping(t *testing.T) {
}
func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "missing_1", Field: "replyText"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("missing_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -202,9 +63,25 @@ func TestValidateDefinitionRejectsUnknownInputSourceNode(t *testing.T) {
}
}
func TestValidateDefinitionRejectsUnavailableInputSourceNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = append(def.Nodes, node("late_1", "llm_reply", inputs("userMessage", dsl.RefValue("reply_1", "sent")), nil))
def.Edges = append(def.Edges, edge("reply_1", "late_1"))
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("late_1", "replyText")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected downstream input source to be invalid")
}
if !hasValidationMessage(result, "input source node is not available before current node") {
t.Fatalf("expected source availability error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "missing"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "missing")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -217,8 +94,8 @@ func TestValidateDefinitionRejectsUnknownInputSourceField(t *testing.T) {
}
func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
def := mappedReplyDefinition()
def.Nodes[1].Inputs["replyText"] = dsl.VariableSelector{NodeID: "start_1", Field: "conversationId"}
def := minimalDefinition()
def.Nodes[1].Data.InputsValues["replyText"] = dsl.RefValue("start_1", "conversationId")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -230,69 +107,83 @@ func TestValidateDefinitionRejectsIncompatibleInputType(t *testing.T) {
}
}
func TestValidateDefinitionAcceptsMappedKnowledgeFlow(t *testing.T) {
func TestValidateDefinitionAcceptsConfirmedCreateTicket(t *testing.T) {
def := dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "retrieve_1", Type: "knowledge_retrieve", Inputs: map[string]dsl.VariableSelector{
"query": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "reply_1", Type: "send_reply", Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("confirm_1", "confirmed"),
}, nil),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "retrieve_1"},
{ID: "e2", Source: "retrieve_1", Target: "reply_1"},
{ID: "e3", Source: "reply_1", Target: "end_1"},
edge("start_1", "draft_1"),
edge("draft_1", "confirm_1"),
edge("confirm_1", "create_1"),
edge("create_1", "end_1"),
},
}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if !result.Valid {
t.Fatalf("expected mapped knowledge flow to be valid, got %#v", result.Errors)
t.Fatalf("expected confirmed create_ticket to be valid, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownConditionOperator(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T) {
def := minimalDefinition()
def.Nodes = []dsl.Node{
node("start_1", "start", nil, nil),
node("draft_1", "prepare_ticket_draft", inputs("issue", dsl.RefValue("start_1", "userMessage")), nil),
node("create_1", "create_ticket", map[string]dsl.Value{
"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"),
"confirmed": dsl.RefValue("start_1", "userMessage"),
}, nil),
node("end_1", "end", nil, nil),
}
config.Branches[0].Condition.Operator = "regex"
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
def.Edges = []dsl.Edge{
edge("start_1", "draft_1"),
edge("draft_1", "create_1"),
edge("create_1", "end_1"),
}
def.Nodes[1].Config = raw
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected unknown condition operator to be invalid")
t.Fatalf("expected confirmed input from non-confirm node to be invalid")
}
if !hasValidationMessage(result, "unsupported condition operator") {
t.Fatalf("expected condition operator error, got %#v", result.Errors)
if !hasValidationMessage(result, "confirmed input must come from human_confirm.confirmed") {
t.Fatalf("expected confirmed-source error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
def := conditionDefinition()
def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected condition branch target without edge to be invalid")
}
if !hasValidationMessage(result, "condition branch target must have an outgoing edge") {
t.Fatalf("expected branch edge error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
if err := json.Unmarshal(def.Nodes[1].Data.Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0].Condition.Left.Field = "missing"
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
}
def.Nodes[1].Config = raw
config.Branches[0].Condition.Left = &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "missing"}}
def.Nodes[1].Data.Config = mustJSON(config)
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
@@ -304,123 +195,30 @@ func TestValidateDefinitionRejectsUnknownConditionVariable(t *testing.T) {
}
}
func TestValidateDefinitionRejectsInvalidConditionEnumValue(t *testing.T) {
def := policyConditionDefinition("unknown_action")
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected invalid enum condition value to be rejected")
}
if !hasValidationMessage(result, "condition comparison value is not allowed") {
t.Fatalf("expected condition enum value error, got %#v", result.Errors)
}
}
func TestValidateDefinitionRejectsConditionDefaultBranchBeforeLast(t *testing.T) {
def := conditionDefinition()
var config dsl.ConditionConfig
if err := json.Unmarshal(def.Nodes[1].Config, &config); err != nil {
t.Fatalf("unmarshal condition config: %v", err)
}
config.Branches[0], config.Branches[1] = config.Branches[1], config.Branches[0]
raw, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal condition config: %v", err)
}
def.Nodes[1].Config = raw
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
if result.Valid {
t.Fatalf("expected default branch before last to be invalid")
}
if !hasValidationMessage(result, "default condition branch must be last") {
t.Fatalf("expected default branch order error, got %#v", result.Errors)
}
}
func minimalDefinition() dsl.Definition {
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "reply_1", Type: "send_reply", Config: json.RawMessage(`{"text":"hello"}`), Inputs: map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("reply_1", "send_reply", inputs("replyText", dsl.RefValue("start_1", "userMessage")), map[string]any{"text": "hello"}),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "reply_1"},
{ID: "e2", Source: "reply_1", Target: "end_1"},
edge("start_1", "reply_1"),
edge("reply_1", "end_1"),
},
}
}
func policyConditionDefinition(action any) dsl.Definition {
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
Branches: []dsl.ConditionBranch{
{
ID: "direct",
Name: "Direct",
TargetNodeID: "end_1",
Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "policy_1", Field: "action"},
Operator: "eq",
Right: action,
},
},
{
ID: "default",
Name: "Default",
TargetNodeID: "end_1",
Default: true,
},
},
})
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "understanding_1", Type: "conversation_understanding", Inputs: map[string]dsl.VariableSelector{
"userMessage": {NodeID: "start_1", Field: "userMessage"},
}},
{ID: "policy_1", Type: "reply_policy", Inputs: map[string]dsl.VariableSelector{
"messageIntent": {NodeID: "understanding_1", Field: "messageIntent"},
"answerScope": {NodeID: "understanding_1", Field: "answerScope"},
}},
{ID: "condition_1", Type: "condition", Config: conditionConfig},
{ID: "end_1", Type: "end"},
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "understanding_1"},
{ID: "e2", Source: "understanding_1", Target: "policy_1"},
{ID: "e3", Source: "policy_1", Target: "condition_1"},
{ID: "e4", Source: "condition_1", Target: "end_1"},
{ID: "e5", Source: "condition_1", Target: "end_1"},
},
}
}
func mappedReplyDefinition() dsl.Definition {
def := minimalDefinition()
def.Nodes[1].Inputs = map[string]dsl.VariableSelector{
"replyText": {NodeID: "start_1", Field: "userMessage"},
}
return def
}
func conditionDefinition() dsl.Definition {
conditionConfig, _ := json.Marshal(dsl.ConditionConfig{
conditionConfig := dsl.ConditionConfig{
Branches: []dsl.ConditionBranch{
{
ID: "hello",
Name: "Hello",
TargetNodeID: "end_1",
Condition: &dsl.Condition{
Left: &dsl.VariableSelector{NodeID: "start_1", Field: "userMessage"},
Left: &dsl.Value{Type: dsl.ValueTypeRef, Content: []string{"start_1", "userMessage"}},
Operator: "eq",
Right: "hello",
},
@@ -432,23 +230,54 @@ func conditionDefinition() dsl.Definition {
Default: true,
},
},
})
}
return dsl.Definition{
SchemaVersion: 1,
EntryNodeID: "start_1",
SchemaVersion: dsl.SchemaVersion,
Nodes: []dsl.Node{
{ID: "start_1", Type: "start"},
{ID: "condition_1", Type: "condition", Config: conditionConfig},
{ID: "end_1", Type: "end"},
node("start_1", "start", nil, nil),
node("condition_1", "condition", nil, conditionConfig),
node("end_1", "end", nil, nil),
},
Edges: []dsl.Edge{
{ID: "e1", Source: "start_1", Target: "condition_1"},
{ID: "e2", Source: "condition_1", Target: "end_1"},
{ID: "e3", Source: "condition_1", Target: "end_1"},
edge("start_1", "condition_1"),
edge("condition_1", "end_1"),
edge("condition_1", "end_1"),
},
}
}
func node(id string, nodeType string, inputValues map[string]dsl.Value, config any) dsl.Node {
return dsl.Node{
ID: id,
Type: nodeType,
Meta: dsl.NodeMeta{Position: dsl.Position{X: 0, Y: 0}},
Data: dsl.NodeData{
Title: nodeType,
Config: mustJSON(config),
InputsValues: inputValues,
},
}
}
func edge(source string, target string) dsl.Edge {
return dsl.Edge{SourceNodeID: source, TargetNodeID: target}
}
func inputs(name string, value dsl.Value) map[string]dsl.Value {
return map[string]dsl.Value{name: value}
}
func mustJSON(value any) json.RawMessage {
if value == nil {
return nil
}
raw, err := json.Marshal(value)
if err != nil {
panic(err)
}
return raw
}
func hasValidationMessage(result validator.Result, want string) bool {
for _, item := range result.Errors {
if strings.Contains(item.Message, want) {