mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-15 01:18:26 +08:00
Replaces the Python agent canvas runtime with a Go implementation that runs inside `cmd/server_main`. The canvas compiles into an eino Workflow that pauses on wait-for-user via native Interrupt/Resume (no sentinel flag) and resumes from a Redis-backed CheckPointStore. All 21 Python agent components and ~35 tools are ported with functional parity. Sandbox providers now read their JSON config from the admin-panel system_settings table with env fallback. 234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate added); drops the v2 DSL E2E step and the gap-analysis plan (both redundant after the port ships). ## Type of change - [x] Refactoring - [x] New feature - [x] Bug fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
259 lines
8.5 KiB
Go
259 lines
8.5 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.
|
|
//
|
|
|
|
// Tests for the Phase 4.4 V2 canvas DSL decoder.
|
|
//
|
|
// Each test pins one branch of decodeCanvasFromDSL:
|
|
// - empty DSL → ErrAgentStorageError (no JSON-round-trip happens)
|
|
// - non-empty DSL with one component → clean *canvas.Canvas
|
|
// - non-empty DSL with multiple components → all preserved
|
|
//
|
|
// All failures MUST carry ErrAgentStorageError so the handler-side
|
|
// mapAgentError classifies them as CodeServerError (500) with the
|
|
// sanitized message (no raw decoder-string leak). This is the
|
|
// regression pin that closes the v3.5.2 review's "raw decode error
|
|
// leak" concern at the Compile/Invoke boundary.
|
|
package service
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// TestDecodeCanvasFromDSL_EmptyDSL pins the "empty DSL" branch:
|
|
// decodeCanvasFromDSL(nil) must return an error wrapping
|
|
// ErrAgentStorageError without attempting json.Marshal (the empty
|
|
// map serialises to "{}" which would decode to an empty Canvas,
|
|
// bypassing the "no components" check).
|
|
func TestDecodeCanvasFromDSL_EmptyDSL(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := decodeCanvasFromDSL(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty DSL")
|
|
}
|
|
if !errors.Is(err, ErrAgentStorageError) {
|
|
t.Errorf("expected ErrAgentStorageError in chain, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDecodeCanvasFromDSL_EmptyMapSameAsNil pins the equivalent
|
|
// branch for an explicitly-empty (but non-nil) map. Same outcome
|
|
// as the nil case — the function must reject before marshalling.
|
|
func TestDecodeCanvasFromDSL_EmptyMapSameAsNil(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := decodeCanvasFromDSL(map[string]any{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty DSL map")
|
|
}
|
|
if !errors.Is(err, ErrAgentStorageError) {
|
|
t.Errorf("expected ErrAgentStorageError in chain, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDecodeCanvasFromDSL_SingleComponent pins the happy-path:
|
|
// a DSL with one Begin component decodes to a *canvas.Canvas with
|
|
// that component under its id key.
|
|
func TestDecodeCanvasFromDSL_SingleComponent(t *testing.T) {
|
|
t.Parallel()
|
|
dsl := map[string]any{
|
|
"components": map[string]any{
|
|
"begin_0": map[string]any{
|
|
"obj": map[string]any{
|
|
"component_name": "Begin",
|
|
"params": map[string]any{},
|
|
},
|
|
"downstream": []any{"message_0"},
|
|
"upstream": []any{},
|
|
},
|
|
},
|
|
"path": []any{"begin_0"},
|
|
}
|
|
c, err := decodeCanvasFromDSL(dsl)
|
|
if err != nil {
|
|
t.Fatalf("decodeCanvasFromDSL: %v", err)
|
|
}
|
|
if c == nil {
|
|
t.Fatal("decoded Canvas is nil")
|
|
}
|
|
if len(c.Components) != 1 {
|
|
t.Errorf("Components length = %d, want 1", len(c.Components))
|
|
}
|
|
if _, ok := c.Components["begin_0"]; !ok {
|
|
t.Errorf("Components missing begin_0 key")
|
|
}
|
|
if len(c.Path) != 1 || c.Path[0] != "begin_0" {
|
|
t.Errorf("Path = %v, want [begin_0]", c.Path)
|
|
}
|
|
}
|
|
|
|
// TestDecodeCanvasFromDSL_MultiComponent pins the multi-node
|
|
// happy-path: Begin → Retrieval → Message → Answer decodes into
|
|
// 4 components with the correct Downstream / Upstream wiring.
|
|
// This is the shape real v1 DSLs use.
|
|
func TestDecodeCanvasFromDSL_MultiComponent(t *testing.T) {
|
|
t.Parallel()
|
|
dsl := map[string]any{
|
|
"components": map[string]any{
|
|
"begin_0": map[string]any{
|
|
"obj": map[string]any{
|
|
"component_name": "Begin",
|
|
"params": map[string]any{},
|
|
},
|
|
"downstream": []any{"retrieval_0"},
|
|
},
|
|
"retrieval_0": map[string]any{
|
|
"obj": map[string]any{
|
|
"component_name": "Retrieval",
|
|
"params": map[string]any{"kb_ids": []any{"kb-1"}},
|
|
},
|
|
"downstream": []any{"message_0"},
|
|
"upstream": []any{"begin_0"},
|
|
},
|
|
"message_0": map[string]any{
|
|
"obj": map[string]any{
|
|
"component_name": "Message",
|
|
"params": map[string]any{"text": "hi {{begin_0@result}}"},
|
|
},
|
|
"upstream": []any{"retrieval_0"},
|
|
},
|
|
},
|
|
"path": []any{"begin_0", "retrieval_0", "message_0"},
|
|
}
|
|
c, err := decodeCanvasFromDSL(dsl)
|
|
if err != nil {
|
|
t.Fatalf("decodeCanvasFromDSL: %v", err)
|
|
}
|
|
if len(c.Components) != 3 {
|
|
t.Errorf("Components length = %d, want 3", len(c.Components))
|
|
}
|
|
for _, id := range []string{"begin_0", "retrieval_0", "message_0"} {
|
|
if _, ok := c.Components[id]; !ok {
|
|
t.Errorf("Components missing %s", id)
|
|
}
|
|
}
|
|
if got := c.Components["retrieval_0"].Obj.ComponentName; got != "Retrieval" {
|
|
t.Errorf("retrieval_0 component_name = %q, want Retrieval", got)
|
|
}
|
|
}
|
|
|
|
// TestDecodeCanvasFromDSL_NoComponents pins the "non-empty map
|
|
// with no components" branch: a DSL whose top-level keys are
|
|
// non-component (e.g. only "globals") must still fail because the
|
|
// resulting Canvas would have an empty Components map. The error
|
|
// must chain ErrAgentStorageError so mapAgentError -> 500.
|
|
func TestDecodeCanvasFromDSL_NoComponents(t *testing.T) {
|
|
t.Parallel()
|
|
dsl := map[string]any{
|
|
"globals": map[string]any{
|
|
"sys.query": "",
|
|
},
|
|
}
|
|
_, err := decodeCanvasFromDSL(dsl)
|
|
if err == nil {
|
|
t.Fatal("expected error when DSL has no components")
|
|
}
|
|
if !errors.Is(err, ErrAgentStorageError) {
|
|
t.Errorf("expected ErrAgentStorageError in chain, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDecodeCanvasFromDSL_GlobalsPreserved pins that round-tripping
|
|
// the DSL through JSON does not lose top-level metadata keys
|
|
// (globals, history, retrieval). These are needed at compile time
|
|
// for state-pre / state-post handlers to resolve references.
|
|
func TestDecodeCanvasFromDSL_GlobalsPreserved(t *testing.T) {
|
|
t.Parallel()
|
|
dsl := map[string]any{
|
|
"components": map[string]any{
|
|
"begin_0": map[string]any{
|
|
"obj": map[string]any{
|
|
"component_name": "Begin",
|
|
"params": map[string]any{},
|
|
},
|
|
"downstream": []any{},
|
|
},
|
|
},
|
|
"globals": map[string]any{
|
|
"sys.query": "hello",
|
|
"sys.user_id": "user-1",
|
|
"env.counter": 0.0,
|
|
"env.loop_done": false,
|
|
"env.sample_rows": []any{},
|
|
},
|
|
}
|
|
c, err := decodeCanvasFromDSL(dsl)
|
|
if err != nil {
|
|
t.Fatalf("decodeCanvasFromDSL: %v", err)
|
|
}
|
|
if c.Globals == nil {
|
|
t.Fatal("Globals is nil after round-trip")
|
|
}
|
|
if got, _ := c.Globals["sys.query"].(string); got != "hello" {
|
|
t.Errorf("Globals[sys.query] = %v, want \"hello\"", c.Globals["sys.query"])
|
|
}
|
|
if got, _ := c.Globals["sys.user_id"].(string); got != "user-1" {
|
|
t.Errorf("Globals[sys.user_id] = %v, want \"user-1\"", c.Globals["sys.user_id"])
|
|
}
|
|
}
|
|
|
|
// TestNewAgentServiceWithOptions_NilOptions pins that the new
|
|
// constructor accepts all-nil options and produces a usable
|
|
// service (no panic on field init, no nil-deref when running
|
|
// without Redis). Existing test sites rely on this — the
|
|
// zero-arg NewAgentService() now just delegates here.
|
|
func TestNewAgentServiceWithOptions_NilOptions(t *testing.T) {
|
|
t.Parallel()
|
|
svc := NewAgentServiceWithOptions(nil, nil, nil)
|
|
if svc == nil {
|
|
t.Fatal("NewAgentServiceWithOptions returned nil")
|
|
}
|
|
if svc.runner == nil {
|
|
t.Error("runner is nil after construction")
|
|
}
|
|
if svc.checkpointStore != nil {
|
|
t.Error("checkpointStore should be nil when constructed with nil")
|
|
}
|
|
if svc.stateSerializer != nil {
|
|
t.Error("stateSerializer should be nil when constructed with nil")
|
|
}
|
|
if svc.runTracker != nil {
|
|
t.Error("runTracker should be nil when constructed with nil")
|
|
}
|
|
if svc.canvasDAO == nil || svc.versionDAO == nil {
|
|
t.Error("DAOs should be non-nil after construction")
|
|
}
|
|
}
|
|
|
|
// TestNewAgentService_DefaultsToNilOptions pins that the legacy
|
|
// zero-arg NewAgentService() is functionally equivalent to
|
|
// NewAgentServiceWithOptions(nil, nil, nil) — the field values
|
|
// must match exactly so existing call sites don't observe a
|
|
// behavioural change.
|
|
func TestNewAgentService_DefaultsToNilOptions(t *testing.T) {
|
|
t.Parallel()
|
|
a := NewAgentService()
|
|
b := NewAgentServiceWithOptions(nil, nil, nil)
|
|
if a.checkpointStore != b.checkpointStore {
|
|
t.Errorf("checkpointStore mismatch: %v vs %v", a.checkpointStore, b.checkpointStore)
|
|
}
|
|
if a.stateSerializer != b.stateSerializer {
|
|
t.Errorf("stateSerializer mismatch: %v vs %v", a.stateSerializer, b.stateSerializer)
|
|
}
|
|
if a.runTracker != b.runTracker {
|
|
t.Errorf("runTracker mismatch: %v vs %v", a.runTracker, b.runTracker)
|
|
}
|
|
}
|