test: 添加各模块单元测试(config/llm/service/handler)
This commit is contained in:
@@ -2,20 +2,27 @@ module ai-agent-scaffold-go
|
|||||||
|
|
||||||
go 1.26.2
|
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 (
|
require (
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.uber.org/multierr v1.10.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/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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.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/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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
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-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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
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=
|
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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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=
|
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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/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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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.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.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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
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 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
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 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
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 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
267
backend/internal/config/loader_test.go
Normal file
267
backend/internal/config/loader_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
322
backend/internal/handler/handler_test.go
Normal file
322
backend/internal/handler/handler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
279
backend/internal/llm/client_test.go
Normal file
279
backend/internal/llm/client_test.go
Normal file
@@ -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"`)
|
||||||
|
}
|
||||||
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