mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +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>
220 lines
7.1 KiB
Go
220 lines
7.1 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 component
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestMatchOutputStructure_ValidJSON_AllKeysPresent: response has all
|
|
// expected keys → ok.
|
|
func TestMatchOutputStructure_ValidJSON_AllKeysPresent(t *testing.T) {
|
|
resp := `{"name":"Alice","age":30}`
|
|
parsed, ok := matchOutputStructure(resp, map[string]any{"name": "", "age": 0})
|
|
if !ok {
|
|
t.Fatalf("expected match")
|
|
}
|
|
if parsed["name"] != "Alice" {
|
|
t.Errorf("name=%v, want Alice", parsed["name"])
|
|
}
|
|
if parsed["age"].(float64) != 30 {
|
|
t.Errorf("age=%v, want 30", parsed["age"])
|
|
}
|
|
}
|
|
|
|
// TestMatchOutputStructure_ValidJSON_MissingKey: parse OK but a key
|
|
// is missing → not ok.
|
|
func TestMatchOutputStructure_ValidJSON_MissingKey(t *testing.T) {
|
|
resp := `{"name":"Alice"}`
|
|
_, ok := matchOutputStructure(resp, map[string]any{"name": "", "age": 0})
|
|
if ok {
|
|
t.Fatalf("expected mismatch (age key missing)")
|
|
}
|
|
}
|
|
|
|
// TestMatchOutputStructure_NotJSON: invalid JSON → not ok.
|
|
func TestMatchOutputStructure_NotJSON(t *testing.T) {
|
|
resp := "this is not json"
|
|
_, ok := matchOutputStructure(resp, map[string]any{"x": 0})
|
|
if ok {
|
|
t.Fatalf("expected mismatch (not valid JSON)")
|
|
}
|
|
}
|
|
|
|
// TestMatchOutputStructure_EmptyExpected: no expected keys → any
|
|
// JSON object passes (vacuous truth).
|
|
func TestMatchOutputStructure_EmptyExpected(t *testing.T) {
|
|
resp := `{"anything":1}`
|
|
_, ok := matchOutputStructure(resp, map[string]any{})
|
|
if !ok {
|
|
t.Fatalf("expected match with empty expected set")
|
|
}
|
|
}
|
|
|
|
// TestBuildStructuredRetryMessages_AppendsRetryTurn: the returned
|
|
// message list's last message is the retry user turn, and the system
|
|
// message still reflects the citation prompt (when cite=true).
|
|
func TestBuildStructuredRetryMessages_AppendsRetryTurn(t *testing.T) {
|
|
msgs := buildStructuredRetryMessages("sys", "user", nil, true,
|
|
map[string]any{"name": "", "age": 0},
|
|
"first response was not JSON")
|
|
if len(msgs) < 1 {
|
|
t.Fatalf("expected at least 1 message, got %d", len(msgs))
|
|
}
|
|
last := msgs[len(msgs)-1]
|
|
if last.Role != "user" {
|
|
t.Fatalf("expected last role=user, got %v", last.Role)
|
|
}
|
|
if !strings.Contains(last.Content, "name") || !strings.Contains(last.Content, "age") {
|
|
t.Errorf("retry prompt missing expected keys; got: %s", last.Content)
|
|
}
|
|
if !strings.Contains(last.Content, "first response was not JSON") {
|
|
t.Errorf("retry prompt should reference the previous response; got: %s", last.Content[:200])
|
|
}
|
|
if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "[ID:") {
|
|
t.Errorf("system message lost; got role=%v content[:80]=%q", msgs[0].Role, msgs[0].Content[:80])
|
|
}
|
|
}
|
|
|
|
// TestLLM_Invoke_OutputStructure_ValidFirstTry: stub returns valid
|
|
// JSON on the first call → no retry; outputs["structured"] populated.
|
|
func TestLLM_Invoke_OutputStructure_ValidFirstTry(t *testing.T) {
|
|
stub := &stubInvoker{resp: &ChatInvokeResponse{
|
|
Content: `{"name":"Alice","age":30}`,
|
|
Model: "echo",
|
|
}}
|
|
withStubInvoker(t, stub)
|
|
|
|
c := NewLLMComponent(LLMParam{
|
|
ModelID: "echo",
|
|
OutputStructure: map[string]any{"name": "", "age": 0},
|
|
})
|
|
out, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "who?"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if stub.calls != 1 {
|
|
t.Errorf("expected 1 call (no retry), got %d", stub.calls)
|
|
}
|
|
structured, ok := out["structured"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("structured not populated; out=%+v", out)
|
|
}
|
|
if structured["name"] != "Alice" {
|
|
t.Errorf("name=%v, want Alice", structured["name"])
|
|
}
|
|
}
|
|
|
|
// TestLLM_Invoke_OutputStructure_RetryOnInvalid: stub returns
|
|
// non-JSON first, then valid JSON → retry happens; outputs["structured"]
|
|
// populated from second call.
|
|
func TestLLM_Invoke_OutputStructure_RetryOnInvalid(t *testing.T) {
|
|
calls := 0
|
|
inv := &callCountingInvoker{
|
|
responses: []*ChatInvokeResponse{
|
|
{Content: "not json at all", Model: "echo"},
|
|
{Content: `{"name":"Bob"}`, Model: "echo"},
|
|
},
|
|
onCall: func() { calls++ },
|
|
}
|
|
withStubInvoker(t, inv)
|
|
|
|
c := NewLLMComponent(LLMParam{
|
|
ModelID: "echo",
|
|
OutputStructure: map[string]any{"name": ""},
|
|
})
|
|
out, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "who?"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Errorf("expected 2 calls (first + retry), got %d", calls)
|
|
}
|
|
structured, ok := out["structured"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("structured not populated after retry; out=%+v", out)
|
|
}
|
|
if structured["name"] != "Bob" {
|
|
t.Errorf("name=%v, want Bob", structured["name"])
|
|
}
|
|
if out["content"] != `{"name":"Bob"}` {
|
|
t.Errorf("content not updated to validated response; got %q", out["content"])
|
|
}
|
|
}
|
|
|
|
// TestLLM_Invoke_OutputStructure_RetryStillFails: stub returns
|
|
// non-JSON both times → no structured output, content kept as first
|
|
// response (caller can still see it), no error.
|
|
func TestLLM_Invoke_OutputStructure_RetryStillFails(t *testing.T) {
|
|
calls := 0
|
|
inv := &callCountingInvoker{
|
|
responses: []*ChatInvokeResponse{
|
|
{Content: "not json", Model: "echo"},
|
|
{Content: "still not json", Model: "echo"},
|
|
},
|
|
onCall: func() { calls++ },
|
|
}
|
|
withStubInvoker(t, inv)
|
|
|
|
c := NewLLMComponent(LLMParam{
|
|
ModelID: "echo",
|
|
OutputStructure: map[string]any{"x": 0},
|
|
})
|
|
out, err := c.Invoke(context.Background(), map[string]any{"user_prompt": "go"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke should not error on parse failure: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Errorf("expected 2 calls (first + 1 retry), got %d", calls)
|
|
}
|
|
if _, hasStructured := out["structured"]; hasStructured {
|
|
t.Errorf("structured should be absent after failed retry; out=%+v", out)
|
|
}
|
|
// content stays as the original (failed) first response
|
|
if out["content"] != "not json" {
|
|
t.Errorf("content should remain the first response; got %q", out["content"])
|
|
}
|
|
}
|
|
|
|
// callCountingInvoker is a test-only ChatInvoker that returns
|
|
// pre-programmed responses in sequence and counts invocations.
|
|
type callCountingInvoker struct {
|
|
responses []*ChatInvokeResponse
|
|
onCall func()
|
|
calls int
|
|
}
|
|
|
|
func (c *callCountingInvoker) Invoke(_ context.Context, _ ChatInvokeRequest) (*ChatInvokeResponse, error) {
|
|
if c.onCall != nil {
|
|
c.onCall()
|
|
}
|
|
c.calls++
|
|
if c.calls-1 < len(c.responses) {
|
|
return c.responses[c.calls-1], nil
|
|
}
|
|
return &ChatInvokeResponse{Content: "exhausted"}, nil
|
|
}
|
|
|
|
func (c *callCountingInvoker) Stream(_ context.Context, _ ChatInvokeRequest) (<-chan map[string]any, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *callCountingInvoker) Inputs() map[string]string { return nil }
|
|
func (c *callCountingInvoker) Outputs() map[string]string { return nil }
|