18c9354095
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
236 lines
9.0 KiB
Go
236 lines
9.0 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
ai "code.tczkiot.com/wlw/ai-agent/internal/ai"
|
|
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
|
|
|
"github.com/cloudwego/eino/schema"
|
|
)
|
|
|
|
func TestPlatformEinoChatModelUsesStableRequestIDsAcrossRunRecovery(t *testing.T) {
|
|
var mu sync.Mutex
|
|
requestIDs := make([]string, 0, 2)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
mu.Lock()
|
|
requestIDs = append(requestIDs, request.Header.Get("X-AI-Request-ID"))
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprint(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"platform-default","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
requestContext := withPlatformRequestIDBase(context.Background(), "conversation:10:message:20:revision:30")
|
|
model, err := newEinoChatModel(requestContext, models.AIConfig{
|
|
APIKey: "platform-managed",
|
|
BaseURL: server.URL + "/v1",
|
|
ModelName: "platform-default",
|
|
Platform: true,
|
|
HTTPClient: server.Client(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newEinoChatModel() error = %v", err)
|
|
}
|
|
for range 2 {
|
|
if _, err = model.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
}
|
|
recoveredModel, err := newEinoChatModel(requestContext, models.AIConfig{
|
|
APIKey: "platform-managed", BaseURL: server.URL + "/v1", ModelName: "platform-default",
|
|
Platform: true, HTTPClient: server.Client(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newEinoChatModel(recovered) error = %v", err)
|
|
}
|
|
for range 2 {
|
|
if _, err = recoveredModel.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil {
|
|
t.Fatalf("recovered Generate() error = %v", err)
|
|
}
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(requestIDs) != 4 || requestIDs[0] == "" || requestIDs[1] == "" || requestIDs[0] == requestIDs[1] {
|
|
t.Fatalf("request IDs = %q, want distinct non-empty per-step values", requestIDs)
|
|
}
|
|
if requestIDs[0] != requestIDs[2] || requestIDs[1] != requestIDs[3] {
|
|
t.Fatalf("request IDs = %q, want recovered run to reuse stable per-step IDs", requestIDs)
|
|
}
|
|
}
|
|
|
|
func TestIsDeepSeekV4Model(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
config models.AIConfig
|
|
want bool
|
|
}{
|
|
{
|
|
name: "flash",
|
|
config: models.AIConfig{
|
|
BaseURL: "https://api.deepseek.com",
|
|
ModelName: "deepseek-v4-flash",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "pro with whitespace",
|
|
config: models.AIConfig{
|
|
BaseURL: " https://api.deepseek.com/v1 ",
|
|
ModelName: " DeepSeek-V4-Pro ",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "other openai compatible provider",
|
|
config: models.AIConfig{
|
|
BaseURL: "https://example.com/v1",
|
|
ModelName: "deepseek-v4-flash",
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isDeepSeekV4Model(tt.config); got != tt.want {
|
|
t.Fatalf("isDeepSeekV4Model() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEinoFunctionToolNormalizesModelNameAndExecutesOriginalBusinessCode(t *testing.T) {
|
|
var executed ai.ToolCall
|
|
tool, err := newEinoFunctionTool(ai.ToolDefinition{
|
|
Name: "business/card_diagnosis",
|
|
Description: "Diagnose the current card.",
|
|
Parameters: map[string]any{"type": "object"},
|
|
}, func(_ context.Context, call ai.ToolCall) (string, error) {
|
|
executed = call
|
|
return "ok", nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newEinoFunctionTool() error = %v", err)
|
|
}
|
|
info, err := tool.Info(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Info() error = %v", err)
|
|
}
|
|
if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(info.Name) {
|
|
t.Fatalf("normalized tool name %q is not OpenAI compatible", info.Name)
|
|
}
|
|
if info.Name == "business/card_diagnosis" || len(info.Name) > 64 {
|
|
t.Fatalf("unexpected normalized tool name %q", info.Name)
|
|
}
|
|
result, err := tool.InvokableRun(context.Background(), `{"card":"current"}`)
|
|
if err != nil {
|
|
t.Fatalf("InvokableRun() error = %v", err)
|
|
}
|
|
if result != "ok" {
|
|
t.Fatalf("InvokableRun() = %q, want ok", result)
|
|
}
|
|
if executed.Name != "business/card_diagnosis" || executed.Arguments != `{"card":"current"}` {
|
|
t.Fatalf("executed call = %#v", executed)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeEinoToolNameKeepsCompatibleName(t *testing.T) {
|
|
if got := normalizeEinoToolName("conversation_decision"); got != "conversation_decision" {
|
|
t.Fatalf("normalizeEinoToolName() = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildEinoUserMessageUsesTrustedInlineImages(t *testing.T) {
|
|
message := buildEinoUserMessage("请看设备指示灯", []ai.ImageInput{{
|
|
AssetID: "asset-1", MIMEType: "image/png", Base64Data: "aGVsbG8=",
|
|
}})
|
|
if message.Role != schema.User || message.Content != "" || len(message.UserInputMultiContent) != 4 {
|
|
t.Fatalf("unexpected multimodal message: %#v", message)
|
|
}
|
|
if message.UserInputMultiContent[2].Type != schema.ChatMessagePartTypeText || !strings.Contains(message.UserInputMultiContent[2].Text, "图1") {
|
|
t.Fatalf("image ordinal label missing: %#v", message.UserInputMultiContent[2])
|
|
}
|
|
imagePart := message.UserInputMultiContent[3]
|
|
if imagePart.Type != schema.ChatMessagePartTypeImageURL || imagePart.Image == nil || imagePart.Image.URL != nil || imagePart.Image.Base64Data == nil || *imagePart.Image.Base64Data != "aGVsbG8=" || imagePart.Image.MIMEType != "image/png" {
|
|
t.Fatalf("unexpected trusted image part: %#v", imagePart)
|
|
}
|
|
if imagePart.Image.Detail != schema.ImageURLDetailHigh {
|
|
t.Fatalf("device image must use high detail, got %q", imagePart.Image.Detail)
|
|
}
|
|
}
|
|
|
|
func TestSupportsVisionInputIsConservativeAndFallbackErrorsAreScoped(t *testing.T) {
|
|
for _, modelName := range []string{"qwen2.5-vl-max", "gpt-4o-mini", "gemini-2.5-flash"} {
|
|
if !supportsVisionInput(models.AIConfig{ModelName: modelName}) {
|
|
t.Fatalf("expected %q to support vision", modelName)
|
|
}
|
|
}
|
|
for _, modelName := range []string{"deepseek-v4-flash", "qwen-plus", "platform-default"} {
|
|
if supportsVisionInput(models.AIConfig{ModelName: modelName}) {
|
|
t.Fatalf("text-only/unknown model %q must degrade without image parts", modelName)
|
|
}
|
|
}
|
|
if supportsVisionInput(models.AIConfig{Platform: true, ModelName: "deepseek-v4-flash"}) {
|
|
t.Fatal("managed platform without an enabled vision route must reject image parts")
|
|
}
|
|
if !supportsVisionInput(models.AIConfig{Platform: true, VisionEnabled: true, VisionModel: "qwen3-vl-plus", ModelName: "deepseek-v4-flash"}) {
|
|
t.Fatal("managed platform with a configured vision route must preserve image parts")
|
|
}
|
|
if !isVisionUnsupportedError(errors.New("model does not support image content")) {
|
|
t.Fatal("expected image capability error to trigger text-only retry")
|
|
}
|
|
if isVisionUnsupportedError(errors.New("upstream timeout")) {
|
|
t.Fatal("unrelated upstream failures must not trigger a duplicate model call")
|
|
}
|
|
}
|
|
|
|
func TestVisionFallbackExplicitlyForbidsPretendingToSeeImage(t *testing.T) {
|
|
messages := []*schema.Message{schema.SystemMessage("base"), buildEinoUserMessage("看图", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}})}
|
|
fallback := buildVisionFallbackMessages(messages, "看图")
|
|
if len(fallback) != 3 || fallback[1].Role != schema.System || !strings.Contains(fallback[1].Content, "Never claim that you saw") || len(fallback[2].UserInputMultiContent) != 0 || fallback[2].Content != "看图" {
|
|
t.Fatalf("unsafe text-only vision fallback: %#v", fallback)
|
|
}
|
|
}
|
|
|
|
func TestEinoOpenAIAdapterSerializesInlineImageURLWithoutExternalURL(t *testing.T) {
|
|
var requestBody string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
data, err := io.ReadAll(request.Body)
|
|
if err != nil {
|
|
t.Errorf("read request body: %v", err)
|
|
}
|
|
requestBody = string(data)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprint(w, `{"id":"chatcmpl-vision","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"看到了"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
model, err := newEinoChatModel(context.Background(), models.AIConfig{
|
|
APIKey: "test", BaseURL: server.URL + "/v1", ModelName: "gpt-4o-mini", HTTPClient: server.Client(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newEinoChatModel() error = %v", err)
|
|
}
|
|
message := buildEinoUserMessage("分析照片", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}})
|
|
if _, err := model.Generate(context.Background(), []*schema.Message{message}); err != nil {
|
|
t.Fatalf("Generate() error = %v", err)
|
|
}
|
|
if !strings.Contains(requestBody, "data:image/png;base64,aGVsbG8=") {
|
|
t.Fatalf("request does not contain the expected inline image URL: %s", requestBody)
|
|
}
|
|
if strings.Contains(requestBody, "http://attacker") || strings.Contains(requestBody, "https://attacker") {
|
|
t.Fatalf("external URL leaked into vision request: %s", requestBody)
|
|
}
|
|
}
|