test: 添加各模块单元测试(config/llm/service/handler)
This commit is contained in:
499
backend/internal/service/agent_test.go
Normal file
499
backend/internal/service/agent_test.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ai-agent-scaffold-go/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Stub 实现
|
||||
// ============================================================
|
||||
|
||||
// stubChatModel 实现 ChatModelWithTools 接口
|
||||
type stubChatModel struct {
|
||||
generateReply model.ChatReply
|
||||
generateErr error
|
||||
streamDelta string
|
||||
streamErr error
|
||||
toolResult string
|
||||
toolErr error
|
||||
}
|
||||
|
||||
func (m *stubChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||
return m.generateReply, m.generateErr
|
||||
}
|
||||
|
||||
func (m *stubChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||
events := make(chan model.ChatStreamEvent, 4)
|
||||
errs := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(errs)
|
||||
if m.streamErr != nil {
|
||||
errs <- m.streamErr
|
||||
return
|
||||
}
|
||||
// 模拟逐字输出
|
||||
for _, ch := range m.streamDelta {
|
||||
events <- model.ChatStreamEvent{Delta: string(ch)}
|
||||
}
|
||||
events <- model.ChatStreamEvent{Done: true}
|
||||
}()
|
||||
|
||||
return events, errs
|
||||
}
|
||||
|
||||
func (m *stubChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
return m.toolResult, m.toolErr
|
||||
}
|
||||
|
||||
// stubToolCallModel 每次都返回工具调用的 stub
|
||||
type stubToolCallModel struct {
|
||||
callCount int
|
||||
maxCalls int // 达到此次数后返回文本
|
||||
}
|
||||
|
||||
func (m *stubToolCallModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||
m.callCount++
|
||||
if m.callCount > m.maxCalls {
|
||||
return model.ChatReply{Content: "done"}, nil
|
||||
}
|
||||
return model.ChatReply{ToolCalls: []model.ChatToolCall{
|
||||
{ID: fmt.Sprintf("call_%d", m.callCount), Name: "tool1", Arguments: `{"query":"test"}`},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (m *stubToolCallModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||
events := make(chan model.ChatStreamEvent, 4)
|
||||
errs := make(chan error, 1)
|
||||
m.callCount++
|
||||
if m.callCount > m.maxCalls {
|
||||
events <- model.ChatStreamEvent{Delta: "done", Done: true}
|
||||
} else {
|
||||
events <- model.ChatStreamEvent{ToolCalls: []model.ChatToolCall{
|
||||
{ID: fmt.Sprintf("call_%d", m.callCount), Name: "tool1", Arguments: `{"query":"test"}`},
|
||||
}}
|
||||
}
|
||||
close(events)
|
||||
close(errs)
|
||||
return events, errs
|
||||
}
|
||||
|
||||
func (m *stubToolCallModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
return "tool-result", nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func newTestLLMAgent(cm ChatModelWithTools) *LLMAgent {
|
||||
return NewLLMAgent("test-agent", "you are a test agent", "test desc", "", cm)
|
||||
}
|
||||
|
||||
func testContent(msg string) model.ChatContent {
|
||||
return model.ChatContent{Texts: []model.TextPart{{Message: msg}}}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LLMAgent 测试
|
||||
// ============================================================
|
||||
|
||||
func TestLLMAgent_Run_ReturnsContent(t *testing.T) {
|
||||
cm := &stubChatModel{generateReply: model.ChatReply{Content: "hello"}}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
result, err := agent.Run(context.Background(), testContent("hi"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello", result)
|
||||
}
|
||||
|
||||
func TestLLMAgent_Run_ToolCallLoop_TwoRounds(t *testing.T) {
|
||||
cm := &stubToolCallModel{maxCalls: 1}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
result, err := agent.Run(context.Background(), testContent("hi"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "done", result)
|
||||
assert.Equal(t, 2, cm.callCount)
|
||||
}
|
||||
|
||||
func TestLLMAgent_Run_ExceedsIterationLimit_ReturnsError(t *testing.T) {
|
||||
cm := &stubToolCallModel{maxCalls: 100} // 永远返回工具调用
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded tool-call iteration limit")
|
||||
}
|
||||
|
||||
func TestLLMAgent_Run_GenerateError_ReturnsError(t *testing.T) {
|
||||
cm := &stubChatModel{generateErr: fmt.Errorf("llm down")}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "llm down")
|
||||
}
|
||||
|
||||
func TestLLMAgent_Run_ToolCallError_ReturnsError(t *testing.T) {
|
||||
cm := &stubChatModel{
|
||||
generateReply: model.ChatReply{ToolCalls: []model.ChatToolCall{
|
||||
{ID: "c1", Name: "bad-tool", Arguments: "{}"},
|
||||
}},
|
||||
toolErr: fmt.Errorf("tool failed"),
|
||||
}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
_, err := agent.Run(context.Background(), testContent("hi"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "tool failed")
|
||||
}
|
||||
|
||||
func TestLLMAgent_Stream_ReturnsChunks(t *testing.T) {
|
||||
cm := &stubChatModel{streamDelta: "hello"}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
out := make(chan string, 10)
|
||||
err := agent.Stream(context.Background(), testContent("hi"), out)
|
||||
assert.NoError(t, err)
|
||||
close(out)
|
||||
|
||||
var texts []string
|
||||
for s := range out {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
assert.Equal(t, []string{"h", "e", "l", "l", "o"}, texts)
|
||||
}
|
||||
|
||||
func TestLLMAgent_Stream_Error_ReturnsError(t *testing.T) {
|
||||
cm := &stubChatModel{streamErr: fmt.Errorf("stream failed")}
|
||||
agent := newTestLLMAgent(cm)
|
||||
|
||||
out := make(chan string, 10)
|
||||
err := agent.Stream(context.Background(), testContent("hi"), out)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLLMAgent_Name_ReturnsName(t *testing.T) {
|
||||
cm := &stubChatModel{}
|
||||
agent := NewLLMAgent("my-agent", "inst", "desc", "key", cm)
|
||||
assert.Equal(t, "my-agent", agent.Name())
|
||||
assert.Equal(t, "key", agent.OutputKey())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SequentialAgent 测试
|
||||
// ============================================================
|
||||
|
||||
func TestSequentialAgent_Run_ExecutesInOrder(t *testing.T) {
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||
result, err := seq.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "second", result) // 返回最后一个的结果
|
||||
}
|
||||
|
||||
func TestSequentialAgent_Run_PassesOutputKey(t *testing.T) {
|
||||
// sub1 输出 "first",存入 vars["out1"]
|
||||
// sub2 的 instruction 包含 {out1},应被替换
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "got-first"}}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||
sub2 := NewLLMAgent("a2", "instruction with {out1}", "", "", cm2)
|
||||
|
||||
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||
result, err := seq.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "got-first", result)
|
||||
}
|
||||
|
||||
func TestSequentialAgent_Run_SubAgentError_StopsExecution(t *testing.T) {
|
||||
cm1 := &stubChatModel{generateErr: fmt.Errorf("fail")}
|
||||
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||
_, err := seq.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSequentialAgent_Stream_LastAgentStreams(t *testing.T) {
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||
cm2 := &stubChatModel{streamDelta: "stream"}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "out1", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
seq := NewSequentialAgent("seq", "desc", []model.Agent{sub1, sub2})
|
||||
out := make(chan string, 10)
|
||||
err := seq.Stream(context.Background(), testContent("hi"), out)
|
||||
close(out)
|
||||
|
||||
assert.NoError(t, err)
|
||||
var texts []string
|
||||
for s := range out {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
assert.Equal(t, []string{"s", "t", "r", "e", "a", "m"}, texts)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ParallelAgent 测试
|
||||
// ============================================================
|
||||
|
||||
func TestParallelAgent_Run_ConcatenatesResults(t *testing.T) {
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "aaa"}}
|
||||
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "bbb"}}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||
result, err := par.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "[a1] aaa")
|
||||
assert.Contains(t, result, "[a2] bbb")
|
||||
}
|
||||
|
||||
func TestParallelAgent_Run_SubAgentError_ReturnsError(t *testing.T) {
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "ok"}}
|
||||
cm2 := &stubChatModel{generateErr: fmt.Errorf("fail")}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||
_, err := par.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParallelAgent_Run_ConcurrentExecution(t *testing.T) {
|
||||
// 验证并发执行:两个 agent 都被调用
|
||||
cm1 := &stubChatModel{generateReply: model.ChatReply{Content: "first"}}
|
||||
cm2 := &stubChatModel{generateReply: model.ChatReply{Content: "second"}}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||
result, err := par.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
// 结果应包含两个 agent 的输出
|
||||
assert.True(t, strings.Contains(result, "first"))
|
||||
assert.True(t, strings.Contains(result, "second"))
|
||||
}
|
||||
|
||||
func TestParallelAgent_Stream_OutputsConcurrently(t *testing.T) {
|
||||
// 使用单次输出的 stub 避免逐字符并发竞争
|
||||
cm1 := &singleShotChatModel{content: "result-a"}
|
||||
cm2 := &singleShotChatModel{content: "result-b"}
|
||||
|
||||
sub1 := NewLLMAgent("a1", "inst1", "", "", cm1)
|
||||
sub2 := NewLLMAgent("a2", "inst2", "", "", cm2)
|
||||
|
||||
par := NewParallelAgent("par", "desc", []model.Agent{sub1, sub2})
|
||||
out := make(chan string, 20)
|
||||
err := par.Stream(context.Background(), testContent("hi"), out)
|
||||
close(out)
|
||||
|
||||
assert.NoError(t, err)
|
||||
var texts []string
|
||||
for s := range out {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
full := strings.Join(texts, "")
|
||||
assert.Contains(t, full, "[a1]")
|
||||
assert.Contains(t, full, "[a2]")
|
||||
assert.Contains(t, full, "result-a")
|
||||
assert.Contains(t, full, "result-b")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LoopAgent 测试
|
||||
// ============================================================
|
||||
|
||||
func TestLoopAgent_Run_RepeatsSubAgents(t *testing.T) {
|
||||
cm := &stubChatModel{generateReply: model.ChatReply{Content: "tick"}}
|
||||
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||
|
||||
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 3)
|
||||
result, err := loop.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "[a1] tick")
|
||||
}
|
||||
|
||||
func TestLoopAgent_Run_DefaultMaxIterations(t *testing.T) {
|
||||
cm := &stubChatModel{generateReply: model.ChatReply{Content: "ok"}}
|
||||
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||
|
||||
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 0) // 0 → 默认 3
|
||||
assert.Equal(t, 3, loop.maxIterations)
|
||||
}
|
||||
|
||||
func TestLoopAgent_Run_SubAgentError_StopsLoop(t *testing.T) {
|
||||
callCount := 0
|
||||
errModel := &stubChatModel{}
|
||||
errModel.generateErr = fmt.Errorf("fail on call")
|
||||
|
||||
// 用一个计数 stub
|
||||
countModel := &countingChatModel{reply: model.ChatReply{Content: "ok"}, failAfter: 2}
|
||||
sub := NewLLMAgent("a1", "inst1", "", "", countModel)
|
||||
|
||||
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 5)
|
||||
_, err := loop.Run(context.Background(), testContent("hi"))
|
||||
|
||||
assert.Error(t, err)
|
||||
_ = callCount
|
||||
_ = errModel
|
||||
}
|
||||
|
||||
func TestLoopAgent_Stream_ExecutesAndStreams(t *testing.T) {
|
||||
cm := &stubChatModel{streamDelta: "loop"}
|
||||
sub := NewLLMAgent("a1", "inst1", "", "", cm)
|
||||
|
||||
loop := NewLoopAgent("loop", "desc", []model.Agent{sub}, 2)
|
||||
out := make(chan string, 20)
|
||||
err := loop.Stream(context.Background(), testContent("hi"), out)
|
||||
close(out)
|
||||
|
||||
assert.NoError(t, err)
|
||||
var texts []string
|
||||
for s := range out {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
full := strings.Join(texts, "")
|
||||
assert.Contains(t, full, "[a1]")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数测试
|
||||
// ============================================================
|
||||
|
||||
func TestCloneVars_CreatesIndependentCopy(t *testing.T) {
|
||||
orig := map[string]string{"a": "1", "b": "2"}
|
||||
cloned := cloneVars(orig)
|
||||
cloned["c"] = "3"
|
||||
assert.NotContains(t, orig, "c")
|
||||
}
|
||||
|
||||
func TestApplyVars_ReplacesPlaceholders(t *testing.T) {
|
||||
vars := map[string]string{"name": "world", "greeting": "hello"}
|
||||
result := applyVars("{greeting} {name}!", vars)
|
||||
assert.Equal(t, "hello world!", result)
|
||||
}
|
||||
|
||||
func TestApplyVars_EmptyTemplate_ReturnsEmpty(t *testing.T) {
|
||||
result := applyVars("", map[string]string{"a": "1"})
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestApplyVars_NoVars_ReturnsOriginal(t *testing.T) {
|
||||
result := applyVars("hello {name}", nil)
|
||||
assert.Equal(t, "hello {name}", result)
|
||||
}
|
||||
|
||||
func TestInitialMessages_WithInstruction(t *testing.T) {
|
||||
msgs := initialMessages("system instruction", "user text")
|
||||
assert.Len(t, msgs, 2)
|
||||
assert.Equal(t, model.ChatRoleSystem, msgs[0].Role)
|
||||
assert.Equal(t, "system instruction", msgs[0].Content)
|
||||
assert.Equal(t, model.ChatRoleUser, msgs[1].Role)
|
||||
}
|
||||
|
||||
func TestInitialMessages_EmptyInstruction_SkipsSystem(t *testing.T) {
|
||||
msgs := initialMessages("", "user text")
|
||||
assert.Len(t, msgs, 1)
|
||||
assert.Equal(t, model.ChatRoleUser, msgs[0].Role)
|
||||
}
|
||||
|
||||
func TestFirstText_WithContent(t *testing.T) {
|
||||
content := model.ChatContent{Texts: []model.TextPart{{Message: "hi"}, {Message: "bye"}}}
|
||||
assert.Equal(t, "hi", firstText(content))
|
||||
}
|
||||
|
||||
func TestFirstText_EmptyContent(t *testing.T) {
|
||||
assert.Equal(t, "", firstText(model.ChatContent{}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 辅助 stub
|
||||
// ============================================================
|
||||
|
||||
// singleShotChatModel 一次性输出完整内容的 stub(适合并发测试)
|
||||
type singleShotChatModel struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func (m *singleShotChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||
return model.ChatReply{Content: m.content}, nil
|
||||
}
|
||||
|
||||
func (m *singleShotChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||
events := make(chan model.ChatStreamEvent, 2)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
events <- model.ChatStreamEvent{Delta: m.content, Done: true}
|
||||
close(events)
|
||||
close(errs)
|
||||
}()
|
||||
return events, errs
|
||||
}
|
||||
|
||||
func (m *singleShotChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
// countingChatModel 记录调用次数,超过 failAfter 后返回错误
|
||||
type countingChatModel struct {
|
||||
reply model.ChatReply
|
||||
failAfter int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *countingChatModel) Generate(ctx context.Context, msgs []model.ChatMessage) (model.ChatReply, error) {
|
||||
m.calls++
|
||||
if m.calls > m.failAfter {
|
||||
return model.ChatReply{}, fmt.Errorf("fail at call %d", m.calls)
|
||||
}
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
func (m *countingChatModel) Stream(ctx context.Context, msgs []model.ChatMessage) (<-chan model.ChatStreamEvent, <-chan error) {
|
||||
events := make(chan model.ChatStreamEvent, 4)
|
||||
errs := make(chan error, 1)
|
||||
m.calls++
|
||||
if m.calls > m.failAfter {
|
||||
errs <- fmt.Errorf("fail at call %d", m.calls)
|
||||
} else {
|
||||
events <- model.ChatStreamEvent{Delta: m.reply.Content, Done: true}
|
||||
}
|
||||
close(events)
|
||||
close(errs)
|
||||
return events, errs
|
||||
}
|
||||
|
||||
func (m *countingChatModel) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
return "ok", nil
|
||||
}
|
||||
248
backend/internal/service/assembler_test.go
Normal file
248
backend/internal/service/assembler_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ai-agent-scaffold-go/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 辅助函数
|
||||
// ============================================================
|
||||
|
||||
func newTestTable() model.AiAgentConfigTable {
|
||||
return model.AiAgentConfigTable{
|
||||
AppName: "test-app",
|
||||
Agent: model.AgentSummary{AgentID: "10001", AgentName: "test", AgentDesc: "test agent"},
|
||||
Module: model.AgentModule{
|
||||
AiAPI: model.AiAPIConfig{BaseURL: "http://localhost:8080", APIKey: "test-key", CompletionsPath: "v1/chat/completions"},
|
||||
ChatModel: model.ChatModelConfig{Model: "gpt-4"},
|
||||
Agents: []model.AgentConfig{{Name: "bot", Instruction: "hello"}},
|
||||
Runner: model.RunnerConfig{AgentName: "bot"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLLMServer 返回固定回复的模拟 LLM 服务
|
||||
func fakeLLMServer(response string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// assembleOne 测试
|
||||
// ============================================================
|
||||
|
||||
func TestAssembleOne_SingleAgent_Success(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
|
||||
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "10001", reg.AgentID)
|
||||
assert.Equal(t, "test", reg.AgentName)
|
||||
assert.Equal(t, "test-app", reg.AppName)
|
||||
assert.NotNil(t, reg.Runner)
|
||||
}
|
||||
|
||||
func TestAssembleOne_WithWorkflow_Success(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.Agents = []model.AgentConfig{
|
||||
{Name: "agent1", Instruction: "inst1"},
|
||||
{Name: "agent2", Instruction: "inst2"},
|
||||
}
|
||||
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||
{
|
||||
Type: model.WorkflowTypeSequential,
|
||||
Name: "seq",
|
||||
SubAgents: []string{"agent1", "agent2"},
|
||||
},
|
||||
}
|
||||
table.Module.Runner.AgentName = "seq"
|
||||
|
||||
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reg.Runner)
|
||||
}
|
||||
|
||||
func TestAssembleOne_ParallelWorkflow_Success(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.Agents = []model.AgentConfig{
|
||||
{Name: "a1", Instruction: "inst1"},
|
||||
{Name: "a2", Instruction: "inst2"},
|
||||
}
|
||||
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||
{
|
||||
Type: model.WorkflowTypeParallel,
|
||||
Name: "par",
|
||||
SubAgents: []string{"a1", "a2"},
|
||||
},
|
||||
}
|
||||
table.Module.Runner.AgentName = "par"
|
||||
|
||||
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reg.Runner)
|
||||
}
|
||||
|
||||
func TestAssembleOne_LoopWorkflow_Success(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.Agents = []model.AgentConfig{
|
||||
{Name: "a1", Instruction: "inst"},
|
||||
}
|
||||
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||
{
|
||||
Type: model.WorkflowTypeLoop,
|
||||
Name: "loop",
|
||||
SubAgents: []string{"a1"},
|
||||
MaxIterations: 3,
|
||||
},
|
||||
}
|
||||
table.Module.Runner.AgentName = "loop"
|
||||
|
||||
reg, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, reg.Runner)
|
||||
}
|
||||
|
||||
func TestAssembleOne_UnknownSubAgent_ReturnsError(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||
{
|
||||
Type: model.WorkflowTypeSequential,
|
||||
Name: "seq",
|
||||
SubAgents: []string{"nonexistent"},
|
||||
},
|
||||
}
|
||||
table.Module.Runner.AgentName = "seq"
|
||||
|
||||
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown agent")
|
||||
}
|
||||
|
||||
func TestAssembleOne_UnknownWorkflowType_ReturnsError(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.AgentWorkflows = []model.AgentWorkflowConfig{
|
||||
{
|
||||
Type: "bad-type",
|
||||
Name: "wf",
|
||||
SubAgents: []string{"bot"},
|
||||
},
|
||||
}
|
||||
table.Module.Runner.AgentName = "wf"
|
||||
|
||||
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown workflow type")
|
||||
}
|
||||
|
||||
func TestAssembleOne_EntryAgentNotFound_ReturnsError(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
table := newTestTable()
|
||||
table.Module.AiAPI.BaseURL = srv.URL
|
||||
table.Module.Runner.AgentName = "nonexistent"
|
||||
|
||||
_, err := assembleOne(context.Background(), table, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AssembleAll 测试
|
||||
// ============================================================
|
||||
|
||||
func TestAssembleAll_MultipleTables_Success(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
tables := map[string]model.AiAgentConfigTable{
|
||||
"t1": func() model.AiAgentConfigTable {
|
||||
t := newTestTable()
|
||||
t.Module.AiAPI.BaseURL = srv.URL
|
||||
t.AppName = "app1"
|
||||
t.Agent.AgentID = "1"
|
||||
return t
|
||||
}(),
|
||||
"t2": func() model.AiAgentConfigTable {
|
||||
t := newTestTable()
|
||||
t.Module.AiAPI.BaseURL = srv.URL
|
||||
t.AppName = "app2"
|
||||
t.Agent.AgentID = "2"
|
||||
return t
|
||||
}(),
|
||||
}
|
||||
|
||||
agents, err := AssembleAll(context.Background(), tables, 5*time.Second)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, agents, 2)
|
||||
}
|
||||
|
||||
func TestAssembleAll_OneFails_ReturnsError(t *testing.T) {
|
||||
srv := fakeLLMServer(`{"choices":[{"message":{"content":"ok"}}]}`)
|
||||
defer srv.Close()
|
||||
|
||||
tables := map[string]model.AiAgentConfigTable{
|
||||
"t1": func() model.AiAgentConfigTable {
|
||||
t := newTestTable()
|
||||
t.Module.AiAPI.BaseURL = srv.URL
|
||||
return t
|
||||
}(),
|
||||
"t2": func() model.AiAgentConfigTable {
|
||||
t := newTestTable()
|
||||
t.Module.AiAPI.BaseURL = srv.URL
|
||||
t.Module.Runner.AgentName = "nonexistent"
|
||||
return t
|
||||
}(),
|
||||
}
|
||||
|
||||
_, err := AssembleAll(context.Background(), tables, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LoadAndAssemble 测试
|
||||
// ============================================================
|
||||
|
||||
func TestLoadAndAssemble_NoPaths_ReturnsError(t *testing.T) {
|
||||
_, err := LoadAndAssemble(context.Background(), []string{}, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no agent tables loaded")
|
||||
}
|
||||
|
||||
func TestLoadAndAssemble_EmptyPaths_ReturnsError(t *testing.T) {
|
||||
_, err := LoadAndAssemble(context.Background(), []string{"", " "}, 5*time.Second)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
200
backend/internal/service/chat_test.go
Normal file
200
backend/internal/service/chat_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"ai-agent-scaffold-go/internal/model"
|
||||
"ai-agent-scaffold-go/pkg/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Stub Runner
|
||||
// ============================================================
|
||||
|
||||
type stubRunner struct {
|
||||
sessionID string
|
||||
runResult []string
|
||||
runErr error
|
||||
}
|
||||
|
||||
func (r *stubRunner) CreateSession(userID string) (string, error) {
|
||||
if r.sessionID != "" {
|
||||
return r.sessionID, nil
|
||||
}
|
||||
return "sess:" + userID + ":1", nil
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
|
||||
return r.runResult, r.runErr
|
||||
}
|
||||
|
||||
func (r *stubRunner) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||
outputs := make(chan string, 4)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(outputs)
|
||||
defer close(errs)
|
||||
if r.runErr != nil {
|
||||
errs <- r.runErr
|
||||
return
|
||||
}
|
||||
for _, s := range r.runResult {
|
||||
outputs <- s
|
||||
}
|
||||
}()
|
||||
return outputs, errs
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ChatService 测试
|
||||
// ============================================================
|
||||
|
||||
func newTestChatService() (*ChatService, *model.InMemoryAgentRegistry, *model.InMemorySessionStore) {
|
||||
registry := model.NewInMemoryAgentRegistry()
|
||||
sessions := model.NewInMemorySessionStore()
|
||||
svc := NewChatService(registry, sessions)
|
||||
return svc, registry, sessions
|
||||
}
|
||||
|
||||
func TestChatService_QueryAgentConfigList_ReturnsSorted(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
|
||||
registry.Register(model.RegisteredAgent{AgentID: "2", AgentName: "b", AgentDesc: "desc b"})
|
||||
registry.Register(model.RegisteredAgent{AgentID: "1", AgentName: "a", AgentDesc: "desc a"})
|
||||
|
||||
agents := svc.QueryAgentConfigList()
|
||||
assert.Len(t, agents, 2)
|
||||
assert.Equal(t, "1", agents[0].AgentID)
|
||||
assert.Equal(t, "2", agents[1].AgentID)
|
||||
}
|
||||
|
||||
func TestChatService_QueryAgentConfigList_Empty(t *testing.T) {
|
||||
svc, _, _ := newTestChatService()
|
||||
agents := svc.QueryAgentConfigList()
|
||||
assert.Len(t, agents, 0)
|
||||
}
|
||||
|
||||
func TestChatService_CreateSession_NewSession(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "sess:u1:1"},
|
||||
})
|
||||
|
||||
sessionID, err := svc.CreateSession("1", "user1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "sess:u1:1", sessionID)
|
||||
}
|
||||
|
||||
func TestChatService_CreateSession_ReusesExisting(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "sess:u1:1"},
|
||||
})
|
||||
|
||||
id1, _ := svc.CreateSession("1", "user1")
|
||||
id2, _ := svc.CreateSession("1", "user1")
|
||||
assert.Equal(t, id1, id2)
|
||||
}
|
||||
|
||||
func TestChatService_CreateSession_AgentNotFound_ReturnsError(t *testing.T) {
|
||||
svc, _, _ := newTestChatService()
|
||||
|
||||
_, err := svc.CreateSession("nonexistent", "user1")
|
||||
assert.Error(t, err)
|
||||
|
||||
var appErr *types.AppError
|
||||
assert.ErrorAs(t, err, &appErr)
|
||||
assert.Equal(t, types.CodeAgentNotFound, appErr.Code)
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessage_AgentNotFound_ReturnsError(t *testing.T) {
|
||||
svc, _, _ := newTestChatService()
|
||||
|
||||
_, err := svc.HandleMessage("nonexistent", "user1", "", "hello")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessage_NormalCall(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "s1", runResult: []string{"reply"}},
|
||||
})
|
||||
|
||||
outputs, err := svc.HandleMessage("1", "user1", "s1", "hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"reply"}, outputs)
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessage_EmptyMessage_StillPassesThrough(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "s1", runResult: []string{}},
|
||||
})
|
||||
|
||||
// 空消息仍会创建 TextPart,当前实现不校验空消息内容
|
||||
outputs, err := svc.HandleMessage("1", "user1", "s1", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, outputs)
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessage_RunnerError_Propagates(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "s1", runErr: fmt.Errorf("run failed")},
|
||||
})
|
||||
|
||||
_, err := svc.HandleMessage("1", "user1", "s1", "hello")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "run failed")
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessageStream_AgentNotFound_ReturnsError(t *testing.T) {
|
||||
svc, _, _ := newTestChatService()
|
||||
|
||||
outputs, errs := svc.HandleMessageStream("nonexistent", "user1", "", "hello")
|
||||
// 消费通道
|
||||
var streamErr error
|
||||
for range outputs {
|
||||
}
|
||||
for err := range errs {
|
||||
streamErr = err
|
||||
}
|
||||
assert.Error(t, streamErr)
|
||||
}
|
||||
|
||||
func TestChatService_HandleMessageStream_NormalCall(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "s1", runResult: []string{"chunk1", "chunk2"}},
|
||||
})
|
||||
|
||||
outputs, errs := svc.HandleMessageStream("1", "user1", "s1", "hello")
|
||||
var texts []string
|
||||
for s := range outputs {
|
||||
texts = append(texts, s)
|
||||
}
|
||||
for range errs {
|
||||
}
|
||||
assert.Equal(t, []string{"chunk1", "chunk2"}, texts)
|
||||
}
|
||||
|
||||
func TestChatService_CreateSession_AutoCreatesWhenSessionEmpty(t *testing.T) {
|
||||
svc, registry, _ := newTestChatService()
|
||||
registry.Register(model.RegisteredAgent{
|
||||
AgentID: "1",
|
||||
Runner: &stubRunner{sessionID: "auto-sess", runResult: []string{"ok"}},
|
||||
})
|
||||
|
||||
// sessionID 为空时自动创建
|
||||
outputs, err := svc.HandleMessage("1", "user1", "", "hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"ok"}, outputs)
|
||||
}
|
||||
Reference in New Issue
Block a user