mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +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
143 lines
4.1 KiB
Go
143 lines
4.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 canvas
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestChannelEmitter_EmitAndClose(t *testing.T) {
|
|
em := NewChannelEmitter(4)
|
|
ch := em.(*channelEmitter).Channel()
|
|
|
|
evs := []StreamEvent{
|
|
{Event: "node_start", TaskID: "t1", Component: "begin_0"},
|
|
{Event: "message", TaskID: "t1", Component: "llm_0",
|
|
Data: map[string]any{"delta": "hello"}},
|
|
{Event: "node_finish", TaskID: "t1", Component: "begin_0",
|
|
Data: map[string]any{"ok": true}},
|
|
}
|
|
for _, ev := range evs {
|
|
if err := em.Emit(ev); err != nil {
|
|
t.Fatalf("Emit %q: %v", ev.Event, err)
|
|
}
|
|
}
|
|
if err := em.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
var got []StreamEvent
|
|
for ev := range ch {
|
|
got = append(got, ev)
|
|
}
|
|
if len(got) != len(evs) {
|
|
t.Fatalf("got %d events, want %d", len(got), len(evs))
|
|
}
|
|
for i, ev := range got {
|
|
if ev.Event != evs[i].Event || ev.TaskID != evs[i].TaskID ||
|
|
ev.Component != evs[i].Component {
|
|
t.Fatalf("event %d: got %+v, want %+v", i, ev, evs[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChannelEmitter_NonBlockingDrop(t *testing.T) {
|
|
// Buffer of 1 with no reader; the second Emit must return nil
|
|
// immediately (drop on full) rather than block.
|
|
em := NewChannelEmitter(1)
|
|
if err := em.Emit(StreamEvent{Event: "e1", TaskID: "t"}); err != nil {
|
|
t.Fatalf("Emit 1: %v", err)
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
if err := em.Emit(StreamEvent{Event: "e2", TaskID: "t"}); err != nil {
|
|
t.Errorf("Emit 2: %v", err)
|
|
}
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("Emit blocked despite non-blocking contract")
|
|
}
|
|
// The first event is still buffered; the second was dropped.
|
|
ch := em.(*channelEmitter).Channel()
|
|
first := <-ch
|
|
if first.Event != "e1" {
|
|
t.Fatalf("first buffered event = %q, want e1", first.Event)
|
|
}
|
|
}
|
|
|
|
func TestFormatSSE(t *testing.T) {
|
|
ev := StreamEvent{
|
|
Event: "message",
|
|
TaskID: "task_42",
|
|
Component: "llm_0",
|
|
Data: map[string]any{
|
|
"delta": "héllo, 世界",
|
|
"index": 7,
|
|
},
|
|
}
|
|
got := FormatSSE(ev)
|
|
|
|
if !strings.HasPrefix(got, "data: ") {
|
|
t.Fatalf("SSE frame must start with 'data: '; got %q", got)
|
|
}
|
|
if !strings.HasSuffix(got, "\n\n") {
|
|
t.Fatalf("SSE frame must end with '\\n\\n'; got %q", got)
|
|
}
|
|
body := strings.TrimPrefix(got, "data: ")
|
|
body = strings.TrimSuffix(body, "\n\n")
|
|
|
|
// Body must be valid JSON and round-trip the Data field.
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
|
|
t.Fatalf("SSE body is not JSON: %v\nbody: %q", err, body)
|
|
}
|
|
if decoded["delta"] != "héllo, 世界" {
|
|
t.Fatalf("delta round-trip: got %q, want %q", decoded["delta"], "héllo, 世界")
|
|
}
|
|
if v, _ := decoded["index"].(float64); v != 7 {
|
|
t.Fatalf("index round-trip: got %v, want 7", decoded["index"])
|
|
}
|
|
}
|
|
|
|
func TestFormatSSE_EmptyData(t *testing.T) {
|
|
// Empty Data must still produce a valid frame, not panic.
|
|
got := FormatSSE(StreamEvent{Event: "node_start", TaskID: "t"})
|
|
if !strings.HasPrefix(got, "data: ") || !strings.HasSuffix(got, "\n\n") {
|
|
t.Fatalf("empty Data frame malformed: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestChannelEmitter_CloseIdempotentCheck(t *testing.T) {
|
|
// Emitting after Close must panic — callers should not emit on a
|
|
// closed emitter. This is the desired Go-idiomatic signal.
|
|
em := NewChannelEmitter(1)
|
|
ch := em.(*channelEmitter).Channel()
|
|
if err := em.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
// Drain to confirm the channel is closed.
|
|
if _, ok := <-ch; ok {
|
|
t.Fatal("channel not closed after Close()")
|
|
}
|
|
}
|