Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
1408 lines
48 KiB
Go
1408 lines
48 KiB
Go
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)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// TestMockCardflowFullLoop 卡流全链路(D39/D35/D32):mock #cardflow → result 卡入收件箱
|
||
// → 助手消息含 cardId 引用部件 → finishRun 回填锚点(卡→消息互定位)。
|
||
func TestMockCardflowFullLoop(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
runID := postMessage(t, h, convID, "#cardflow")
|
||
terminal := waitRunTerminal(t, h, runID, 5*time.Second)
|
||
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, _ := newTestServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
runID := postMessage(t, h, convID, "#cardstream")
|
||
terminal := waitRunTerminal(t, h, runID, 5*time.Second)
|
||
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, 5*time.Second)
|
||
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, 5*time.Second)
|
||
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, 5*time.Second)
|
||
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, 5*time.Second)
|
||
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, 5*time.Second)
|
||
|
||
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) == 0 {
|
||
t.Fatal("artifact not created")
|
||
}
|
||
art := list[0].(map[string]any)
|
||
req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil)
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
// mock #doc 走 docgen:内容是 zip 容器,标题/表格排版在 word/document.xml 里。
|
||
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, _ := newTestServer(t)
|
||
h := srv.Handler()
|
||
_, convID := setupConversation(t, h)
|
||
runID := postMessage(t, h, convID, "#doc")
|
||
terminal := waitRunTerminal(t, h, runID, 5*time.Second)
|
||
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-会议纪要.docx" {
|
||
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, 5*time.Second)
|
||
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, 5*time.Second)
|
||
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()
|
||
}
|