Why: 卡流机制(CF1)需要消息部件正式引用收件箱 ActivityCard——submit_card 产卡时卡片为产物权威载体,消息只存引用+锚点渲染(原仅能从 data 字符串里正则抠 card:<id>)。 Evidence: go test ./... 通过;golden message.json 增 card 引用部件锁定 cardId 线格式(-update 重生成后逐字节一致)。 Impact: CardPart 增可选 cardId(omitempty,旧数据零影响);ts/types.ts 同步,镜像经 make contracts 再生成。 Refs: Dev 任务卡 feature/cardflow-r1;提案 cardflow-and-process-split(编制中)。
266 lines
12 KiB
Go
266 lines
12 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"flag"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// 黄金样本一致性:Go 序列化(线格式契约)与 testdata/golden/*.json 逐字节一致。
|
||
// ts/types.ts 镜像同一契约;前端测试按样本做形状断言。变更契约须显式更新样本。
|
||
var update = flag.Bool("update", false, "rewrite golden files")
|
||
|
||
func golden(t *testing.T, name string, v any) {
|
||
t.Helper()
|
||
data, err := json.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
path := filepath.Join("testdata", "golden", name+".json")
|
||
if *update {
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return
|
||
}
|
||
want, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("golden missing (run go test ./... -update): %v", err)
|
||
}
|
||
if string(want) != string(append(data, '\n')) {
|
||
t.Errorf("%s: golden mismatch:\n got: %s\nwant: %s", name, data, want)
|
||
}
|
||
}
|
||
|
||
func sampleMessage() Message {
|
||
return Message{
|
||
ID: "msg_abc123def456", ConversationID: "cnv_abc123def456", RunID: "run_abc123def456",
|
||
Role: RoleAssistant,
|
||
Parts: []Part{
|
||
{Type: PartThinking, Text: "考虑先检索…", Process: true},
|
||
{Type: PartToolCall, Process: true, ToolCall: &ToolCallPart{ID: "call_1", Name: "history_search", Arguments: `{"q":"部署"}`, Duration: 12.5}},
|
||
{Type: PartToolResult, Process: true, ToolResult: &ToolResultPart{CallID: "call_1", Status: "ok", Summary: "3 条命中"}},
|
||
{Type: PartText, Text: "根据历史记录……"},
|
||
{Type: PartArtifactRef, Artifact: &ArtifactRef{ArtifactID: "art_abc123def456", Name: "报告.md", Kind: "document"}},
|
||
{Type: PartCard, Card: &CardPart{Kind: "result", Data: []UINode{{Type: "markdown", Markdown: "全文见卡片"}}, CardID: "card_abc123def456"}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func sampleRun() Run {
|
||
return Run{
|
||
ID: "run_abc123def456", ConversationID: "cnv_abc123def456", ProjectID: "prj_abc123def456",
|
||
Kind: RunConversation, ProfileID: "default", Status: RunCompleted, Phase: PhaseDone,
|
||
Usage: &Usage{PromptTokens: 1200, CompletionTokens: 300, TotalTokens: 1500, DurationMs: 4200.5, ToolCalls: 2},
|
||
}
|
||
}
|
||
|
||
func TestGoldenDomain(t *testing.T) {
|
||
golden(t, "message", sampleMessage())
|
||
golden(t, "run", sampleRun())
|
||
golden(t, "project", Project{ID: "prj_abc123def456", Name: "示例项目"})
|
||
golden(t, "conversation", Conversation{ID: "cnv_abc123def456", ProjectID: "prj_abc123def456", Title: "新会话", AllowCrossRead: true, CrossPrivate: true})
|
||
golden(t, "artifact", Artifact{ID: "art_abc123def456", ProjectID: "prj_abc123def456", Name: "报告.md", Kind: "document", MIME: "text/markdown", Size: 128})
|
||
}
|
||
|
||
func TestGoldenEvents(t *testing.T) {
|
||
golden(t, "event_message_delta", StreamEvent{Seq: 42, RunID: "run_1", ConversationID: "cnv_1", TS: "2026-09-29T10:00:00Z", V: 1, Type: EvMessageDelta, Payload: json.RawMessage(`{"index":0,"text":"你"}`)})
|
||
golden(t, "event_agent_spawn", StreamEvent{Seq: 43, RunID: "run_1", ConversationID: "cnv_1", TS: "2026-09-29T10:00:01Z", V: 1, Type: EvAgentSpawn, Payload: json.RawMessage(`{"agentRun":"run_sub1","name":"检索员","task":"查法规"}`)})
|
||
golden(t, "event_run_completed", StreamEvent{Seq: 44, RunID: "run_1", ConversationID: "cnv_1", TS: "2026-09-29T10:00:02Z", V: 1, Type: EvRunCompleted, Payload: json.RawMessage(`{"usage":{"promptTokens":100,"completionTokens":20,"totalTokens":120,"durationMs":1500,"toolCalls":1}}`)})
|
||
}
|
||
|
||
func TestGoldenHITL(t *testing.T) {
|
||
golden(t, "hitl_card", HITLCard{
|
||
ID: "hitl_abc123def456", RunID: "run_1", ConversationID: "cnv_1",
|
||
Question: "采用哪种输出格式?", Kind: HITLKindChoice,
|
||
Choices: []HITLChoice{
|
||
{ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看", Recommended: true},
|
||
{ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件"},
|
||
},
|
||
AllowFreeInput: true, FreeInputHint: "也可以直接输入其他格式要求",
|
||
})
|
||
// 阻断态 + 锚定(内联确认卡:anchorMessageId 内联于对话流;blocked 禁用确认并给出出路)。
|
||
golden(t, "hitl_card_blocked", HITLCard{
|
||
ID: "hitl_def456abc789", RunID: "run_1", ConversationID: "cnv_1",
|
||
AnchorMessageID: "msg_abc123def456",
|
||
Question: "批准变更 CO-2026-007?", Kind: HITLKindConfirm,
|
||
Choices: []HITLChoice{
|
||
{ID: "approve", Label: "确认批准", Description: "变更单进入执行态", Impact: "立即生效并留痕,不可裸文本撤回"},
|
||
{ID: "reject", Label: "驳回", Description: "关闭变更单并记录理由", Impact: "留痕可审计"},
|
||
},
|
||
Blocked: true, BlockReason: "存在未闭环高风险变更单,可先处理后重新检查",
|
||
})
|
||
}
|
||
|
||
func TestGoldenActivityCard(t *testing.T) {
|
||
golden(t, "activity_card", ActivityCard{
|
||
ID: "card_abc123def456", ProjectID: "prj_abc123def456", ConversationID: "cnv_abc123def456",
|
||
SourceRunID: "run_abc123def456", AnchorMessageID: "msg_abc123def456",
|
||
Kind: CardKindCompare, Title: "项目执行周期不一致 · 目标已基线",
|
||
Status: CardActionRequired, Tone: "danger",
|
||
Body: []UINode{
|
||
{Type: UICompare, Compare: &UICompareSpec{
|
||
Left: UICompareSide{Title: "基线 V1.2 · 已评审", Tone: "danger", Nodes: []UINode{{Type: UIMarkdown, Markdown: "12 个月(立项到注册)"}}},
|
||
Right: UICompareSide{Title: "新资料 · 待确认", Tone: "ok", Nodes: []UINode{{Type: UIMarkdown, Markdown: "18 个月(含临床试验)"}}},
|
||
}},
|
||
{Type: UIProgress, Title: "切片进度", Progress: &UIProgressSpec{Label: "切片 24/24", Value: 1, Tone: "ok"}},
|
||
},
|
||
Actions: []UIAction{
|
||
{ID: "escalate", Label: "发起变更流程", Style: "primary", Command: "change.escalate"},
|
||
{ID: "keep", Label: "维持原样", Style: "danger"},
|
||
},
|
||
Pinned: true,
|
||
})
|
||
}
|
||
|
||
func TestGoldenCardEvents(t *testing.T) {
|
||
golden(t, "event_card_created", StreamEvent{Seq: 45, RunID: "run_1", ConversationID: "cnv_1", TS: "2026-09-30T10:00:00Z", V: 1, Type: EvCardCreated,
|
||
Payload: json.RawMessage(`{"card":{"id":"card_abc123def456","projectId":"prj_1","kind":"progress","title":"竞品分析 PPTX 入库","status":"active","createdAt":"2026-09-30T10:00:00Z","updatedAt":"2026-09-30T10:00:00Z"}}`)})
|
||
golden(t, "event_card_resolved", StreamEvent{Seq: 46, RunID: "run_1", ConversationID: "cnv_1", TS: "2026-09-30T10:05:00Z", V: 1, Type: EvCardResolved,
|
||
Payload: json.RawMessage(`{"cardId":"card_abc123def456","actionId":"escalate"}`)})
|
||
}
|
||
|
||
func TestGoldenUIVocabularies(t *testing.T) {
|
||
golden(t, "ui_avatar", []UINode{
|
||
{Type: UIAvatar, Avatar: &UIAvatarSpec{Name: "weichen", Sub: "OWNER"}},
|
||
{Type: UIAvatar, Avatar: &UIAvatarSpec{
|
||
Stack: []UIAvatarSpec{{Name: "甲", Tone: "ok"}, {Name: "乙", Tone: "warn"}, {Name: "丙"}},
|
||
}},
|
||
})
|
||
golden(t, "ui_vocabularies", []UINode{
|
||
{Type: UIProgress, Progress: &UIProgressSpec{Label: "市场可行性 78%", Value: 0.78, Tone: "ok"}},
|
||
{Type: UICompare, Compare: &UICompareSpec{
|
||
Left: UICompareSide{Title: "基线", Tone: "danger"},
|
||
Right: UICompareSide{Title: "新内容", Tone: "ok", Nodes: []UINode{{Type: UIMarkdown, Markdown: "18 个月"}}},
|
||
}},
|
||
{Type: UITree, Tree: &UITreeSpec{Nodes: []UITreeNode{
|
||
{ID: "stage-1", Label: "商务与策划", Count: 24, Children: []UITreeNode{
|
||
{ID: "topic-1", Label: "市场调研", Tone: "ok", Badge: "多挂载", Count: 12, Selected: true},
|
||
}},
|
||
}}},
|
||
{Type: UITable, Table: &UITableSpec{
|
||
Columns: []UITableColumn{{Key: "id", Label: "编号"}, {Key: "title", Label: "标题"}},
|
||
Rows: []UITableRow{
|
||
{ID: "ur-005", Cells: [][2]string{{"id", "UR-005"}, {"title", "项目执行周期"}}, Actions: []UIAction{{ID: "trace", Label: "追溯"}}},
|
||
},
|
||
}},
|
||
{Type: UISteps, Steps: &UIStepsSpec{Current: 2, Steps: []UIStep{
|
||
{Label: "申请", Status: "done"}, {Label: "影响分析", Status: "done"}, {Label: "评审", Status: "active"}, {Label: "批准", Status: "pending"},
|
||
}}},
|
||
{Type: UITabs, Tabs: &UITabsSpec{Active: "view", Tabs: []UITab{
|
||
{ID: "view", Label: "查看", Nodes: []UINode{{Type: UIMarkdown, Markdown: "正文"}}},
|
||
{ID: "history", Label: "历史版本"},
|
||
}}},
|
||
{Type: UITimeline, Timeline: &UITimelineSpec{Items: []UITimelineItem{
|
||
{Time: "10:42", Title: "v0.6 填入注册分类界定", Tone: "ok", Markdown: "来源 S-121", Ref: "S-121"},
|
||
{Time: "09-28", Title: "v0.5 更新竞品定价段落", Ref: "陈磊确认"},
|
||
}}},
|
||
})
|
||
}
|
||
|
||
func TestGoldenProfileAndTools(t *testing.T) {
|
||
maxT := 40
|
||
golden(t, "profile", AgentProfile{
|
||
ID: "default", Name: "通用助手", Builtin: true, Source: "builtin",
|
||
SystemPrompt: "你是一个通用智能体助手。",
|
||
Model: ModelRoute{Chat: "gpt-default", Lightweight: "gpt-lite"},
|
||
Tools: ToolPolicy{Allow: []string{"*"}, Deny: []string{"http.admin."}, Confirm: []string{"http."}},
|
||
Context: ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000},
|
||
HITL: HITLPolicy{AskOnAmbiguity: true},
|
||
Budget: RunBudget{MaxToolCalls: &maxT},
|
||
})
|
||
golden(t, "tool_http", ToolDesc{
|
||
Name: "http_translate", Description: "调用翻译 API", Source: SourceUser,
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`),
|
||
HTTP: &HTTPToolSpec{Method: "POST", URL: "https://api.example.com/translate", BodyTemplate: `{"q":"{{param.text}}"}`, TimeoutMs: 10000, ResultPath: "data.0"},
|
||
})
|
||
}
|
||
|
||
func TestGoldenIngest(t *testing.T) {
|
||
golden(t, "parsed_document", ParsedDocument{
|
||
Meta: DocMeta{Filename: "报告.docx", MIME: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Parser: "builtin:docx", Confidence: 0.92},
|
||
Blocks: []DocBlock{
|
||
{Type: BlockHeading, Level: 1, Text: "概述"},
|
||
{Type: BlockPara, Text: "本文档说明……"},
|
||
{Type: BlockList, Items: []string{"要点一", "要点二"}},
|
||
{Type: BlockTable, Rows: [][]string{{"项", "值"}, {"版本", "1.0"}}},
|
||
},
|
||
})
|
||
}
|
||
|
||
func TestGoldenPlugin(t *testing.T) {
|
||
golden(t, "plugin_manifest", PluginManifest{
|
||
ID: "example", Name: "示例插件", Version: "0.1.0", APIVersion: PluginAPIVersion,
|
||
Requires: PluginRequires{Network: []string{"api.example.com"}},
|
||
Provides: PluginProvides{Tools: []string{"http_echo"}, Intents: []string{"demo"}, Panels: []string{"demo"}, Cards: []string{"note"}, Settings: []string{"greeting"}, Commands: []string{"demo.refresh"}, Workspaces: []string{"demo-main"}},
|
||
})
|
||
}
|
||
|
||
// ---- 纯函数行为 ----
|
||
|
||
func TestToolPolicy(t *testing.T) {
|
||
p := ToolPolicy{Allow: []string{"*"}, Deny: []string{"http.admin."}, Confirm: []string{"http."}}
|
||
if !p.ToolAllowed("history_search") {
|
||
t.Error("allow * should permit builtin tools")
|
||
}
|
||
if p.ToolAllowed("http.admin.reset") {
|
||
t.Error("deny prefix should win over allow *")
|
||
}
|
||
if !p.ToolAllowed("http.fetch") || !p.NeedsConfirm("http.fetch") {
|
||
t.Error("http prefix tools allowed but need confirm")
|
||
}
|
||
if p.NeedsConfirm("history_search") {
|
||
t.Error("builtin tool should not need confirm")
|
||
}
|
||
}
|
||
|
||
func TestPlainText(t *testing.T) {
|
||
d := ParsedDocument{Blocks: []DocBlock{
|
||
{Type: BlockHeading, Level: 2, Text: "标题"},
|
||
{Type: BlockPara, Text: "段落"},
|
||
{Type: BlockTable, Rows: [][]string{{"a", "b"}}},
|
||
}}
|
||
got := d.PlainText()
|
||
for _, want := range []string{"## 标题", "段落", "a\tb"} {
|
||
if !contains(got, want) {
|
||
t.Errorf("PlainText missing %q in %q", want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSettingsMergeAndPath(t *testing.T) {
|
||
base := map[string]any{"models": map[string]any{"chat": "a", "temperature": 0.7}, "ui": map[string]any{"theme": "light"}}
|
||
over := map[string]any{"models": map[string]any{"chat": "b"}, "ui": map[string]any{"theme": "dark"}}
|
||
eff := EffectiveSettings(MergeSettings(base, over))
|
||
if eff.GetString("models.chat", "") != "b" || eff.GetNumber("models.temperature", 0) != 0.7 {
|
||
t.Error("deep merge failed")
|
||
}
|
||
if eff.GetString("models.missing", "def") != "def" {
|
||
t.Error("missing path should return default")
|
||
}
|
||
}
|
||
|
||
func contains(s, sub string) bool {
|
||
return len(s) >= len(sub) && (func() bool {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})()
|
||
}
|
||
|
||
func TestGoldenContextObservation(t *testing.T) {
|
||
golden(t, "context_observation", ContextObservation{
|
||
Items: []string{"system(画像)", "history(12)", "attachments(1)", "knowledge(3)"},
|
||
Truncations: []string{"历史 47 条超窗口 40,仅保留最近部分", "工具结果超长,转工件引用"},
|
||
EstChars: 18234,
|
||
})
|
||
}
|