Files
ragflow/internal/handler/agent_component_test.go
Hz_ c4166a91e0 fix(go-agent): align agent debug input form with Python (#16654)
## Summary

- derive Go Agent debug input forms from prompt variable references
instead of Agent meta fields
- seed `sys.*` debug params into `CanvasState.Sys` so single-component
debug resolves prompt variables like Python
- restore Agent test-run parity for form rendering and debug execution

## Tests

- `go test ./internal/agent/component -run
'TestAgent_(GetInputForm_UsesPromptReferences|GetInputForm_DeduplicatesPromptReferences|Meta_DefaultsToEmpty|Reset_NoTools)$'`
- `go test ./internal/handler -run
'Test(DebugComponent_SeedsSysInputsIntoCanvasState|DebugComponent_HappyPath_Begin|GetComponentInputForm_HappyPath)$'`

AFTER:
<img width="669" height="456" alt="image"
src="https://github.com/user-attachments/assets/4fd86559-aafc-4027-91ae-6e666137ee1b"
/>
2026-07-06 13:29:10 +08:00

427 lines
12 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.
//
package handler
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
agentruntime "ragflow/internal/agent/runtime"
_ "ragflow/internal/agent/component" // registers the production factory
"ragflow/internal/dao"
"ragflow/internal/entity"
)
// componentCtx builds a Gin context for one of the
// /agents/:canvas_id/components/:component_id/* endpoints. The
// supplied `body` is bound as the request body (empty for GET).
func componentCtx(t *testing.T, method, path, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
c.Request.Header.Set("Content-Type", "application/json")
}
c.Set("user", &entity.User{ID: "u-1"})
return c, w
}
// beginCanvas returns a UserCanvas whose DSL has a single Begin
// component with both an input_form and a params block. Used for
// happy-path tests.
func beginCanvas() *entity.UserCanvas {
return &entity.UserCanvas{
ID: "c1",
DSL: map[string]any{
"components": map[string]any{
"begin": map[string]any{
"obj": map[string]any{
"component_name": "Begin",
"params": map[string]any{
"mode": "Manual",
},
"input_form": map[string]any{
"query": map[string]any{"type": "string"},
},
},
},
},
},
}
}
// ---------- GetComponentInputForm ----------
func TestGetComponentInputForm_HappyPath(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
c, w := componentCtx(t, "GET", "/api/v1/agents/c1/components/begin/input-form", "")
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.GetComponentInputForm(c)
if w.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var env struct {
Code int `json:"code"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, w.Body.String())
}
q, ok := env.Data["query"].(map[string]interface{})
if !ok {
t.Fatalf("data.query = %v, want a map", env.Data["query"])
}
if q["type"] != "string" {
t.Errorf("data.query.type = %v, want string", q["type"])
}
}
func TestGetComponentInputForm_CanvasNotFound(t *testing.T) {
h := &AgentHandler{loader: &fakeCanvasLoader{err: dao.ErrUserCanvasNotFound}}
c, w := componentCtx(t, "GET", "/api/v1/agents/c1/components/begin/input-form", "")
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.GetComponentInputForm(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 103 { // CodeOperatingError — python @_require_canvas_access parity
t.Errorf("code = %d, want 103; msg=%q", code, msg)
}
want := "Make sure you have permission to access the agent."
if msg != want {
t.Errorf("msg = %q, want %q", msg, want)
}
}
func TestGetComponentInputForm_ComponentNotFound(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
c, w := componentCtx(t, "GET", "/api/v1/agents/c1/components/missing/input-form", "")
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "missing"},
}
h.GetComponentInputForm(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 102 { // CodeDataError
t.Errorf("code = %d, want 102; msg=%q", code, msg)
}
if !strings.Contains(msg, "component not found") {
t.Errorf("msg = %q, want 'component not found'", msg)
}
}
func TestGetComponentInputForm_NoInputForm(t *testing.T) {
cv := &entity.UserCanvas{
ID: "c1",
DSL: map[string]any{
"components": map[string]any{
"answer": map[string]any{
"obj": map[string]any{
"component_name": "Answer",
// no input_form
},
},
},
},
}
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
c, w := componentCtx(t, "GET", "/api/v1/agents/c1/components/answer/input-form", "")
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "answer"},
}
h.GetComponentInputForm(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 102 {
t.Errorf("code = %d, want 102; msg=%q", code, msg)
}
if !strings.Contains(msg, "no input_form") {
t.Errorf("msg = %q, want 'no input_form'", msg)
}
}
// ---------- DebugComponent ----------
func TestDebugComponent_HappyPath_Begin(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
body := `{"params":{"query":{"value":"hello world"}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/begin/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.DebugComponent(c)
if w.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
// Begin's Invoke is a passthrough — it returns the input map as
// outputs (see internal/agent/component/begin.go:96-98). Pin the
// exact passthrough so a regression that drops the `query` field
// would be caught (architect review A2).
var env struct {
Code int `json:"code"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, w.Body.String())
}
if env.Code != 0 {
t.Errorf("code = %d, want 0; body=%s", env.Code, w.Body.String())
}
if env.Data["query"] != "hello world" {
t.Errorf("data.query = %v, want 'hello world'", env.Data["query"])
}
}
type sysEchoComponent struct{}
func (s *sysEchoComponent) Invoke(ctx context.Context, _ map[string]any) (map[string]any, error) {
state, _, err := agentruntime.GetStateFromContext[*agentruntime.CanvasState](ctx)
if err != nil {
return nil, err
}
return map[string]any{
"query": state.Sys["query"],
"tenant_id": state.Sys["tenant_id"],
}, nil
}
func TestDebugComponent_SeedsSysInputsIntoCanvasState(t *testing.T) {
origFactory := agentruntime.DefaultFactory()
agentruntime.SetDefaultFactory(func(name string, _ map[string]any) (agentruntime.Component, error) {
if name != "Probe" {
return nil, errors.New("unexpected component name: " + name)
}
return &sysEchoComponent{}, nil
})
t.Cleanup(func() { agentruntime.SetDefaultFactory(origFactory) })
cv := &entity.UserCanvas{
ID: "c1",
DSL: map[string]any{
"components": map[string]any{
"probe": map[string]any{
"obj": map[string]any{
"component_name": "Probe",
"params": map[string]any{},
},
},
},
},
}
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
body := `{"params":{"sys.query":{"value":"hello sys"}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/probe/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "probe"},
}
h.DebugComponent(c)
if w.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var env struct {
Code int `json:"code"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, w.Body.String())
}
if env.Data["query"] != "hello sys" {
t.Fatalf("data.query = %v, want 'hello sys'", env.Data["query"])
}
}
func TestDebugComponent_RejectsSysTenantIDOverride(t *testing.T) {
origFactory := agentruntime.DefaultFactory()
agentruntime.SetDefaultFactory(func(name string, _ map[string]any) (agentruntime.Component, error) {
if name != "Probe" {
return nil, errors.New("unexpected component name: " + name)
}
return &sysEchoComponent{}, nil
})
t.Cleanup(func() { agentruntime.SetDefaultFactory(origFactory) })
cv := &entity.UserCanvas{
ID: "c1",
DSL: map[string]any{
"components": map[string]any{
"probe": map[string]any{
"obj": map[string]any{
"component_name": "Probe",
"params": map[string]any{},
},
},
},
},
}
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
body := `{"params":{"sys.query":{"value":"hello sys"},"sys.tenant_id":{"value":"attacker-tenant"}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/probe/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "probe"},
}
h.DebugComponent(c)
if w.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var env struct {
Code int `json:"code"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, w.Body.String())
}
if env.Data["tenant_id"] != "u-1" {
t.Fatalf("data.tenant_id = %v, want u-1", env.Data["tenant_id"])
}
}
func TestDebugComponent_InvalidParams_MissingField(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
// No `params` field.
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/begin/debug", `{}`)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.DebugComponent(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 101 { // CodeArgumentError
t.Errorf("code = %d, want 101; msg=%q", code, msg)
}
if !strings.Contains(msg, "params") {
t.Errorf("msg = %q, want 'params'", msg)
}
}
// TestDebugComponent_InvalidParams_MissingValue covers CodeRabbit
// PR review #2: `{"params":{"q":{}}}` (no `value` key) should
// fail-fast with 101 rather than silently invoking the component
// with inputs["q"]=nil.
func TestDebugComponent_InvalidParams_MissingValue(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/begin/debug", `{"params":{"q":{}}}`)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.DebugComponent(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 101 {
t.Errorf("code = %d, want 101; msg=%q", code, msg)
}
if !strings.Contains(msg, "value") {
t.Errorf("msg = %q, want mention of 'value'", msg)
}
}
func TestDebugComponent_UnknownComponent(t *testing.T) {
cv := beginCanvas()
h := &AgentHandler{loader: &fakeCanvasLoader{canvas: cv}}
body := `{"params":{"x":{"value":1}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/missing/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "missing"},
}
h.DebugComponent(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 102 {
t.Errorf("code = %d, want 102; msg=%q", code, msg)
}
if !strings.Contains(msg, "component not found") {
t.Errorf("msg = %q, want 'component not found'", msg)
}
}
func TestDebugComponent_CannotAccessCanvas(t *testing.T) {
h := &AgentHandler{loader: &fakeCanvasLoader{err: dao.ErrUserCanvasNotFound}}
body := `{"params":{"x":{"value":1}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/begin/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.DebugComponent(c)
code, msg := errBody(t, w.Body.Bytes())
if code != 103 { // CodeOperatingError
t.Errorf("code = %d, want 103; msg=%q", code, msg)
}
want := "Make sure you have permission to access the agent."
if msg != want {
t.Errorf("msg = %q, want %q", msg, want)
}
}
func TestDebugComponent_LoaderError_NonNotFound(t *testing.T) {
h := &AgentHandler{loader: &fakeCanvasLoader{err: errors.New("db conn lost")}}
body := `{"params":{"x":{"value":1}}}`
c, w := componentCtx(t, "POST", "/api/v1/agents/c1/components/begin/debug", body)
c.Params = gin.Params{
{Key: "canvas_id", Value: "c1"},
{Key: "component_id", Value: "begin"},
}
h.DebugComponent(c)
code, _ := errBody(t, w.Body.Bytes())
if code != 500 { // CodeServerError
t.Errorf("code = %d, want 500", code)
}
}