Files
ragflow/internal/agent/canvas/loop_semantics_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

395 lines
14 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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.
//
// loop_semantics_test.go — end-to-end Loop semantics tests.
//
// Unlike loop_subgraph_test.go (which unit-tests helpers in isolation
// with no factory registered), this file imports
// internal/agent/component as a side-effect to install the real
// component factory via runtime.SetDefaultFactory. The tests then
// compile and run a full Begin → Loop → ... DSL and assert that the
// loop body actually mutates CanvasState across iterations, that
// termination conditions fire on the real state values, and that
// factory errors surface with cpn-scoped diagnostics.
//
// The blank import below is what wires component.New into the
// canvas builder's runtime.DefaultFactory() lookup; without it,
// BuildWorkflow would fall back to its placeholder echo body and the
// loop would never observe the counter increment.
package canvas
import (
"context"
"errors"
"strings"
"testing"
// Blank-import to trigger component package init(), which calls
// runtime.SetDefaultFactory(component.New). Without this, the
// canvas builder uses its placeholder body and these tests cannot
// exercise real component invocation.
_ "ragflow/internal/agent/component"
"ragflow/internal/agent/runtime"
"ragflow/internal/agent/workflowx"
)
// runLoopCanvas is the common harness for the e2e loop tests. It
// compiles dsl, attaches state to a fresh ctx, invokes the workflow,
// and returns the run error. Callers inspect state after the run to
// assert per-iteration writes landed.
func runLoopCanvas(t *testing.T, dsl *Canvas) (*CanvasState, error) {
t.Helper()
cc, err := Compile(context.Background(), dsl)
if err != nil {
t.Fatalf("Compile: %v", err)
}
state := NewCanvasState("run-loop", "task-loop")
ctx := withState(context.Background(), state)
_, runErr := cc.Workflow.Invoke(ctx, map[string]any{"query": "go"})
return state, runErr
}
// counterLoopDSL builds a Begin → Loop DSL with one VariableAssigner
// body node that adds the supplied step to a counter loop variable
// each iteration. The loop terminates when counter >= threshold.
func counterLoopDSL(step int, threshold int, maxCount int) *Canvas {
return &Canvas{
Version: 1,
Components: map[string]CanvasComponent{
"begin": {
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"loop"},
},
"loop": {
Obj: CanvasComponentObj{
ComponentName: "Loop",
Params: map[string]any{
"loop_variables": []any{
map[string]any{
"variable": "counter",
"input_mode": "constant",
"value": 0,
"type": "number",
},
},
"loop_termination_condition": []any{
map[string]any{
"variable": "counter",
"operator": "≥",
"value": threshold,
"input_mode": "constant",
},
},
"logical_operator": "and",
"maximum_loop_count": maxCount,
},
},
Upstream: []string{"begin"},
Downstream: []string{"bump"},
},
"bump": {
Obj: CanvasComponentObj{
ComponentName: "VariableAssigner",
Params: map[string]any{
"variables": []any{
map[string]any{
"variable": "loop@counter",
"operator": "+=",
"parameter": step,
},
},
},
},
Upstream: []string{"loop"},
},
},
Path: []string{"begin", "loop"},
}
}
// TestLoop_DoWhileCounter is the keystone test: it proves that the
// real VariableAssigner component runs inside the loop body, mutates
// the shared CanvasState, and that the termination condition fires
// on the mutated value. If the loop body were still a placeholder
// echo lambda the counter would stay at 0 and the loop would run to
// maximum_loop_count or hit defaultMaxIterations.
func TestLoop_DoWhileCounter(t *testing.T) {
state, err := runLoopCanvas(t, counterLoopDSL(1, 3, 50))
if err != nil {
t.Fatalf("Invoke: %v", err)
}
v, err := state.GetVar("loop@counter")
if err != nil {
t.Fatalf("GetVar: %v", err)
}
got, ok := v.(float64)
if !ok {
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v", v, v)
}
// The loop performs do-while semantics: it runs the body, THEN
// checks the condition. Starting at counter=0, the body
// increments to 1, 2, 3 — the condition (counter >= 3) becomes
// true after the third iteration, so the final value is 3.
if got != 3 {
t.Errorf("counter: got %v, want 3", got)
}
}
// TestLoop_MaxCount proves that maximum_loop_count caps iterations
// when the termination condition never fires. The condition asks for
// counter >= 100 but maximum_loop_count is 5; the loop must stop at
// counter=5 (5 successful body runs).
func TestLoop_MaxCount(t *testing.T) {
state, err := runLoopCanvas(t, counterLoopDSL(1, 100, 5))
// workflowx surfaces a MaxIterationsExceeded error when the cap
// is hit. Both the error path AND the partial state must be
// observable to the caller — the state writes that succeeded
// before the cap should still be present.
if err == nil {
t.Fatalf("expected ErrLoopMaxIterationsExceeded, got nil")
}
if !errors.Is(err, workflowx.ErrLoopMaxIterationsExceeded) {
t.Fatalf("want ErrLoopMaxIterationsExceeded, got: %v", err)
}
v, err := state.GetVar("loop@counter")
if err != nil {
t.Fatalf("GetVar: %v", err)
}
got, ok := v.(float64)
if !ok {
t.Fatalf("counter: want float64, got %T: %v", v, v)
}
if got != 5 {
t.Errorf("counter at cap: got %v, want 5 (maximum_loop_count)", got)
}
}
// TestLoop_FactoryErrorSurfaces proves that a factory rejection of a
// loop body member produces a cpn-scoped error from BuildWorkflow
// (not a silent placeholder fallback or an opaque error from the
// workflowx layer).
//
// VariableAssigner's factory rejects a non-list `variables` param
// (see variable_assigner.go's Update). We trigger that by supplying
// a string instead of a list.
func TestLoop_FactoryErrorSurfaces(t *testing.T) {
dsl := &Canvas{
Version: 1,
Components: map[string]CanvasComponent{
"begin": {
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"loop"},
},
"loop": {
Obj: CanvasComponentObj{
ComponentName: "Loop",
Params: map[string]any{
"loop_variables": []any{},
"loop_termination_condition": []any{},
},
},
Upstream: []string{"begin"},
Downstream: []string{"bad"},
},
"bad": {
Obj: CanvasComponentObj{
ComponentName: "VariableAssigner",
Params: map[string]any{
"variables": "not-a-list", // factory rejects this
},
},
Upstream: []string{"loop"},
},
},
}
_, err := Compile(context.Background(), dsl)
if err == nil {
t.Fatal("expected factory error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "bad") {
t.Errorf("error should name the cpn_id 'bad'; got: %v", err)
}
if !strings.Contains(msg, "VariableAssigner") {
t.Errorf("error should name the component type 'VariableAssigner'; got: %v", err)
}
}
// TestLoop_LegacyExitLoopStaysNoOp confirms that the DSL v1 sentinel
// "ExitLoop" continues to compile as a no-op even when a factory is
// registered (the legacy-no-op path takes precedence over factory
// lookup). This is the protection against a future "ExitLoop" being
// accidentally registered as a real component and changing behaviour
// for v1 DSLs.
func TestLoop_LegacyExitLoopStaysNoOp(t *testing.T) {
dsl := &Canvas{
Version: 1,
Components: map[string]CanvasComponent{
"begin": {
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"exit"},
},
"exit": {
Obj: CanvasComponentObj{ComponentName: "ExitLoop"},
Upstream: []string{"begin"},
},
},
}
if _, err := Compile(context.Background(), dsl); err != nil {
t.Fatalf("Compile with legacy ExitLoop (factory registered): %v", err)
}
// Also verify the factory IS registered — otherwise this test
// would be no different from the canvas-only TestBuildWorkflow_LegacyExitLoop.
if runtime.DefaultFactory() == nil {
t.Fatal("factory must be registered for this test to be meaningful")
}
}
// TestLoop_FactoryRegisteredInThisBinary is a sanity guard: if a
// future refactor breaks the blank import in this file, the other
// e2e tests would silently fall back to placeholder bodies and
// pass for the wrong reason. This test fails loudly if the factory
// is not installed.
func TestLoop_FactoryRegisteredInThisBinary(t *testing.T) {
if runtime.DefaultFactory() == nil {
t.Fatal("runtime.DefaultFactory() is nil; the blank import of internal/agent/component is missing or broken")
}
}
// variableModeLoopDSL builds a Begin → VariableAssigner(seed) → Loop →
// VariableAssigner(bump) DSL where the loop's counter is seeded from
// the seed component's output via input_mode="variable". The loop
// terminates when counter >= threshold; the bump node increments
// counter by step each iteration.
//
// This is the regression test for the "input_mode=variable" loop
// variable init bug: the init lambda must dereference the value
// against the live CanvasState (state.GetVar) at init time, not
// store the raw ref string. If the dereference is missing, counter
// is seeded with the literal string "seed@initial" and the body's
// `+=` operator fails with PARAMETER_NOT_NUMBER on the first
// iteration — the loop terminates after a single body run with
// counter=0 (or errors out).
//
// The seed uses VariableAssigner's `set` operator with an int
// parameter (not `overwrite` with a {{literal}} — `overwrite` looks
// the parameter up as a state ref, so a bare number would error with
// PARAMETER_UNRESOLVED). `set` falls through to return the raw param
// for non-string types, which is what we want here.
func variableModeLoopDSL(threshold, step int) *Canvas {
return &Canvas{
Version: 1,
Components: map[string]CanvasComponent{
"begin": {
Obj: CanvasComponentObj{ComponentName: "Begin", Params: map[string]any{}},
Downstream: []string{"seed"},
},
"seed": {
Obj: CanvasComponentObj{
ComponentName: "VariableAssigner",
Params: map[string]any{
"variables": []any{
map[string]any{
"variable": "seed@initial",
"operator": "set",
"parameter": 5,
},
},
},
},
Upstream: []string{"begin"},
Downstream: []string{"loop"},
},
"loop": {
Obj: CanvasComponentObj{
ComponentName: "Loop",
Params: map[string]any{
"loop_variables": []any{
map[string]any{
"variable": "counter",
"input_mode": "variable", // dereference against state
"value": "seed@initial",
"type": "number",
},
},
"loop_termination_condition": []any{
map[string]any{
"variable": "counter",
"operator": "≥",
"value": threshold,
"input_mode": "constant",
},
},
"logical_operator": "and",
"maximum_loop_count": 50,
},
},
Upstream: []string{"seed"},
Downstream: []string{"bump"},
},
"bump": {
Obj: CanvasComponentObj{
ComponentName: "VariableAssigner",
Params: map[string]any{
"variables": []any{
map[string]any{
"variable": "loop@counter",
"operator": "+=",
"parameter": step,
},
},
},
},
Upstream: []string{"loop"},
},
},
Path: []string{"begin", "loop"},
}
}
// TestLoop_VariableModeInitDereferencesRef proves that the loop init
// lambda actually dereferences input_mode="variable" refs against the
// live CanvasState. Seed writes 5 to Outputs["seed"]["initial"]; the
// loop's counter is initialised from "seed@initial" (a ref), so the
// expected starting counter is 5. The bump node increments by 1 and
// the loop terminates when counter >= 8. With correct resolution,
// counter walks 5 → 6 → 7 → 8 (3 successful body runs) and stops.
//
// If the init lambda fails to dereference, counter is seeded with the
// literal string "seed@initial" and `+= 1` fails on the first
// iteration; the test would observe a counter of 0 (or a
// PARAMETER_NOT_NUMBER error surfacing from bump).
func TestLoop_VariableModeInitDereferencesRef(t *testing.T) {
state, err := runLoopCanvas(t, variableModeLoopDSL(8, 1))
if err != nil {
t.Fatalf("Invoke: %v", err)
}
v, err := state.GetVar("loop@counter")
if err != nil {
t.Fatalf("GetVar: %v", err)
}
got, ok := v.(float64)
if !ok {
t.Fatalf("counter: want float64 (VariableAssigner += produces float64), got %T: %v — input_mode=variable init did not dereference the ref; the seed was written as the literal string %q instead of the resolved value", v, v, "seed@initial")
}
// 5 (resolved from seed@initial) + 1 + 1 + 1 = 8 (do-while: body
// runs, THEN condition is checked). Threshold is 8, so the
// condition fires after the 3rd body run, leaving counter=8.
if got != 8 {
t.Errorf("counter: got %v, want 8 (input_mode=variable should seed from seed@initial=5, then 3 increments to reach threshold)", got)
}
}