Files
Agent-System/internal/server/taskstream_test.go
T
weichen f8730791b9 fix(coordinator): 回填会话项目绑定,避免受控投影被跳过
聊天入口只把项目 ID 放在授权字段,协调器仍读空的 UserCtx.Project.ID,受控任务因此以 project_id=0 跳过账本投影,回复却称正文已保存。
2026-09-28 12:34:32 +00:00

255 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bufio"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/stream"
)
// readSSEEvents 从 SSE 响应解析事件序列(测试断言辅助)
func readSSEEvents(t *testing.T, resp *http.Response) []stream.StreamEvent {
t.Helper()
scanner := bufio.NewScanner(resp.Body)
var events []stream.StreamEvent
var currentType string
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "event: ") {
currentType = strings.TrimPrefix(line, "event: ")
} else if strings.HasPrefix(line, "data: ") {
var ev stream.StreamEvent
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err == nil {
if ev.Type == "" {
ev.Type = stream.EventType(currentType)
}
events = append(events, ev)
}
}
}
return events
}
func findEvent(events []stream.StreamEvent, eventType stream.EventType) *stream.StreamEvent {
for i := range events {
if events[i].Type == eventType {
return &events[i]
}
}
return nil
}
func hasAction(actions []string, want string) bool {
for _, a := range actions {
if a == want {
return true
}
}
return false
}
// TestTaskStream_TransientInjection_DeadPiAgent 会话级 transient 故障注入:
// pi-agent 指向不可达端口 → dial 失败 → SSE 必须输出 event:error 且 error_info.class=transient
// (补齐 2026-09-09 测试报告遗留建议 #2)
func TestTaskStream_TransientInjection_DeadPiAgent(t *testing.T) {
cfg := &config.Config{
Env: "test",
AgentBackend: "stub",
DataDir: t.TempDir(),
KbDbPort: 5432,
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
PiAgentRunnerURL: "http://127.0.0.1:1", // 死端口:connection refused → 网络类瞬态错误
PiAgentModel: "MiniMax-M3",
PiAgentProvider: "minimax",
AgentTimeout: 5 * time.Second,
}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
body, _ := json.Marshal(map[string]any{
"task_key": "intake",
"project_id": 1,
"project_name": "超声内镜系统",
"customer": "北京水木东方",
"backend": "pi-agent",
})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testBearerToken(t, ts.URL, cfg))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post tasks run stream failed: %v", err)
}
defer resp.Body.Close()
if rid := resp.Header.Get("X-Request-Id"); rid == "" {
t.Fatal("expected non-empty X-Request-Id header")
}
events := readSSEEvents(t, resp)
errEv := findEvent(events, stream.EventError)
if errEv == nil {
t.Fatalf("expected event:error in stream, got %d events", len(events))
}
if errEv.ErrorInfo == nil {
t.Fatal("error_info is nil")
}
if errEv.ErrorInfo.Class != stream.ErrorClassTransient {
t.Fatalf("class=%q want transient (upstream=%q)", errEv.ErrorInfo.Class, errEv.ErrorInfo.Upstream)
}
if !hasAction(errEv.ErrorInfo.Actions, "retry") {
t.Fatalf("actions=%v want retry", errEv.ErrorInfo.Actions)
}
if errEv.ErrorInfo.RequestID == "" {
t.Fatal("error_info.request_id is empty")
}
if errEv.ErrorInfo.PhaseID == "" {
t.Fatal("error_info.phase_id is empty (expected last streamed phase)")
}
}
// TestTaskStream_TypedReject_ClassifiedFatal runner 契约失败(typed result rejected):
// 即使携带 WorkDir,也必须 class=fatal、仅 restart,不得伪装成可 continue 的中断。
func TestTaskStream_TypedReject_ClassifiedFatal(t *testing.T) {
brokenRunner := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "simulated upstream business failure", http.StatusInternalServerError)
}))
defer brokenRunner.Close()
cfg := &config.Config{
Env: "test",
AgentBackend: "stub",
DataDir: t.TempDir(),
KbDbPort: 5432,
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
PiAgentRunnerURL: brokenRunner.URL,
PiAgentModel: "MiniMax-M3",
PiAgentProvider: "minimax",
AgentTimeout: 10 * time.Second,
}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
body, _ := json.Marshal(map[string]any{
"task_key": "intake",
"project_id": 1,
"project_name": "超声内镜系统",
"customer": "北京水木东方",
"backend": "pi-agent",
})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testBearerToken(t, ts.URL, cfg))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post tasks run stream failed: %v", err)
}
defer resp.Body.Close()
events := readSSEEvents(t, resp)
errEv := findEvent(events, stream.EventError)
if errEv == nil {
t.Fatalf("expected event:error in stream, got %d events", len(events))
}
if errEv.ErrorInfo == nil {
t.Fatal("error_info is nil")
}
if errEv.ErrorInfo.Class != stream.ErrorClassFatal {
t.Fatalf("class=%q want fatal (upstream=%q)", errEv.ErrorInfo.Class, errEv.ErrorInfo.Upstream)
}
if errEv.ErrorInfo.Code != "RUNNER_RESULT_REJECTED" {
t.Fatalf("code=%q want RUNNER_RESULT_REJECTED", errEv.ErrorInfo.Code)
}
if hasAction(errEv.ErrorInfo.Actions, "continue") {
t.Fatalf("actions=%v must not include continue for typed reject", errEv.ErrorInfo.Actions)
}
if !hasAction(errEv.ErrorInfo.Actions, "restart") {
t.Fatalf("actions=%v want restart", errEv.ErrorInfo.Actions)
}
}
// TestTaskStream_MaterialsInjection 验证前端传入的 materials(Base64 编码的附件内容)
// 能被后端 HTTP handler 正确反序列化,透传给 ExecuteTask,并写入工作区的 materials_text/ 目录
func TestTaskStream_MaterialsInjection(t *testing.T) {
cfg := &config.Config{
Env: "test",
AgentBackend: "stub",
DataDir: t.TempDir(),
KbDbPort: 5432,
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
}
svc := bizdev.MustNewService(t, cfg)
srv := NewServer(cfg, svc)
ts := httptest.NewServer(srv.Routes())
defer ts.Close()
// 模拟前端发来的包含 materials 字段的请求
// Content 经过 Base64 编码:"用户上传文档核心需求:需要支持 1080P 超声实时渲染。"
const rawText = "用户上传文档核心需求:需要支持 1080P 超声实时渲染。"
b64Content := "55So5oi35LiK5Lyg5paH5qGj5qC45b+D6ZyA5rGC77ya6ZyA6KaB5pSv5oyBIDEwODBQIOi2heWjsOWunuaXtua4suafk+OAgg=="
body, _ := json.Marshal(map[string]any{
"task_key": "intake",
"project_id": 1,
"project_name": "超声材料注入测试",
"customer_name": "北京水木东方",
"device_type": "超声诊断设备",
"backend": "stub",
"materials": []map[string]string{
{
"filename": "initial_requirements.txt",
"content": b64Content,
},
},
})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run/stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testBearerToken(t, ts.URL, cfg))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("post tasks run stream failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.StatusCode)
}
events := readSSEEvents(t, resp)
doneEv := findEvent(events, stream.EventDone)
if doneEv == nil || doneEv.Done == nil || doneEv.Done.WorkspaceID == "" {
t.Fatalf("expected event:done with workspace_id, got %d events", len(events))
}
workDir := doneEv.Done.WorkspaceID
// 验证 materials_text 目录下是否存在 initial_requirements.txt 且内容还原正确
savedFile := workDir + "/context/materials_text/initial_requirements.txt"
content, err := os.ReadFile(savedFile)
if err != nil {
t.Fatalf("expected material file at %s: %v", savedFile, err)
}
if string(content) != rawText {
t.Fatalf("material content mismatch: got %q, want %q", string(content), rawText)
}
}