mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +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
230 lines
6.0 KiB
Go
230 lines
6.0 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 tool
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestEmail_BuildMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
msg := buildEmailMessage(
|
|
"alice@example.com",
|
|
[]string{"bob@example.com", "carol@example.com"},
|
|
"Hello, world",
|
|
"Body of the message.",
|
|
)
|
|
|
|
s := string(msg)
|
|
for _, want := range []string{
|
|
"From: alice@example.com",
|
|
"To: bob@example.com, carol@example.com",
|
|
"Subject: Hello, world",
|
|
"Content-Type: text/plain; charset=UTF-8",
|
|
"Body of the message.",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("message missing %q\n--- message ---\n%s\n---", want, s)
|
|
}
|
|
}
|
|
// RFC 822 mandates a blank line between headers and body.
|
|
if !strings.Contains(s, "\r\n\r\n") {
|
|
t.Errorf("message missing blank line between headers and body\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestEmail_SendAgainstMockSMTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Spin up a minimal SMTP server: read commands, respond 250 to
|
|
// everything, and copy the DATA payload bytes so the test can
|
|
// inspect them.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
var receivedData strings.Builder
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
reader := bufio.NewReader(conn)
|
|
writer := bufio.NewWriter(conn)
|
|
// Greeting
|
|
_, _ = writer.WriteString("220 mock-smtp ready\r\n")
|
|
_ = writer.Flush()
|
|
inData := false
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
up := strings.ToUpper(strings.TrimSpace(line))
|
|
switch {
|
|
case strings.HasPrefix(up, "EHLO"), strings.HasPrefix(up, "HELO"):
|
|
_, _ = writer.WriteString("250-mock-smtp\r\n250 OK\r\n")
|
|
_ = writer.Flush()
|
|
case strings.HasPrefix(up, "MAIL FROM:"), strings.HasPrefix(up, "RCPT TO:"):
|
|
_, _ = writer.WriteString("250 OK\r\n")
|
|
_ = writer.Flush()
|
|
case strings.HasPrefix(up, "DATA"):
|
|
_, _ = writer.WriteString("354 End data with <CR><LF>.<CR><LF>\r\n")
|
|
_ = writer.Flush()
|
|
inData = true
|
|
case inData && strings.TrimSpace(line) == ".":
|
|
_, _ = writer.WriteString("250 Queued\r\n")
|
|
_ = writer.Flush()
|
|
inData = false
|
|
case inData:
|
|
receivedData.WriteString(line)
|
|
case strings.HasPrefix(up, "QUIT"):
|
|
_, _ = writer.WriteString("221 Bye\r\n")
|
|
_ = writer.Flush()
|
|
return
|
|
default:
|
|
_, _ = writer.WriteString("250 OK\r\n")
|
|
_ = writer.Flush()
|
|
}
|
|
}
|
|
}()
|
|
|
|
host, port, err := net.SplitHostPort(ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("SplitHostPort: %v", err)
|
|
}
|
|
_ = host
|
|
var portInt int
|
|
_, _ = fmt.Sscanf(port, "%d", &portInt)
|
|
|
|
tool := NewEmailTool()
|
|
args := map[string]any{
|
|
"smtp_host": "127.0.0.1",
|
|
"smtp_port": portInt,
|
|
"from_addr": "alice@example.com",
|
|
"to_addrs": []string{"bob@example.com"},
|
|
"subject": "Test Subject",
|
|
"body": "Test body content.",
|
|
}
|
|
argsJSON, _ := json.Marshal(args)
|
|
out, err := tool.InvokableRun(context.Background(), string(argsJSON))
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun: %v", err)
|
|
}
|
|
|
|
var env emailEnvelope
|
|
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
|
t.Fatalf("output is not valid JSON: %v (raw=%s)", jerr, out)
|
|
}
|
|
if env.Error != "" {
|
|
t.Errorf("Error = %q, want empty", env.Error)
|
|
}
|
|
if !env.OK {
|
|
t.Errorf("OK = false, want true")
|
|
}
|
|
|
|
// Wait for the mock server to finish.
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("mock SMTP server did not close in time")
|
|
}
|
|
|
|
if !strings.Contains(receivedData.String(), "Subject: Test Subject") {
|
|
t.Errorf("mock server did not receive subject\n--- data ---\n%s\n---",
|
|
receivedData.String())
|
|
}
|
|
if !strings.Contains(receivedData.String(), "bob@example.com") {
|
|
t.Errorf("mock server did not receive recipient\n--- data ---\n%s\n---",
|
|
receivedData.String())
|
|
}
|
|
if !strings.Contains(receivedData.String(), "Test body content.") {
|
|
t.Errorf("mock server did not receive body\n--- data ---\n%s\n---",
|
|
receivedData.String())
|
|
}
|
|
}
|
|
|
|
func TestEmail_RequiresFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tool := NewEmailTool()
|
|
|
|
cases := []struct {
|
|
name string
|
|
args string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing smtp_host",
|
|
args: `{"smtp_port":587,"from_addr":"a@b","to_addrs":["c@d"],"subject":"s","body":"b"}`,
|
|
wantErr: "smtp_host",
|
|
},
|
|
{
|
|
name: "missing to_addrs",
|
|
args: `{"smtp_host":"x","smtp_port":587,"from_addr":"a@b","subject":"s","body":"b"}`,
|
|
wantErr: "to_addrs",
|
|
},
|
|
{
|
|
name: "bad smtp_port",
|
|
args: `{"smtp_host":"x","smtp_port":0,"from_addr":"a@b","to_addrs":["c@d"],"subject":"s","body":"b"}`,
|
|
wantErr: "smtp_port",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := tool.InvokableRun(context.Background(), tc.args)
|
|
if err == nil {
|
|
t.Fatalf("expected error for %s", tc.name)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Errorf("err = %v, want to contain %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmail_Info(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tool := NewEmailTool()
|
|
info, err := tool.Info(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Info: %v", err)
|
|
}
|
|
if info.Name != "email" {
|
|
t.Errorf("Name = %q, want email", info.Name)
|
|
}
|
|
if !strings.Contains(info.Desc, "SMTP") {
|
|
t.Errorf("Desc = %q, want to mention SMTP", info.Desc)
|
|
}
|
|
}
|