Files
ragflow/internal/agent/dsl/v1_examples_test.go
Zhichang Yu 3fa15c0e2f feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)
Ports the agent canvas subsystem from Python to Go.

## What's included

### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages

### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |

### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7

### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)

### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs

### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
2026-06-12 22:58:28 +08:00

193 lines
7.1 KiB
Go

// Package dsl — loader test for the production v1 DSL examples.
//
// These fixtures are mirrored from agent/test/dsl_examples/*.json so
// the Go port has a self-contained set of representative v1 payloads
// to exercise against. The Python side ships the originals (and may
// grow them); this directory is the Go side's frozen copy for unit
// tests so the Go port is buildable on its own.
//
// The test walks every fixture through the four DSL entry points
// (DetectVersion, LoadV1, Load auto-detect, DecodeReader) and asserts
// the converted v2 Canvas is structurally sound in each case. The
// four entry points differ only in how the bytes reach the
// parser/detector — by covering them all from one table we catch
// any drift in detection rules or converter behaviour without
// fragmenting coverage into per-entry-point subtests.
//
// Each example covers a distinct topology:
//
// retrieval_and_generate — Begin → Retrieval → LLM → Message
// tavily_and_generate — Begin → TavilySearch → LLM → Message
// categorize_and_agent_with_tavily — Begin → Categorize → {Agent(Tavily) | Message}
// retrieval_categorize_and_generate — Begin → Categorize → {Retrieval → Agent → Message | Message}
// iteration — Begin → Agent → Iteration → {IterationItem → TavilySearch → Agent}
// exesql — Begin → Answer ↔ ExeSQL (cycle, kept in fixtures as a stress test)
// headhunter_zh — multi-Categorize, multi-Generate, Answer+Message loop
package dsl
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// v1Examples lists every fixture under testdata/v1_examples. Adding
// a new file to the directory automatically extends coverage.
var v1Examples = []string{
"categorize_and_agent_with_tavily.json",
"exesql.json",
"headhunter_zh.json",
"iteration.json",
"retrieval_and_generate.json",
"retrieval_categorize_and_generate.json",
"tavily_and_generate.json",
}
// readV1Example reads a v1 fixture from testdata/v1_examples. Tests
// call t.Skip if the file is missing so the package still builds in
// environments where the fixtures have not been vendored.
func readV1Example(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("testdata", "v1_examples", name)
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("v1 fixture %s not readable: %v", path, err)
}
return raw
}
// TestV1Examples is the single canary for the v1 fixture set. It
// drives every fixture through the four DSL entry points and asserts
// the resulting v2 Canvas is structurally sound in each case. Failing
// here means either the directory contents drifted, a new fixture
// doesn't match the v1 envelope, or the converter lost something on
// its way to v2.
func TestV1Examples(t *testing.T) {
// Directory canary: the on-disk list must match v1Examples. This
// guards against the suite silently passing because new files
// were added without updating v1Examples, or vice versa.
entries, err := os.ReadDir(filepath.Join("testdata", "v1_examples"))
if err != nil {
t.Fatalf("testdata/v1_examples not accessible: %v", err)
}
got := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
got = append(got, e.Name())
}
}
sort.Strings(got)
want := append([]string(nil), v1Examples...)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("v1_examples count = %d (%v), want %d (%v)", len(got), got, len(want), want)
}
for i := range got {
if got[i] != want[i] {
t.Errorf("v1_examples[%d] = %q, want %q", i, got[i], want[i])
}
}
for _, name := range v1Examples {
t.Run(name, func(t *testing.T) {
raw := readV1Example(t, name)
// DetectVersion must report V1.
v, err := DetectVersion(raw)
if err != nil {
t.Fatalf("DetectVersion: %v", err)
}
if v != V1 {
t.Fatalf("DetectVersion = %s, want v1", v)
}
// LoadV1 (explicit v1) and Load (auto-detect) must both
// produce a valid v2 Canvas. Comparing the two
// additionally proves the auto-detect path routed to
// LoadV1 (the canvas shapes must match).
explicit, err := LoadV1(raw)
if err != nil {
t.Fatalf("LoadV1: %v", err)
}
assertConvertedCanvasOK(t, name, explicit)
auto, err := Load(raw)
if err != nil {
t.Fatalf("Load: %v", err)
}
assertConvertedCanvasOK(t, name, auto)
if len(auto.Components) != len(explicit.Components) {
t.Errorf("Load and LoadV1 disagree on component count: %d vs %d",
len(auto.Components), len(explicit.Components))
}
// DecodeReader (io.Reader path) must produce the same
// v2 Canvas up to component count.
decoded, err := DecodeReader(strings.NewReader(string(raw)))
if err != nil {
t.Fatalf("DecodeReader: %v", err)
}
if decoded.Version != CurrentVersion {
t.Errorf("DecodeReader Version = %d, want %d", decoded.Version, CurrentVersion)
}
if len(decoded.Components) != len(explicit.Components) {
t.Errorf("DecodeReader produced %d components, LoadV1 produced %d",
len(decoded.Components), len(explicit.Components))
}
})
}
}
// assertConvertedCanvasOK verifies a v1-converted Canvas is a valid v2
// graph: non-empty components, non-empty names, no colon in ids, and
// every Downstream ref resolves. This is the load-time check that
// protects against drift in either the fixture or the converter.
func assertConvertedCanvasOK(t *testing.T, name string, c *Canvas) {
t.Helper()
if c.Version != CurrentVersion {
t.Errorf("[%s] Version = %d, want %d", name, c.Version, CurrentVersion)
}
if len(c.Components) == 0 {
t.Fatalf("[%s] Components is empty", name)
}
byName := map[string]int{}
for id, cpn := range c.Components {
if strings.Contains(id, ":") {
t.Errorf("[%s] v2 id %q still contains ':'", name, id)
}
if id != cpn.ID && cpn.ID != "" {
t.Errorf("[%s] key %q != Component.ID %q", name, id, cpn.ID)
}
if cpn.Name == "" {
t.Errorf("[%s] component %q has empty Name", name, id)
}
for _, ds := range cpn.Downstream {
if _, ok := c.Components[ds]; !ok {
t.Errorf("[%s] component %q downstream %q does not exist", name, id, ds)
}
}
byName[cpn.Name]++
}
// Begin must be present in every fixture (they all start from a
// user query). Catches the case where someone renames Begin in a
// fixture and silently breaks every orchestrator that looks it up.
if byName["Begin"] == 0 {
t.Errorf("[%s] no Begin component found in converted canvas (have %v)", name, byName)
}
// We deliberately do NOT require at least one terminal node
// here: the v1 fixtures include Answer↔ExeSQL and Answer↔Message
// cycles (e.g. exesql.json, headhunter_zh.json) where every
// component has a non-empty downstream by design — the cycle
// represents the agent waiting for the next user turn. Cycle
// detection is a runtime concern, not a load-time one, and lives
// in the scheduler.
//
// Run the v2 Validate as the final gate. This catches anything
// the per-component loop above did not (e.g. Version mismatch).
if err := c.Validate(); err != nil {
t.Errorf("[%s] Validate: %v", name, err)
}
}