From 92c6c27b254b0fe4dc3193764e0d0c115f91eea2 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Wed, 10 Jun 2026 16:11:02 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E5=90=84=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88config/ll?= =?UTF-8?q?m/service/handler=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 14 +- backend/go.sum | 22 + backend/internal/config/loader_test.go | 267 +++++++++++ backend/internal/handler/handler_test.go | 322 +++++++++++++ backend/internal/llm/client_test.go | 279 ++++++++++++ backend/internal/service/agent_test.go | 499 +++++++++++++++++++++ backend/internal/service/assembler_test.go | 248 ++++++++++ backend/internal/service/chat_test.go | 200 +++++++++ 8 files changed, 1847 insertions(+), 4 deletions(-) create mode 100644 backend/internal/config/loader_test.go create mode 100644 backend/internal/handler/handler_test.go create mode 100644 backend/internal/llm/client_test.go create mode 100644 backend/internal/service/agent_test.go create mode 100644 backend/internal/service/assembler_test.go create mode 100644 backend/internal/service/chat_test.go diff --git a/backend/go.mod b/backend/go.mod index 615e40b..e0a60c9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,20 +2,27 @@ module ai-agent-scaffold-go go 1.26.2 +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 + go.uber.org/zap v1.28.0 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.12.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -23,18 +30,17 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.28.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 3b3511a..6fc4c80 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -7,6 +7,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= @@ -14,6 +15,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -24,6 +27,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= @@ -31,6 +36,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -42,11 +51,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -56,16 +68,24 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= @@ -80,6 +100,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/config/loader_test.go b/backend/internal/config/loader_test.go new file mode 100644 index 0000000..3dc594a --- /dev/null +++ b/backend/internal/config/loader_test.go @@ -0,0 +1,267 @@ +package config + +import ( + "os" + "testing" + + "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"}, + ChatModel: model.ChatModelConfig{Model: "gpt-4"}, + Agents: []model.AgentConfig{{Name: "bot", Instruction: "hello"}}, + Runner: model.RunnerConfig{AgentName: "bot"}, + }, + } +} + +func newTestYAML() []byte { + return []byte(` +ai: + agent: + config: + tables: + test-app: + app-name: test-app + agent: + agent-id: "10001" + agent-name: test + agent-desc: test agent + module: + ai-api: + base-url: "http://localhost:8080" + api-key: "test-key" + chat-model: + model: "gpt-4" + agents: + - name: bot + instruction: hello + runner: + agent-name: bot +`) +} + +// ============================================================ +// expandEnvPlaceholders 测试 +// ============================================================ + +func TestExpandEnvPlaceholders_SetVar_ReturnsValue(t *testing.T) { + t.Setenv("TEST_URL", "http://example.com") + result := expandEnvPlaceholders("url=${TEST_URL}") + assert.Equal(t, "url=http://example.com", result) +} + +func TestExpandEnvPlaceholders_UnsetVar_NoDefault_ReturnsEmpty(t *testing.T) { + os.Unsetenv("TEST_MISSING_VAR") + result := expandEnvPlaceholders("url=${TEST_MISSING_VAR}") + assert.Equal(t, "url=", result) +} + +func TestExpandEnvPlaceholders_UnsetVar_WithDefault_ReturnsDefault(t *testing.T) { + os.Unsetenv("TEST_DEFAULT_VAR") + result := expandEnvPlaceholders("url=${TEST_DEFAULT_VAR:-http://localhost}") + assert.Equal(t, "url=http://localhost", result) +} + +func TestExpandEnvPlaceholders_SetVar_IgnoresDefault(t *testing.T) { + t.Setenv("TEST_OVERRIDE", "http://real.com") + result := expandEnvPlaceholders("url=${TEST_OVERRIDE:-http://default.com}") + assert.Equal(t, "url=http://real.com", result) +} + +func TestExpandEnvPlaceholders_MultipleVars(t *testing.T) { + t.Setenv("VAR_A", "aaa") + t.Setenv("VAR_B", "bbb") + result := expandEnvPlaceholders("${VAR_A}-${VAR_B}") + assert.Equal(t, "aaa-bbb", result) +} + +// ============================================================ +// validateTable 测试 — table-driven +// ============================================================ + +func TestValidateTable(t *testing.T) { + tests := []struct { + name string + modify func(*model.AiAgentConfigTable) + wantErr string + }{ + {"missing app-name", func(t *model.AiAgentConfigTable) { t.AppName = "" }, "app-name"}, + {"missing agent-id", func(t *model.AiAgentConfigTable) { t.Agent.AgentID = "" }, "agent.agent-id"}, + {"missing base-url", func(t *model.AiAgentConfigTable) { t.Module.AiAPI.BaseURL = "" }, "module.ai-api.base-url"}, + {"missing api-key", func(t *model.AiAgentConfigTable) { t.Module.AiAPI.APIKey = "" }, "module.ai-api.api-key"}, + {"missing model", func(t *model.AiAgentConfigTable) { t.Module.ChatModel.Model = "" }, "module.chat-model.model"}, + {"missing runner agent-name", func(t *model.AiAgentConfigTable) { t.Module.Runner.AgentName = "" }, "module.runner.agent-name"}, + {"missing agents", func(t *model.AiAgentConfigTable) { t.Module.Agents = nil }, "module.agents"}, + {"empty agent name", func(t *model.AiAgentConfigTable) { t.Module.Agents[0].Name = "" }, "module.agents[0].name"}, + {"empty agent instruction", func(t *model.AiAgentConfigTable) { t.Module.Agents[0].Instruction = "" }, "module.agents[0].instruction"}, + {"invalid workflow type", func(t *model.AiAgentConfigTable) { + t.Module.AgentWorkflows = []model.AgentWorkflowConfig{ + {Type: "bad", Name: "wf"}, + } + }, "module.agent-workflows[0].type is invalid"}, + {"missing workflow name", func(t *model.AiAgentConfigTable) { + t.Module.AgentWorkflows = []model.AgentWorkflowConfig{ + {Type: model.WorkflowTypeSequential, Name: ""}, + } + }, "module.agent-workflows[0].name is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + table := newTestTable() + tt.modify(&table) + err := validateTable("test", table) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidateTable_ValidTable_NoError(t *testing.T) { + table := newTestTable() + err := validateTable("test", table) + assert.NoError(t, err) +} + +// ============================================================ +// normalizeDefaults 测试 +// ============================================================ + +func TestNormalizeDefaults_SetsCompletionsPath(t *testing.T) { + table := newTestTable() + table.Module.AiAPI.CompletionsPath = "" + normalizeDefaults(&table) + assert.Equal(t, "v1/chat/completions", table.Module.AiAPI.CompletionsPath) +} + +func TestNormalizeDefaults_SetsMaxIterations(t *testing.T) { + table := newTestTable() + table.Module.AgentWorkflows = []model.AgentWorkflowConfig{ + {Type: model.WorkflowTypeLoop, Name: "loop", MaxIterations: 0}, + } + normalizeDefaults(&table) + assert.Equal(t, 3, table.Module.AgentWorkflows[0].MaxIterations) +} + +// ============================================================ +// LoadAgentTables 测试 +// ============================================================ + +func TestLoadAgentTables_ValidYAML_ReturnsTable(t *testing.T) { + tables, err := LoadAgentTables(newTestYAML()) + assert.NoError(t, err) + assert.Len(t, tables, 1) + + table, ok := tables["test-app"] + assert.True(t, ok) + assert.Equal(t, "test-app", table.AppName) + assert.Equal(t, "10001", table.Agent.AgentID) + assert.Equal(t, "gpt-4", table.Module.ChatModel.Model) +} + +func TestLoadAgentTables_WithEnvVar_Substitutes(t *testing.T) { + t.Setenv("TEST_LLM_URL", "http://llm.example.com") + yaml := []byte(` +ai: + agent: + config: + tables: + t: + app-name: app + agent: + agent-id: "1" + agent-name: n + agent-desc: d + module: + ai-api: + base-url: "${TEST_LLM_URL}" + api-key: k + chat-model: + model: m + agents: + - name: bot + instruction: hi + runner: + agent-name: bot +`) + tables, err := LoadAgentTables(yaml) + assert.NoError(t, err) + assert.Equal(t, "http://llm.example.com", tables["t"].Module.AiAPI.BaseURL) +} + +func TestLoadAgentTables_EmptyTables_ReturnsError(t *testing.T) { + yaml := []byte(` +ai: + agent: + config: + tables: {} +`) + _, err := LoadAgentTables(yaml) + assert.Error(t, err) + assert.Contains(t, err.Error(), "agent config tables are required") +} + +func TestLoadAgentTables_InvalidYAML_ReturnsError(t *testing.T) { + _, err := LoadAgentTables([]byte("not: [valid: yaml")) + assert.Error(t, err) +} + +func TestLoadAgentTables_MissingRequiredField_ReturnsError(t *testing.T) { + yaml := []byte(` +ai: + agent: + config: + tables: + t: + app-name: "" + agent: + agent-id: "1" + module: + ai-api: + base-url: u + api-key: k + chat-model: + model: m + agents: + - name: bot + instruction: hi + runner: + agent-name: bot +`) + _, err := LoadAgentTables(yaml) + assert.Error(t, err) + assert.Contains(t, err.Error(), "app-name") +} + +// ============================================================ +// LoadAgentTablesFile 测试 +// ============================================================ + +func TestLoadAgentTablesFile_ValidFile_ReturnsTable(t *testing.T) { + tmp, err := os.CreateTemp("", "agent-*.yaml") + assert.NoError(t, err) + defer os.Remove(tmp.Name()) + + _, err = tmp.Write(newTestYAML()) + assert.NoError(t, err) + tmp.Close() + + tables, err := LoadAgentTablesFile(tmp.Name()) + assert.NoError(t, err) + assert.Len(t, tables, 1) +} + +func TestLoadAgentTablesFile_FileNotFound_ReturnsError(t *testing.T) { + _, err := LoadAgentTablesFile("/nonexistent/path.yaml") + assert.Error(t, err) +} diff --git a/backend/internal/handler/handler_test.go b/backend/internal/handler/handler_test.go new file mode 100644 index 0000000..cc6f186 --- /dev/null +++ b/backend/internal/handler/handler_test.go @@ -0,0 +1,322 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "ai-agent-scaffold-go/internal/model" + "ai-agent-scaffold-go/internal/service" + "ai-agent-scaffold-go/pkg/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// ============================================================ +// Stub Runner for handler tests +// ============================================================ + +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 +} + +// ============================================================ +// 辅助函数 +// ============================================================ + +func setupRouter() (*gin.Engine, *model.InMemoryAgentRegistry) { + gin.SetMode(gin.TestMode) + registry := model.NewInMemoryAgentRegistry() + sessions := model.NewInMemorySessionStore() + svc := service.NewChatService(registry, sessions) + router := gin.New() + RegisterRoutes(router, svc) + return router, registry +} + +func doRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder { + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func parseEnvelope(t *testing.T, w *httptest.ResponseRecorder) Envelope { + t.Helper() + var resp Envelope + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + return resp +} + +// ============================================================ +// healthz 测试 +// ============================================================ + +func TestHealthz_Returns200(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + w := doRequest(router, "GET", "/healthz", "") + assert.Equal(t, 200, w.Code) +} + +// ============================================================ +// queryAgentConfigList 测试 +// ============================================================ + +func TestQueryAgentConfigList_ReturnsList(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{AgentID: "1", AgentName: "a", AgentDesc: "desc a"}) + registry.Register(model.RegisteredAgent{AgentID: "2", AgentName: "b", AgentDesc: "desc b"}) + + w := doRequest(router, "GET", "/api/v1/query_ai_agent_config_list", "") + assert.Equal(t, 200, w.Code) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeSuccess, resp.Code) + + data, _ := json.Marshal(resp.Data) + var agents []AiAgentConfigResponse + json.Unmarshal(data, &agents) + assert.Len(t, agents, 2) +} + +func TestQueryAgentConfigList_Empty_ReturnsEmptyArray(t *testing.T) { + router, _ := setupRouter() + + w := doRequest(router, "GET", "/api/v1/query_ai_agent_config_list", "") + assert.Equal(t, 200, w.Code) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeSuccess, resp.Code) +} + +// ============================================================ +// createSession 测试 +// ============================================================ + +func TestCreateSession_Success(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{ + AgentID: "1", + Runner: &stubRunner{sessionID: "sess:1:1"}, + }) + + body := `{"agentId":"1","userId":"user1"}` + w := doRequest(router, "POST", "/api/v1/create_session", body) + assert.Equal(t, 200, w.Code) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeSuccess, resp.Code) + + data, _ := json.Marshal(resp.Data) + var sessResp CreateSessionResponse + json.Unmarshal(data, &sessResp) + assert.Equal(t, "sess:1:1", sessResp.SessionID) +} + +func TestCreateSession_AgentNotFound_Returns0003(t *testing.T) { + router, _ := setupRouter() + + body := `{"agentId":"nonexistent","userId":"user1"}` + w := doRequest(router, "POST", "/api/v1/create_session", body) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeAgentNotFound, resp.Code) +} + +func TestCreateSession_MissingParams_Returns0002(t *testing.T) { + router, _ := setupRouter() + + // 空 body + w := doRequest(router, "POST", "/api/v1/create_session", "") + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeIllegalParameter, resp.Code) +} + +func TestCreateSession_Query_Success(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{ + AgentID: "1", + Runner: &stubRunner{sessionID: "sess:u1:1"}, + }) + + w := doRequest(router, "GET", "/api/v1/create_session?agentId=1&userId=user1", "") + assert.Equal(t, 200, w.Code) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeSuccess, resp.Code) +} + +func TestCreateSession_Query_AgentNotFound_Returns0003(t *testing.T) { + router, _ := setupRouter() + + w := doRequest(router, "GET", "/api/v1/create_session?agentId=x&userId=u", "") + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeAgentNotFound, resp.Code) +} + +// ============================================================ +// chat 测试 +// ============================================================ + +func TestChat_Success(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{ + AgentID: "1", + Runner: &stubRunner{sessionID: "s1", runResult: []string{"hello"}}, + }) + + body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}` + w := doRequest(router, "POST", "/api/v1/chat", body) + assert.Equal(t, 200, w.Code) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeSuccess, resp.Code) + + data, _ := json.Marshal(resp.Data) + var chatResp ChatResponse + json.Unmarshal(data, &chatResp) + assert.Equal(t, "hello", chatResp.Content) +} + +func TestChat_AgentNotFound_Returns0003(t *testing.T) { + router, _ := setupRouter() + + body := `{"agentId":"nonexistent","userId":"u1","message":"hi"}` + w := doRequest(router, "POST", "/api/v1/chat", body) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeAgentNotFound, resp.Code) +} + +func TestChat_MissingBody_Returns0002(t *testing.T) { + router, _ := setupRouter() + + w := doRequest(router, "POST", "/api/v1/chat", "") + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeIllegalParameter, resp.Code) +} + +func TestChat_RunnerError_ReturnsUnknownError(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{ + AgentID: "1", + Runner: &stubRunner{sessionID: "s1", runErr: assert.AnError}, + }) + + body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}` + w := doRequest(router, "POST", "/api/v1/chat", body) + + resp := parseEnvelope(t, w) + assert.Equal(t, types.CodeUnknownError, resp.Code) +} + +// ============================================================ +// chatStream 测试 +// ============================================================ + +func TestChatStream_SSEHeaders(t *testing.T) { + router, registry := setupRouter() + registry.Register(model.RegisteredAgent{ + AgentID: "1", + Runner: &stubRunner{sessionID: "s1", runResult: []string{"chunk1", "chunk2"}}, + }) + + body := `{"agentId":"1","userId":"u1","sessionId":"s1","message":"hi"}` + w := doRequest(router, "POST", "/api/v1/chat_stream", body) + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream") + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) +} + +func TestChatStream_AgentNotFound_ReturnsError(t *testing.T) { + router, _ := setupRouter() + + body := `{"agentId":"nonexistent","userId":"u1","message":"hi"}` + w := doRequest(router, "POST", "/api/v1/chat_stream", body) + + // SSE 流式中错误通过 event 发送,HTTP 状态码仍为 200 + assert.Equal(t, 200, w.Code) +} + +// ============================================================ +// writeError 测试 +// ============================================================ + +func TestWriteError_AppError(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + writeError(c, types.NewAppError(types.CodeAgentNotFound, "not found")) + + var resp Envelope + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, types.CodeAgentNotFound, resp.Code) +} + +func TestWriteError_UnknownError(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + writeError(c, assert.AnError) + + var resp Envelope + json.Unmarshal(w.Body.Bytes(), &resp) + assert.Equal(t, types.CodeUnknownError, resp.Code) +} + +// ============================================================ +// success 测试 +// ============================================================ + +func TestSuccess_ReturnsEnvelope(t *testing.T) { + resp := success(map[string]string{"key": "val"}) + assert.Equal(t, types.CodeSuccess, resp.Code) + assert.Equal(t, types.InfoSuccess, resp.Info) + assert.NotNil(t, resp.Data) +} diff --git a/backend/internal/llm/client_test.go b/backend/internal/llm/client_test.go new file mode 100644 index 0000000..2d3e9ae --- /dev/null +++ b/backend/internal/llm/client_test.go @@ -0,0 +1,279 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "ai-agent-scaffold-go/internal/model" + "github.com/stretchr/testify/assert" +) + +// ============================================================ +// 辅助函数 +// ============================================================ + +func newTestClient(url string) *OpenAIClient { + return NewOpenAIClient(url, "test-key", "gpt-4", 5*time.Second) +} + +// ============================================================ +// Generate 测试 +// ============================================================ + +func TestOpenAIClient_Generate_ReturnsContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "hello world"}}, + }, + }) + })) + defer srv.Close() + + client := newTestClient(srv.URL) + reply, err := client.Generate(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + assert.NoError(t, err) + assert.Equal(t, "hello world", reply.Content) + assert.Empty(t, reply.ToolCalls) +} + +func TestOpenAIClient_Generate_ToolCalls(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{ + "content": "", + "tool_calls": []map[string]any{ + {"id": "call_1", "type": "function", "function": map[string]any{ + "name": "search", + "arguments": `{"query":"test"}`, + }}, + }, + }}, + }, + }) + })) + defer srv.Close() + + client := newTestClient(srv.URL) + reply, err := client.Generate(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "search for test"}, + }, []ToolDef{{Name: "search", Description: "search tool"}}) + + assert.NoError(t, err) + assert.Len(t, reply.ToolCalls, 1) + assert.Equal(t, "call_1", reply.ToolCalls[0].ID) + assert.Equal(t, "search", reply.ToolCalls[0].Name) + assert.Equal(t, `{"query":"test"}`, reply.ToolCalls[0].Arguments) +} + +func TestOpenAIClient_Generate_Upstream500_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "internal error") + })) + defer srv.Close() + + client := newTestClient(srv.URL) + _, err := client.Generate(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestOpenAIClient_Generate_EmptyChoices_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"choices": []any{}}) + })) + defer srv.Close() + + client := newTestClient(srv.URL) + _, err := client.Generate(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no choices") +} + +// ============================================================ +// Stream 测试 +// ============================================================ + +func TestOpenAIClient_Stream_ReturnsChunks(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n") + w.(http.Flusher).Flush() + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n") + w.(http.Flusher).Flush() + fmt.Fprint(w, "data: [DONE]\n\n") + w.(http.Flusher).Flush() + })) + defer srv.Close() + + client := newTestClient(srv.URL) + events, errs := client.Stream(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + var texts []string + for ev := range events { + if ev.Delta != "" { + texts = append(texts, ev.Delta) + } + if ev.Done { + break + } + } + // 检查错误通道 + for err := range errs { + assert.NoError(t, err) + } + + assert.Equal(t, []string{"hel", "lo"}, texts) +} + +func TestOpenAIClient_Stream_ToolCallDeltas_MergesCorrectly(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // 工具调用分多个 chunk 发送 + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"search\"}}]}}]}\n\n") + w.(http.Flusher).Flush() + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"q\"}}]}}]}\n\n") + w.(http.Flusher).Flush() + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"uery\\\":\\\"test\\\"}\"}}]}}]}\n\n") + w.(http.Flusher).Flush() + fmt.Fprint(w, "data: [DONE]\n\n") + w.(http.Flusher).Flush() + })) + defer srv.Close() + + client := newTestClient(srv.URL) + events, errs := client.Stream(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + var toolCalls []model.ChatToolCall + for ev := range events { + if len(ev.ToolCalls) > 0 { + toolCalls = append(toolCalls, ev.ToolCalls...) + } + if ev.Done { + break + } + } + for range errs { + } + + assert.Len(t, toolCalls, 1) + assert.Equal(t, "call_1", toolCalls[0].ID) + assert.Equal(t, "search", toolCalls[0].Name) + assert.Equal(t, `{"query":"test"}`, toolCalls[0].Arguments) +} + +func TestOpenAIClient_Stream_Upstream500_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "error") + })) + defer srv.Close() + + client := newTestClient(srv.URL) + events, errs := client.Stream(context.Background(), []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil) + + // 消费通道直到关闭 + var streamErr error + done := make(chan struct{}) + go func() { + for range events { + } + close(done) + }() + for err := range errs { + streamErr = err + } + <-done + + assert.Error(t, streamErr) + assert.Contains(t, streamErr.Error(), "500") +} + +// ============================================================ +// encodeMessages / encodeTools 测试 +// ============================================================ + +func TestEncodeMessages_WithToolCalls(t *testing.T) { + msgs := []model.ChatMessage{ + {Role: model.ChatRoleAssistant, ToolCalls: []model.ChatToolCall{ + {ID: "c1", Name: "search", Arguments: `{"q":"x"}`}, + }}, + } + encoded := encodeMessages(msgs) + assert.Len(t, encoded, 1) + assert.Equal(t, "assistant", encoded[0]["role"]) + calls := encoded[0]["tool_calls"].([]map[string]any) + assert.Len(t, calls, 1) + assert.Equal(t, "c1", calls[0]["id"]) +} + +func TestEncodeTools_CorrectFormat(t *testing.T) { + tools := []ToolDef{{Name: "search", Description: "search tool"}} + encoded := encodeTools(tools) + assert.Len(t, encoded, 1) + assert.Equal(t, "function", encoded[0]["type"]) + fn := encoded[0]["function"].(map[string]any) + assert.Equal(t, "search", fn["name"]) + assert.Equal(t, "search tool", fn["description"]) +} + +func TestEncodeTools_EmptyDescription_UsesFallback(t *testing.T) { + tools := []ToolDef{{Name: "mytool"}} + encoded := encodeTools(tools) + fn := encoded[0]["function"].(map[string]any) + assert.Contains(t, fn["description"], "mytool") +} + +// ============================================================ +// truncate 测试 +// ============================================================ + +func TestTruncate_ShortString_Unchanged(t *testing.T) { + assert.Equal(t, "abc", truncate("abc", 10)) +} + +func TestTruncate_LongString_Truncated(t *testing.T) { + result := truncate("abcdefghijk", 5) + assert.Equal(t, "abcde...", result) +} + +// ============================================================ +// buildRequestBody 测试 +// ============================================================ + +func TestBuildRequestBody_Stream(t *testing.T) { + body, err := buildRequestBody("gpt-4", []model.ChatMessage{ + {Role: model.ChatRoleUser, Content: "hi"}, + }, nil, true) + assert.NoError(t, err) + assert.Contains(t, string(body), `"stream":true`) + assert.Contains(t, string(body), `"model":"gpt-4"`) +} diff --git a/backend/internal/service/agent_test.go b/backend/internal/service/agent_test.go new file mode 100644 index 0000000..1fbb36a --- /dev/null +++ b/backend/internal/service/agent_test.go @@ -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 +} diff --git a/backend/internal/service/assembler_test.go b/backend/internal/service/assembler_test.go new file mode 100644 index 0000000..f362ee1 --- /dev/null +++ b/backend/internal/service/assembler_test.go @@ -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) +} diff --git a/backend/internal/service/chat_test.go b/backend/internal/service/chat_test.go new file mode 100644 index 0000000..19108df --- /dev/null +++ b/backend/internal/service/chat_test.go @@ -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) +}