Files
weichen 49c1259e00 Keep the server admission test inside the intent boundary
Why: the U6 admission test imported agent-intent directly from
internal/server, which lint-boundaries forbids (only intentgate,
intentsem and the squad intent adapter may import it).
Evidence: repair receipt success; server/intentgate/route tests pass
(verify-u6r.json); fixtures moved behind intentgate helpers.
Impact: test-only rework plus a small intentgate fixture helper.
Refs: feature/agent-stack-audit-remediation-b1 U6 repair
2026-10-21 17:35:53 +00:00

1461 lines
50 KiB
Go
Raw Permalink 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 (
"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:<id> 伪键,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), "<w:tbl>") {
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:装载插件意图后,自由输入命中其他意图 → 转轨(intentgate 闸门)。
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": `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="报价" sheetId="1" r:id="rId1"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`,
"xl/worksheets/sheet1.xml": `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>物料</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>支架</t></is></c></row>
</sheetData></worksheet>`,
}
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()
}