mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-04 09:39:32 +08:00
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
210 lines
5.4 KiB
Go
210 lines
5.4 KiB
Go
// Package canvas — state unit tests (Worker A, Phase 1).
|
|
package canvas
|
|
|
|
import (
|
|
"reflect"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// TestCanvasState_GetVarSetVar covers all 4 ref kinds (cpn@param, sys.x,
|
|
// env.x, item/index) plus missing keys, dot-path traversal, and concurrent
|
|
// read/write under the simple RWMutex.
|
|
func TestCanvasState_GetVarSetVar(t *testing.T) {
|
|
type step struct {
|
|
name string
|
|
ref string
|
|
want any
|
|
wantErr bool
|
|
}
|
|
cases := []struct {
|
|
title string
|
|
setup func(s *CanvasState)
|
|
checks []step
|
|
}{
|
|
{
|
|
title: "cpn_id@param direct",
|
|
setup: func(s *CanvasState) {
|
|
s.SetVar("retrieval_0", "chunks", []string{"a", "b"})
|
|
},
|
|
checks: []step{
|
|
{"hit", "retrieval_0@chunks", []string{"a", "b"}, false},
|
|
{"miss unknown cpn", "missing_0@chunks", nil, false},
|
|
{"miss unknown param on known cpn", "retrieval_0@other", nil, false},
|
|
},
|
|
},
|
|
{
|
|
title: "cpn_id@param dot-path",
|
|
setup: func(s *CanvasState) {
|
|
s.SetVar("llm_0", "result", map[string]any{
|
|
"text": "hi",
|
|
"meta": map[string]any{"tokens": 42},
|
|
})
|
|
},
|
|
checks: []step{
|
|
{"two-level", "llm_0@result.meta.tokens", 42, false},
|
|
{"one-level", "llm_0@result.text", "hi", false},
|
|
{"deep miss", "llm_0@result.meta.absent", nil, false},
|
|
},
|
|
},
|
|
{
|
|
title: "sys namespace",
|
|
setup: func(s *CanvasState) {
|
|
s.Sys["query"] = "what is ragflow"
|
|
s.Sys["user_id"] = "tenant-1"
|
|
},
|
|
checks: []step{
|
|
{"sys.query", "sys.query", "what is ragflow", false},
|
|
{"sys.user_id", "sys.user_id", "tenant-1", false},
|
|
{"sys absent", "sys.missing", nil, false},
|
|
},
|
|
},
|
|
{
|
|
title: "env namespace",
|
|
setup: func(s *CanvasState) {
|
|
s.Env["max_tokens"] = 1024
|
|
},
|
|
checks: []step{
|
|
{"env.max_tokens", "env.max_tokens", 1024, false},
|
|
{"env absent", "env.min_tokens", nil, false},
|
|
},
|
|
},
|
|
{
|
|
title: "iteration aliases",
|
|
setup: func(s *CanvasState) {
|
|
// Tests run single-threaded; writing the Globals map
|
|
// directly is safe and exercises the same read path
|
|
// (GetVar locks internally) as production code.
|
|
s.Globals["__item__"] = "item-value"
|
|
s.Globals["__index__"] = 7
|
|
},
|
|
checks: []step{
|
|
{"item", "item", "item-value", false},
|
|
{"index", "index", 7, false},
|
|
},
|
|
},
|
|
{
|
|
title: "invalid ref",
|
|
setup: func(s *CanvasState) {},
|
|
checks: []step{
|
|
{"no namespace and no @", "garbage", nil, true},
|
|
{"empty", "", nil, true},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.title, func(t *testing.T) {
|
|
s := NewCanvasState("run-test", "task-test")
|
|
c.setup(s)
|
|
for _, ch := range c.checks {
|
|
got, err := s.GetVar(ch.ref)
|
|
if ch.wantErr {
|
|
if err == nil {
|
|
t.Errorf("%s: expected error for ref %q, got nil (val=%v)", ch.name, ch.ref, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Errorf("%s: unexpected error for ref %q: %v", ch.name, ch.ref, err)
|
|
continue
|
|
}
|
|
if !equalValue(got, ch.want) {
|
|
t.Errorf("%s: ref %q: got %v (%T), want %v (%T)", ch.name, ch.ref, got, got, ch.want, ch.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCanvasState_SetVar_AutocreateNested confirms SetVar creates
|
|
// intermediate dicts for a dot-path, mirroring Python's
|
|
// set_variable_param_value (canvas.py:261-271).
|
|
func TestCanvasState_SetVar_AutocreateNested(t *testing.T) {
|
|
s := NewCanvasState("r", "t")
|
|
s.SetVar("cpn_0", "a.b.c", "deep")
|
|
|
|
// GetVar locks internally; no need to wrap with an outer RLock
|
|
// (a recursive Read lock would also work but is unnecessary).
|
|
got, err := s.GetVar("cpn_0@a.b.c")
|
|
if err != nil {
|
|
t.Fatalf("GetVar: %v", err)
|
|
}
|
|
if got != "deep" {
|
|
t.Fatalf("got %v, want \"deep\"", got)
|
|
}
|
|
}
|
|
|
|
// TestCanvasState_ConcurrentReadWrite sanity-checks the RWMutex under mixed
|
|
// workload. The hard-gate benchmark (state_bench_test.go) measures the
|
|
// real numbers; this is a smoke test for race-detector cleanliness.
|
|
func TestCanvasState_ConcurrentReadWrite(t *testing.T) {
|
|
s := NewCanvasState("r", "t")
|
|
for i := 0; i < 50; i++ {
|
|
s.SetVar(cpnID(i), "v", i)
|
|
}
|
|
var wg sync.WaitGroup
|
|
for g := 0; g < 8; g++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 200; i++ {
|
|
_, _ = s.GetVar(cpnID(i%50) + "@v")
|
|
s.SetVar(cpnID(i%50), "v", i)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestReadVars covers batch resolution for parameter binding.
|
|
func TestReadVars(t *testing.T) {
|
|
s := NewCanvasState("r", "t")
|
|
s.SetVar("a", "x", "alpha")
|
|
s.SetVar("b", "y", "beta")
|
|
s.Sys["query"] = "q1"
|
|
|
|
refs := []string{"a@x", "b@y", "sys.query", "missing@z"}
|
|
got, err := s.ReadVars(refs)
|
|
if err != nil {
|
|
t.Fatalf("ReadVars: %v", err)
|
|
}
|
|
if got["a@x"] != "alpha" {
|
|
t.Errorf("a@x: got %v", got["a@x"])
|
|
}
|
|
if got["b@y"] != "beta" {
|
|
t.Errorf("b@y: got %v", got["b@y"])
|
|
}
|
|
if got["sys.query"] != "q1" {
|
|
t.Errorf("sys.query: got %v", got["sys.query"])
|
|
}
|
|
if got["missing@z"] != nil {
|
|
t.Errorf("missing@z: expected nil, got %v", got["missing@z"])
|
|
}
|
|
}
|
|
|
|
// equalValue is a small structural comparator — `int(42)` and `float64(42)`
|
|
// both count as "42" because the table tests were written for clarity, plus
|
|
// slice/map/struct equality via reflect.DeepEqual. Avoids the runtime panic
|
|
// that `==` produces on uncomparable types like []string.
|
|
func equalValue(got, want any) bool {
|
|
if got == nil && want == nil {
|
|
return true
|
|
}
|
|
if got == nil || want == nil {
|
|
return false
|
|
}
|
|
switch w := want.(type) {
|
|
case int:
|
|
switch g := got.(type) {
|
|
case int:
|
|
return w == g
|
|
case int64:
|
|
return int64(w) == g
|
|
case float64:
|
|
return float64(w) == g
|
|
}
|
|
}
|
|
return reflect.DeepEqual(got, want)
|
|
}
|