package server import ( "archive/zip" "bufio" "bytes" "context" "encoding/json" "errors" "fmt" agentcommand "agent-command" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" "io" "mime/multipart" "net/http" "net/http/httptest" "os" osexec "os/exec" "path/filepath" "strings" "testing" "time" agentllm "agent-native/runtime/llm" nativetools "agent-native/runtime/tools" nativeadp "agent-squad/adapters/native" "agent-system-backend/internal/bus" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" "agent-system-backend/internal/runmgr" "agent-system-backend/internal/runtime" "agent-system-backend/internal/settings" skillseed "agent-system-backend/internal/skills" "agent-system-backend/internal/squadadapter" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" core "agent-system-core" ) // newTestServer 组合根(测试版):mock runner 驱动全链路,不依赖模型。 func newTestServer(t *testing.T) (*Server, *store.Store) { t.Helper() dir := t.TempDir() st, err := store.OpenSQLite(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() setSvc := settings.NewService(st) // P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。 if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { t.Fatal(err) } profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { account, err := st.GetAccountByOwner(owner) if errors.Is(err, store.ErrNotFound) && owner == store.OwnerDefault { return true, nil } if err != nil { return false, err } return account.Enabled, nil }, true) pluginDir := filepath.Join(dir, "plugins") os.MkdirAll(pluginDir, 0o755) ph := plugins.New(st, reg, setSvc, pluginDir) srv := New(st, mgr, reg, profSvc, setSvc, ph) // 生产同款装配(app.go):卡片事件经 runmgr 事件缓冲持久化/发布——项目流 //(project: 伪键,D50)依赖它;此前为 no-op emitter,事件不入库。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) srv.Cards = cardSvc mgr.SetCards(cardSvc) // run 内产卡(摄取/编排/submit_card)经 runmgr,与 app.go 装配一致 srv.Bus = cmdbus.New(st, srv.Cards, nil) srv.WireBuiltinCommands() srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) } return srv, st } // stubNativeStream 内核桩模型流(零网络):首轮按任务书「期望产出」逐个 // write_file 落盘(agent-native 契约:workspace 档步骤自己写交付物), // 见到工具结果后给收尾文本。 func stubNativeStream(text string) agentllm.StreamFunc { return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event { ch := make(chan agentllm.Event, 8) go func() { defer close(ch) finish := func() { ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text} ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: text}} } prompt := "" for _, m := range msgs { if m.Role == agentllm.RoleTool { finish() return } if m.Role == agentllm.RoleUser { prompt = m.Content } } targets := stubTargets(prompt) if len(targets) == 0 { finish() return } var calls []agentllm.ToolCall for i, tgt := range targets { args := fmt.Sprintf(`{"path":%q,"content":%q}`, tgt, text) id := fmt.Sprintf("call_stub_%d", i+1) ch <- agentllm.Event{Kind: agentllm.KindToolCallDelta, ToolIndex: i, ToolID: id, ToolName: "write_file", ArgumentsDelta: args} calls = append(calls, agentllm.ToolCall{ID: id, Name: "write_file", Arguments: args}) } ch <- agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, ToolCalls: calls, StopReason: agentllm.StopToolCalls}} }() return ch } } // stubTargets 从任务书解析「期望产出:[a.md b.md]」。 func stubTargets(prompt string) []string { i := strings.Index(prompt, "期望产出:[") if i < 0 { return nil } j := strings.Index(prompt[i:], "]") if j < 0 { return nil } return strings.Fields(prompt[i+len("期望产出:[") : i+j]) } func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) { t.Helper() var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req := httptest.NewRequest(method, path, &buf) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) out := map[string]any{} if rec.Body.Len() > 0 { json.Unmarshal(rec.Body.Bytes(), &out) } return rec.Code, out } // doAny 同 doJSON 但保留任意 JSON 形态(数组端点)。 func doAny(t *testing.T, h http.Handler, method, path string, body any) (int, any) { t.Helper() var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req := httptest.NewRequest(method, path, &buf) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var out any if rec.Body.Len() > 0 { json.Unmarshal(rec.Body.Bytes(), &out) } return rec.Code, out } func mustCode(t *testing.T, h http.Handler, method, path string, body any) map[string]any { t.Helper() code, out := doJSON(t, h, method, path, body) if code >= 300 { t.Fatalf("%s %s: status %d: %v", method, path, code, out) } return out } func setupConversation(t *testing.T, h http.Handler) (projectID, convID string) { p := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "测试项目"}) projectID = p["id"].(string) c := mustCode(t, h, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "测试会话"}) convID = c["id"].(string) return } func postMessage(t *testing.T, h http.Handler, convID, text string) string { out := mustCode(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text}) run := out["run"].(map[string]any) return run["id"].(string) } // testRunTerminalTimeout bounds run-terminal waits; slow, heavily parallel CI // machines need far more than the typical few hundred milliseconds. const testRunTerminalTimeout = 30 * time.Second func waitRunTerminal(t *testing.T, h http.Handler, runID string, timeout time.Duration) map[string]any { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { _, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil) if r, ok := out["run"].(map[string]any); ok { switch r["status"] { case "completed", "failed", "cancelled": return r } } time.Sleep(40 * time.Millisecond) } t.Fatalf("run %s not terminal after %v", runID, timeout) return nil } // waitRunCompleted also waits for the persisted run.completed event. The run row // turns terminal in the settle commit, before finishRun backfills card anchors; // run.completed is emitted only after that backfill. func waitRunCompleted(t *testing.T, h http.Handler, st *store.Store, convID, runID string, timeout time.Duration) map[string]any { t.Helper() terminal := waitRunTerminal(t, h, runID, timeout) if terminal["status"] != "completed" { return terminal } deadline := time.Now().Add(timeout) var after int64 for time.Now().Before(deadline) { events, err := st.EventsAfter(convID, after, 1000) if err != nil { t.Fatal(err) } for _, e := range events { if e.RunID == runID && e.Type == core.EvRunCompleted { return terminal } after = e.Seq } if len(events) < 1000 { time.Sleep(10 * time.Millisecond) } } t.Fatalf("run %s completed without run.completed event after %v", runID, timeout) return nil } // TestMockCardflowFullLoop 卡流全链路(D39/D35/D32):mock #cardflow → result 卡入收件箱 // → 助手消息含 cardId 引用部件 → finishRun 回填锚点(卡→消息互定位)。 func TestMockCardflowFullLoop(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#cardflow") terminal := waitRunCompleted(t, h, st, convID, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status: %v", terminal["status"]) } // 收件箱出现 result 卡。 _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) cards, _ := cardsResp["cards"].([]any) var resultCard map[string]any for _, ci := range cards { c := ci.(map[string]any) if c["kind"] == "result" && c["title"] == "会议纪要 · 第 12 次周会" { resultCard = c } } if resultCard == nil { t.Fatalf("result card missing from inbox: %v", cards) } // 助手消息含 cardId 引用部件(卡片为产物权威,消息只存引用)。 _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) msgs := detail["messages"].([]any) assistant := msgs[len(msgs)-1].(map[string]any) cardID := resultCard["id"].(string) refFound := false for _, pi := range assistant["parts"].([]any) { p := pi.(map[string]any) if p["type"] != "card" { continue } cp := p["card"].(map[string]any) if cp["cardId"] == cardID { refFound = true } } if !refFound { t.Errorf("assistant message missing card-part with cardId=%s", cardID) } // finishRun 回填锚点:卡→消息互定位(D32 方向)。 if anchor, _ := resultCard["anchorMessageId"].(string); anchor != assistant["id"].(string) { t.Errorf("card anchorMessageId = %q, want assistant message %q", anchor, assistant["id"]) } } // TestMockCardflowStreamLoop 流式产卡全链路(CF-S/D44-D45):mock #cardstream → // card_begin(active 卡入收件箱)→ card_append×N(card.updated 增量)→ // card_commit(done + cardId 引用部件 + finishRun 锚点回填),且正文逐段合并。 func TestMockCardflowStreamLoop(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#cardstream") terminal := waitRunCompleted(t, h, st, convID, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status: %v", terminal["status"]) } // 收件箱出现 result 卡(commit 后终态 done)。 _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) cards, _ := cardsResp["cards"].([]any) var streamCard map[string]any for _, ci := range cards { c := ci.(map[string]any) if c["kind"] == "result" && c["title"] == "项目周报 · 流式" { streamCard = c } } if streamCard == nil { t.Fatalf("stream card missing from inbox: %v", cards) } if streamCard["status"] != "done" { t.Errorf("stream card status = %v, want done", streamCard["status"]) } // 正文逐段合并(begin 的 kv 摘要 + 三段 append)。 body, _ := streamCard["body"].([]any) full := "" for _, bi := range body { n := bi.(map[string]any) if n["type"] == "markdown" { full += n["markdown"].(string) } } for _, seg := range []string{"## 进展", "## 风险", "## 下周", "artifact 统一"} { if !strings.Contains(full, seg) { t.Errorf("stream body missing %q, got %q", seg, full) } } // 助手消息含 cardId 引用部件 + 锚点回填。 _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) msgs := detail["messages"].([]any) assistant := msgs[len(msgs)-1].(map[string]any) cardID := streamCard["id"].(string) refFound := false for _, pi := range assistant["parts"].([]any) { p := pi.(map[string]any) if p["type"] != "card" { continue } cp := p["card"].(map[string]any) if cp["cardId"] == cardID { refFound = true } } if !refFound { t.Errorf("assistant message missing card-part with cardId=%s", cardID) } if anchor, _ := streamCard["anchorMessageId"].(string); anchor != assistant["id"].(string) { t.Errorf("stream card anchorMessageId = %q, want assistant message %q", anchor, assistant["id"]) } } func TestMockConversationFlow(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "你好,骨架测试") terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status: %v", terminal["status"]) } _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) msgs := detail["messages"].([]any) if len(msgs) != 2 { t.Fatalf("expected 2 messages, got %d", len(msgs)) } assistant := msgs[1].(map[string]any) parts := assistant["parts"].([]any) kinds := map[string]bool{} processCount := 0 for _, pi := range parts { p := pi.(map[string]any) kinds[p["type"].(string)] = true if b, ok := p["process"].(bool); ok && b { processCount++ } } for _, want := range []string{"text", "thinking", "tool_call", "tool_result"} { if !kinds[want] { t.Errorf("assistant parts missing %q: %v", want, kinds) } } if processCount == 0 { t.Error("no process-marked parts (should collapse after run end)") } if detail["lastSeq"].(float64) <= 0 { t.Error("lastSeq should be positive") } // 上下文组装观测随 run.completed 事件下发(4.10/4.17 可解释性)。 evs := replayEvents(t, st, convID) found := false for _, e := range evs { if e.Type != core.EvRunCompleted { continue } var p core.RunCompletedPayload if json.Unmarshal(e.Payload, &p) == nil && p.Context != nil && len(p.Context.Items) > 0 { found = true } } if !found { t.Error("run.completed payload missing context observation") } } // replayEvents 经 store 重放事件(SSE 重放的数据同源)。 func replayEvents(t *testing.T, st *store.Store, convID string) []core.StreamEvent { t.Helper() evs, err := st.EventsAfter(convID, 0, 1000) if err != nil { t.Fatal(err) } return evs } func TestHITLChoiceFlow(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#hitl") var card *core.HITLCard deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) && card == nil { code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if code == 200 { if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { raw, _ := json.Marshal(cards[0]) c := core.HITLCard{} json.Unmarshal(raw, &c) card = &c } } time.Sleep(30 * time.Millisecond) } if card == nil { t.Fatal("no active hitl card") } for _, ch := range card.Choices { if ch.Impact == "" { t.Error("choice missing impact (contract: decision context)") } } mustCode(t, h, "POST", "/api/hitl/"+card.ID+"/resolve", map[string]any{"choiceId": "md"}) terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status after resolve: %v", terminal["status"]) } _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) msgs := d["messages"].([]any) found := false for _, pi := range msgs[len(msgs)-1].(map[string]any)["parts"].([]any) { if p := pi.(map[string]any); p["type"] == "hitl" { found = true } } if !found { t.Error("assistant message missing hitl summary part") } } func TestConversationResumeSeq(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#hitl") deadline := time.Now().Add(5 * time.Second) var waiting map[string]any for time.Now().Before(deadline) && waiting == nil { code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if code == 200 { if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { waiting = d } } time.Sleep(30 * time.Millisecond) } if waiting == nil { t.Fatal("no active hitl card") } lastSeq := int64(waiting["lastSeq"].(float64)) resumeSeq := int64(waiting["resumeSeq"].(float64)) var first int64 for _, e := range replayEvents(t, st, convID) { if string(e.RunID) == runID { first = e.Seq break } } if first == 0 { t.Fatal("run has no events") } if resumeSeq != first-1 { t.Fatalf("resumeSeq = %d, want first-1 = %d", resumeSeq, first-1) } if resumeSeq >= lastSeq { t.Fatalf("resumeSeq %d should be before lastSeq %d while the run is open", resumeSeq, lastSeq) } cardID := waiting["activeHitl"].([]any)[0].(map[string]any)["id"].(string) mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"choiceId": "md"}) terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status after resolve: %v", terminal["status"]) } _, settled := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if int64(settled["resumeSeq"].(float64)) != int64(settled["lastSeq"].(float64)) { t.Fatalf("resumeSeq = %v, lastSeq = %v, want them equal after the run ends", settled["resumeSeq"], settled["lastSeq"]) } } func TestHITLFreeTextPivot(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#hitl") // 等待卡片并以 / 命令自由输入 → 转轨(run 取消)。 var cardID string deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) && cardID == "" { _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { cardID = cards[0].(map[string]any)["id"].(string) } time.Sleep(30 * time.Millisecond) } if cardID == "" { t.Fatal("no active card") } out := mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"freeText": "/new"}) if out["pivoted"] != true { t.Fatalf("expected pivot=true, got %v", out) } terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "cancelled" { t.Fatalf("suspended run should be cancelled on pivot, got %v", terminal["status"]) } _ = projectID } func TestSubagentFlow(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#agents") terminal := waitRunTerminal(t, h, runID, 10*time.Second) if terminal["status"] != "completed" { t.Fatalf("status %v", terminal["status"]) } _, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil) children := out["children"].([]any) if len(children) != 2 { t.Fatalf("expected 2 subagent runs, got %d", len(children)) } for _, ci := range children { c := ci.(map[string]any) if c["kind"] != "subagent" || c["parentRunId"] != runID { t.Errorf("bad child run: %+v", c) } if c["status"] != "completed" { t.Errorf("subagent not completed: %+v", c) } } } func TestArtifactFlow(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#doc") waitRunTerminal(t, h, runID, testRunTerminalTimeout) code, raw := doAny(t, h, "GET", "/api/projects/"+projectID+"/artifacts", nil) if code != 200 { t.Fatal("list artifacts failed") } list := raw.([]any) if len(list) != 4 { t.Fatalf("#doc artifacts = %d, want Markdown, DOCX, manifest, archive", len(list)) } var art map[string]any seen := map[string]bool{} for _, rawArtifact := range list { candidate := rawArtifact.(map[string]any) name := candidate["name"].(string) switch { case strings.HasSuffix(name, ".manifest.json"): seen["manifest"] = true case strings.HasSuffix(name, ".docx"): seen["docx"] = true art = candidate case strings.HasSuffix(name, ".md"): seen["markdown"] = true case strings.HasSuffix(name, ".zip"): seen["archive"] = true } } if art == nil || len(seen) != 4 { t.Fatalf("#doc artifact set incomplete: %#v", list) } req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) // mock #doc 走共享 renderer:DOCX 仍可回读标题和表格。 body := rec.Body.Bytes() zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { t.Fatalf("artifact 不是合法 docx 容器: %v", err) } var docXML []byte for _, f := range zr.File { if f.Name == "word/document.xml" { rc, oerr := f.Open() if oerr == nil { docXML, _ = io.ReadAll(rc) rc.Close() } } } if !strings.Contains(string(docXML), "演示会议纪要") || !strings.Contains(string(docXML), "") { t.Error("docx 缺少标题/表格排版(仍是纯文本?)") } } // TestArtifactFacadeCard 工件门面卡全链路(CF-A/D47):mock #doc → 工件落库 + // artifact_ref 部件 → 门面卡入收件箱(摘要 kv + doc-ref)→ 消息含 card 引用 → // finishRun 锚点回填。 func TestArtifactFacadeCard(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#doc") terminal := waitRunCompleted(t, h, st, convID, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status: %v", terminal["status"]) } _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) cards, _ := cardsResp["cards"].([]any) var facade map[string]any for _, ci := range cards { c := ci.(map[string]any) if c["kind"] == "result" && c["title"] == "工具大结果 · demo-会议纪要.zip" { facade = c } } if facade == nil { t.Fatalf("facade card missing from inbox: %v", cards) } if facade["status"] != "done" { t.Errorf("facade status = %v, want done", facade["status"]) } hasDocRef := false for _, bi := range facade["body"].([]any) { n := bi.(map[string]any) if n["type"] == "doc-ref" && n["docId"] != "" { hasDocRef = true } } if !hasDocRef { t.Error("facade body missing doc-ref node") } _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) msgs := detail["messages"].([]any) assistant := msgs[len(msgs)-1].(map[string]any) cardID := facade["id"].(string) hasRef := false for _, pi := range assistant["parts"].([]any) { p := pi.(map[string]any) if p["type"] == "card" && p["card"].(map[string]any)["cardId"] == cardID { hasRef = true } } if !hasRef { t.Errorf("assistant message missing facade card ref %s", cardID) } if anchor, _ := facade["anchorMessageId"].(string); anchor != assistant["id"].(string) { t.Errorf("facade anchorMessageId = %q, want %q", anchor, assistant["id"]) } } func TestUploadParseAndKnowledge(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() projectID, _ := setupConversation(t, h) body := &bytes.Buffer{} mw := multipart.NewWriter(body) fw, _ := mw.CreateFormFile("file", "法规笔记.md") fw.Write([]byte("# 法规要点\n\n- 分类规则甲\n- 临床评价路径\n")) mw.Close() req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", body) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String()) } up := map[string]any{} json.Unmarshal(rec.Body.Bytes(), &up) if up["parsed"] == nil { t.Fatal("upload response missing parsed document") } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { code, raw := doAny(t, h, "GET", "/api/knowledge/search?q=%E4%B8%B4%E5%BA%8A%E8%AF%84%E4%BB%B7", nil) if code == 200 { if hits, ok := raw.([]any); ok && len(hits) > 0 { return } } time.Sleep(100 * time.Millisecond) } t.Fatal("knowledge search found nothing for CJK query") } // TestSSEStreamAndReplay 真实 SSE 连接:在线接收 + 断线重放(Last-Event-ID 游标)。 func TestSSEStreamAndReplay(t *testing.T) { srv, _ := newTestServer(t) ts := httptest.NewServer(srv.Handler()) defer ts.Close() h := srv.Handler() _, convID := setupConversation(t, h) // 1) 在线流:先连流,再发消息,读至 run.completed。 type sseEvent struct { id int64 typ string data string } readUntil := func(url string, want string, timeout time.Duration) (events []sseEvent) { resp, err := http.Get(url) if err != nil { t.Fatal(err) } defer resp.Body.Close() sc := bufio.NewScanner(resp.Body) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) done := make(chan bool, 1) go func() { var id int64 var typ string for sc.Scan() { line := sc.Text() switch { case strings.HasPrefix(line, "id: "): fmt := strings.TrimPrefix(line, "id: ") id = parseInt64(fmt) case strings.HasPrefix(line, "event: "): typ = strings.TrimPrefix(line, "event: ") case strings.HasPrefix(line, "data: "): ev := sseEvent{id: id, typ: typ, data: strings.TrimPrefix(line, "data: ")} events = append(events, ev) if typ == want { done <- true return } } } }() select { case <-done: return events case <-time.After(timeout): t.Fatalf("timeout waiting for %s", want) return nil } } streamURL := ts.URL + "/api/events?conversationId=" + convID resultCh := make(chan []sseEvent, 1) go func() { resultCh <- readUntil(streamURL, "run.completed", 8*time.Second) }() time.Sleep(100 * time.Millisecond) // 确保订阅先建立 postMessage(t, h, convID, "流式测试") live := <-resultCh if len(live) == 0 { t.Fatal("no live events") } seen := map[string]bool{} for _, e := range live { seen[e.typ] = true } for _, want := range []string{"message.delta", "run.completed"} { if !seen[want] { t.Errorf("live stream missing %s (got %v)", want, seen) } } lastID := live[len(live)-1].id // 2) 断线重放:从 lastID 续传应只取其后事件(此处取 0 起点全量并校验单调)。 replayURL := ts.URL + "/api/events?conversationId=" + convID + "&since=0" // 重放需等在线流关闭后进行;直接断言全量事件数 ≥ 在线事件数。 resp, err := http.Get(replayURL) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("replay status %d", resp.StatusCode) } if lastID <= 0 { t.Error("last event id should be positive") } } func parseInt64(s string) int64 { var v int64 for _, c := range s { if c < '0' || c > '9' { return v } v = v*10 + int64(c-'0') } return v } func TestCommandsRoute(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) out := mustCode(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "/help"}) if out["command"] != "/help" { t.Fatalf("command route failed: %v", out) } code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "/nonexistent"}) if code == 200 { t.Error("unknown command should 400") } } func TestDangerCommandConfirmFlowE2E(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() _, convID := setupConversation(t, h) // 注册一条高危命令(模拟插件/扩展注册),API 入口 button 直发。 _ = srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ ID: "e2e.destroy", Title: "销毁演示", Danger: agentcommand.DangerHigh, ParamsSchema: json.RawMessage(`{"type":"object","required":["what"],"properties":{"what":{"type":"string"}}}`), }, "", func(ctx context.Context, req cmdbus.Request) (any, error) { w, _ := req.Params["what"].(string) return map[string]any{"destroyed": w}, nil }) // 清单可见(数组契约,legacy {name,description} 兼容):原始请求解码。 req := httptest.NewRequest("GET", "/api/commands", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("list commands: %d", rec.Code) } var cl []map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &cl); err != nil { t.Fatalf("list decode: %v", err) } found, legacySlash := false, false for _, m := range cl { if m["id"] == "e2e.destroy" && m["danger"] == "high" { found = true } if m["name"] == "/help" { legacySlash = true } } if !found || !legacySlash { t.Fatalf("listing wrong (found=%v legacy=%v): %v", found, legacySlash, cl) } // button 入口未确认 → 确认卡 + accepted 回执。 code, out := doJSON(t, h, "POST", "/api/commands/e2e.destroy", map[string]any{ "params": map[string]any{"what": "世界"}, "entrance": "button", "conversationId": convID}) if code != 200 { t.Fatalf("dispatch: %d %v", code, out) } card, _ := out["card"].(map[string]any) if card == nil { t.Fatalf("confirm card missing: %v", out) } cardID, _ := card["id"].(string) // 卡上确认 → 执行 committed + 审计可查。 code, out2 := doJSON(t, h, "POST", "/api/cards/"+cardID+"/actions/confirm", map[string]any{}) if code != 200 { t.Fatalf("confirm action: %d %v", code, out2) } if rec, _ := out2["receipt"].(map[string]any); rec == nil || rec["state"] != "committed" { t.Fatalf("confirm receipt: %v", out2) } code, audits := doJSON(t, h, "GET", "/api/commands/audit?conversationId="+convID, nil) if code != 200 { t.Fatalf("audit query: %d", code) } rows, _ := audits["audits"].([]any) if len(rows) < 2 { t.Fatalf("audit rows = %d, want >=2 (accepted+committed)", len(rows)) } states := []string{} for _, r := range rows { m, _ := r.(map[string]any) st, _ := m["state"].(string) states = append(states, st) } if states[1] != "accepted" || states[0] != "committed" { t.Fatalf("audit order wrong: %v (latest first)", states) } } func TestStaticServed(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() // dist 为空(.gitkeep):SPA 回落 index.html 缺失时 404 属预期; // 完整前端经 Dev Makefile 构建后内嵌。此处验证路由不 panic。 req := httptest.NewRequest("GET", "/", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 && rec.Code != 404 { t.Fatalf("unexpected static status %d", rec.Code) } _ = io.Discard } // TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(agent-intent 闸门)。 func TestHITLIntentPivot(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() gate := intentgate.New() if err := gate.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":"demo.show","domain":"demo","version":1,"matcher":{"includes_any":["示例"]}}`)}); err != nil { t.Fatal(err) } srv.mgr.SetGate(gate) _, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#hitl") var cardID string deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) && cardID == "" { _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { cardID = cards[0].(map[string]any)["id"].(string) } time.Sleep(30 * time.Millisecond) } if cardID == "" { t.Fatal("no card") } // 自由输入命中 demo_intent → 转轨(pivoted=true,挂起 run 取消)。 out := mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"freeText": "来个示例演示"}) if out["pivoted"] != true { t.Fatalf("intent hit should pivot: %v", out) } terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "cancelled" { t.Fatalf("suspended run should cancel on intent pivot, got %v", terminal["status"]) } } // TestSquadOrchestration:Agent-Squad 引擎真实驱动——规则规划器四阶段计划、 // Q3 WP2 后:每步经 "agent-native" 内核后端在进程内执行(桩 Stream,零网络; // SpawnSubagent 压缩路径已退役——步骤不再派生任务树子 Run)、计划事件、交付物工件。 func TestSquadOrchestration(t *testing.T) { srv, _ := newTestServer(t) skillsRoot := t.TempDir() if _, serr := skillseed.Seed(skillsRoot); serr != nil { t.Fatal(serr) } savedNative := squadadapter.AgentNativeOptions squadadapter.AgentNativeOptions = func(squadadapter.Ctx) (nativeadp.Config, bool) { py, _ := osexec.LookPath("python3") return nativeadp.Config{DefaultModel: "stub", SkillsRoots: []string{skillsRoot}, Exec: &nativetools.ExecConfig{Bash: "/bin/bash", Python: py}, Stream: stubNativeStream( "# 交付\n\n这是内核桩后端产出的步骤交付物正文,长度超过五十个字符以满足最小质检要求。")}, true } defer func() { squadadapter.AgentNativeOptions = savedNative }() h := srv.Handler() projectID, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#squad 生成一份示例目标简报") // 编排可能挂起征求确认(规则规划器派生角色时 NeedsConfirmation): // 自动以首个选项(approve)应答出现的卡片,直至 run 终态。 stop := make(chan struct{}) go func() { for { select { case <-stop: return default: } _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 { card := cards[0].(map[string]any) choices := card["choices"].([]any) choice := "" if len(choices) > 0 { choice = choices[0].(map[string]any)["id"].(string) } doJSON(t, h, "POST", "/api/hitl/"+card["id"].(string)+"/resolve", map[string]any{"choiceId": choice}) } time.Sleep(80 * time.Millisecond) } }() terminal := waitRunTerminal(t, h, runID, 45*time.Second) close(stop) if terminal["status"] != "completed" { t.Fatalf("squad run status: %v (err: %v)", terminal["status"], terminal["error"]) } // 1) 任务树:内核后端进程内执行步骤,不再派生子代理 run //(SpawnSubagent 压缩路径退役的行为面)。 _, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil) children := out["children"].([]any) if len(children) != 0 { t.Fatalf("kernel steps must not spawn subagent runs, got %d children", len(children)) } // 2) 计划快照:四阶段骨架步骤映射到右栏计划。 _, planRaw := doAny(t, h, "GET", "/api/conversations/"+convID+"/plan", nil) p, ok := planRaw.(map[string]any) if !ok { t.Fatalf("no plan snapshot after squad orchestration: %v", planRaw) } steps := p["steps"].([]any) if len(steps) < 3 { t.Fatalf("plan steps: %d", len(steps)) } doneOrMore := 0 for _, si := range steps { if si.(map[string]any)["status"] == "done" { doneOrMore++ } } if doneOrMore < 3 { t.Errorf("plan steps not mostly done: %d/%d", doneOrMore, len(steps)) } // 3) 交付物工件。 deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { _, raw := doAny(t, h, "GET", "/api/projects/"+projectID+"/artifacts", nil) if list, ok := raw.([]any); ok && len(list) > 0 { for _, ai := range list { name := ai.(map[string]any)["name"].(string) if strings.HasPrefix(name, "squad-") { return // 全部断言通过 } } } time.Sleep(100 * time.Millisecond) } t.Fatal("squad deliverable artifact not found") } // TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统 // 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名 // 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。 func TestModelRoutingOverrideChain(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) // user/project 层写受管项 → 400(含点路径与整体子树覆盖)。 for _, tc := range []struct { name string path string body map[string]any }{ {"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}}, {"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}}, {"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}}, {"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}}, } { code, out := doJSON(t, h, "PUT", tc.path, tc.body) if code != http.StatusBadRequest { t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out) } } // 系统层(无认证测试档 = 本机管理员)写入生效。 code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{ "models": map[string]any{"chat": map[string]any{"model": "model-SYS"}}, }) if code != 200 { t.Fatalf("system write: %d %v", code, out) } if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" { t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", "")) } // 系统 GET 面:schema 携带 scope,系统值脱敏返回。 code, out = doJSON(t, h, "GET", "/api/settings/system", nil) if code != 200 { t.Fatalf("get system: %d %v", code, out) } sysView := out if sysView["system"] == nil || sysView["schema"] == nil { t.Fatalf("system view malformed: %#v", sysView) } // 非受管项仍可走 user 层(回归)。 if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 { t.Fatalf("non-managed user write rejected") } // 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。 if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 { t.Fatal("disable mock via system layer failed") } prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{ "id": "prof_route_c", "name": "路由C", "systemPrompt": "x", "model": map[string]any{"chat": "model-C"}, "tools": map[string]any{"allow": []string{"*"}}, "context": map[string]any{"maxHistoryMessages": 10}, "hitl": map[string]any{"askOnAmbiguity": true}, "budget": map[string]any{}, }) if prof["id"] != "prof_route_c" { t.Fatalf("profile save: %v", prof) } mustCode(t, h, "PATCH", "/api/conversations/"+convID, map[string]any{"profileId": "prof_route_c"}) runID := postMessage(t, h, convID, "触发一次真实模型调用(预期失败并携带解析出的模型名)") terminal := waitRunTerminal(t, h, runID, 10*time.Second) if terminal["status"] != "failed" { t.Fatalf("expected failed run (no baseUrl), got %v", terminal["status"]) } errMsg := terminal["error"].(map[string]any)["message"].(string) if !strings.Contains(errMsg, `model-C`) { t.Fatalf("run error should carry profile-routed model name: %q", errMsg) } } // TestTaskListAndFlags 方案 A R1:/api/me 身份、跨项目任务列表与置顶/归档 PATCH。 func TestTaskListAndFlags(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() p1 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P1"}) p2 := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "P2"}) c1 := mustCode(t, h, "POST", "/api/projects/"+p1["id"].(string)+"/conversations", map[string]any{"title": "t1"}) c2 := mustCode(t, h, "POST", "/api/projects/"+p2["id"].(string)+"/conversations", map[string]any{"title": "t2"}) // 身份:嵌入式档(无认证中间件)→ 默认租户,auth=false。 code, me := doJSON(t, h, "GET", "/api/me", nil) if code != 200 || me["owner"] != store.OwnerDefault || me["auth"] != false { t.Fatalf("me: code=%d owner=%v auth=%v", code, me["owner"], me["auth"]) } if s, _ := me["name"].(string); s == "" { t.Fatalf("me.name must be non-empty, got %v", me["name"]) } // 跨项目任务列表:两个项目的会话都在。 code, list := doAny(t, h, "GET", "/api/conversations", nil) if code != 200 { t.Fatalf("listTasks code=%d", code) } arr, _ := list.([]any) if len(arr) != 2 { t.Fatalf("task list must span projects, got %d", len(arr)) } // 置顶 t2 → 列表首;归档 t1 → 默认隐藏、?archived=1 可见。 mustCode(t, h, "PATCH", "/api/conversations/"+c2["id"].(string), map[string]any{"pinned": true}) mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": true}) _, list = doAny(t, h, "GET", "/api/conversations", nil) arr, _ = list.([]any) if len(arr) != 1 || arr[0].(map[string]any)["id"] != c2["id"] || arr[0].(map[string]any)["pinned"] != true { t.Fatalf("pinned-first and archived-hidden expected, got %+v", list) } _, list = doAny(t, h, "GET", "/api/conversations?archived=1", nil) arr, _ = list.([]any) if len(arr) != 2 { t.Fatalf("archived=1 must include archived, got %d", len(arr)) } // 解除归档恢复可见。 mustCode(t, h, "PATCH", "/api/conversations/"+c1["id"].(string), map[string]any{"archived": false}) _, list = doAny(t, h, "GET", "/api/conversations", nil) arr, _ = list.([]any) if len(arr) != 2 { t.Fatalf("unarchive must restore visibility, got %d", len(arr)) } // 详情读取带标记(对账权威路径)。 det := mustCode(t, h, "GET", "/api/conversations/"+c2["id"].(string), nil) if det["conversation"].(map[string]any)["pinned"] != true { t.Fatalf("detail must carry pinned flag, got %+v", det["conversation"]) } } // TestProjectInbox 项目收件箱全链路(CF3/D48-D51):#cardflow 会话卡 → REST // to_project(幂等)→ 项目聚合列表(副本可见、原卡 dismissed)→ 项目流持久化 // 事件(card.created,信封盖章 ProjectID)。 func TestProjectInbox(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() projectID, convID := setupConversation(t, h) runID := postMessage(t, h, convID, "#cardflow") terminal := waitRunTerminal(t, h, runID, testRunTerminalTimeout) if terminal["status"] != "completed" { t.Fatalf("run status: %v", terminal["status"]) } _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) var cardID string for _, ci := range cardsResp["cards"].([]any) { c := ci.(map[string]any) if c["kind"] == "result" && c["title"] == "会议纪要 · 第 12 次周会" { cardID = c["id"].(string) } } if cardID == "" { t.Fatal("result card missing") } // 留档到项目:副本项目级、原卡 dismissed。 code, raw := doAny(t, h, "POST", "/api/cards/"+cardID+"/to_project", nil) if code != 200 { t.Fatalf("to_project: %d %v", code, raw) } resp := raw.(map[string]any) copyCard := resp["card"].(map[string]any) if copyCard["conversationId"] != "" && copyCard["conversationId"] != nil { t.Errorf("副本应为项目级(conversationId 空),got %v", copyCard["conversationId"]) } if origin := resp["origin"].(map[string]any); origin["status"] != "dismissed" { t.Errorf("原卡应 dismissed, got %v", origin["status"]) } // 幂等:重复留档返回同一副本。 code2, raw2 := doAny(t, h, "POST", "/api/cards/"+cardID+"/to_project", nil) if code2 != 200 || raw2.(map[string]any)["card"].(map[string]any)["id"] != copyCard["id"] { t.Errorf("重复留档应幂等: %d %v", code2, raw2) } // 项目聚合列表含副本。 _, pc := doJSON(t, h, "GET", "/api/projects/"+projectID+"/cards", nil) found := false for _, ci := range pc["cards"].([]any) { c := ci.(map[string]any) if c["id"] == copyCard["id"] { found = true } } if !found { t.Error("project cards missing archived copy") } // 项目流事件持久化 + 信封盖章 ProjectID(D50)。 evs, err := st.EventsAfter("project:"+projectID, 0, 100) if err != nil { t.Fatal(err) } created := false for _, e := range evs { if e.Type == core.EvCardCreated && e.ProjectID == core.ID(projectID) { created = true } } if !created { t.Errorf("project stream missing card.created with ProjectID, got %d events", len(evs)) } // scope 校验:未知项目 → NotFound(不泄露存在性)。 codeX, _ := doAny(t, h, "GET", "/api/events?scope=project&projectId=prj_nonexistent", nil) if codeX != 404 && codeX != 500 { t.Errorf("unknown project subscribe should not leak, got %d", codeX) } } // TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、 // 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。 func TestSettingsDottedKeyAndSecretMask(t *testing.T) { srv, st := newTestServer(t) h := srv.Handler() svc := settings.NewService(st) set := func(body map[string]any) { t.Helper() code, out := doJSON(t, h, "PUT", "/api/settings/user", body) if code != 200 { t.Fatalf("PUT user: %d %v", code, out) } } setSys := func(body map[string]any) { t.Helper() code, out := doJSON(t, h, "PUT", "/api/settings/system", body) if code != 200 { t.Fatalf("PUT system: %d %v", code, out) } } set(map[string]any{"runtime.entry": "squad"}) if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" { t.Fatalf("dotted write did not take effect: %q", got) } raw, err := svc.UserScope(store.OwnerDefault) if err != nil { t.Fatal(err) } if _, ok := raw["runtime.entry"]; ok { t.Fatalf("dotted key stored as-is: %#v", raw) } const pool = "tvly-should-not-leak" const modelKey = "sk-should-not-leak" // P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。 if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{ "tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}, }); code != http.StatusBadRequest { t.Fatalf("managed pool write via user should 400, got %d", code) } setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}}) setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}}) // 掩码回写经系统层:不覆盖已存明文。 setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}}) eff := svc.Effective(store.OwnerDefault, "") if got := eff.GetString("models.chat.apiKey", ""); got != modelKey { t.Fatalf("mask writeback overwrote key: %q", got) } if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool { t.Fatalf("system pool not effective: %q", got) } // 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。 code, out := doJSON(t, h, "GET", "/api/settings", nil) if code != 200 { t.Fatalf("GET settings: %d", code) } blob, err := json.Marshal(out) if err != nil { t.Fatal(err) } for _, secret := range []string{pool, modelKey} { if strings.Contains(string(blob), secret) { t.Fatalf("GET /api/settings leaked %q", secret) } } user := out["user"].(map[string]any) if user["models"] != nil || user["tools"] != nil { t.Fatalf("managed subtrees exposed in user scope: %#v", user) } code, sys := doJSON(t, h, "GET", "/api/settings/system", nil) if code != 200 { t.Fatalf("GET system: %d", code) } sysBlob, err := json.Marshal(sys) if err != nil { t.Fatal(err) } for _, secret := range []string{pool, modelKey} { if strings.Contains(string(sysBlob), secret) { t.Fatalf("GET /api/settings/system leaked %q", secret) } } sysValues := sys["system"].(map[string]any) tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any) if tavily["apiKeys"] != settings.MaskPlaceholder { t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"]) } chat := sysValues["models"].(map[string]any)["chat"].(map[string]any) if chat["apiKey"] != settings.MaskPlaceholder { t.Fatalf("system apiKey mask = %v", chat["apiKey"]) } } // TestUploadXlsxParsedAndIndexed:xlsx 上传走内置解析器(builtin:xlsx、无警告), // 单元格文本进入知识检索(EXT-5 验收路径)。 func TestUploadXlsxParsedAndIndexed(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() projectID, _ := setupConversation(t, h) xlsx := buildTestXLSX(t) body := &bytes.Buffer{} mw := multipart.NewWriter(body) fw, _ := mw.CreateFormFile("file", "报价单.xlsx") fw.Write(xlsx) mw.Close() req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", body) req.Header.Set("Content-Type", mw.FormDataContentType()) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 { t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String()) } up := map[string]any{} json.Unmarshal(rec.Body.Bytes(), &up) parsed, _ := up["parsed"].(map[string]any) if parsed == nil { t.Fatalf("upload response missing parsed: %s", rec.Body.String()) } meta, _ := parsed["meta"].(map[string]any) if meta["parser"] != "builtin:xlsx" { t.Fatalf("parser: %v", meta["parser"]) } if w, ok := parsed["warnings"].([]any); ok && len(w) > 0 { t.Fatalf("xlsx upload should parse without warnings: %v", w) } deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { code, raw := doAny(t, h, "GET", "/api/knowledge/search?q=%E6%94%AF%E6%9E%B6", nil) if code == 200 { if hits, ok := raw.([]any); ok && len(hits) > 0 { return } } time.Sleep(100 * time.Millisecond) } t.Fatal("knowledge search found nothing for xlsx cell text") } // buildTestXLSX 构造最小可用 xlsx(inlineStr 单元格)。 func buildTestXLSX(t *testing.T) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) files := map[string]string{ "xl/workbook.xml": ``, "xl/_rels/workbook.xml.rels": ``, "xl/worksheets/sheet1.xml": ` 物料 支架 `, } for _, name := range []string{"xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/worksheets/sheet1.xml"} { w, err := zw.Create(name) if err != nil { t.Fatal(err) } w.Write([]byte(files[name])) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() }