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, DurationMs: 1500}, {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 TestGoldenPromptModule(t *testing.T) { golden(t, "prompt_module_subagent", PromptModule{ ID: "agent.sub.explore", Layer: LayerAgent, Version: 1, Title: "探索子代理", Body: "只读检索。", Tools: []string{"knowledge_search", "web_search"}, Model: "lightweight", MaxTurns: 8, }) } 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 TestGoldenProjectStreamEvent(t *testing.T) { // 项目流(D50):伪会话键 "project:" 复用会话流机制;信封带 ProjectID。 golden(t, "event_project_stream", StreamEvent{Seq: 47, ConversationID: "project:prj_abc123def456", ProjectID: "prj_abc123def456", TS: "2026-09-30T11:00:00Z", V: 1, Type: EvCardCreated, Payload: json.RawMessage(`{"card":{"id":"card_def456abc789","projectId":"prj_abc123def456","kind":"result","title":"项目级卡","status":"done","createdAt":"2026-09-30T11:00:00Z","updatedAt":"2026-09-30T11:00:00Z"}}`)}) } 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 TestGoldenWorkflowDef(t *testing.T) { golden(t, "workflow_def", WorkflowDef{ ID: "demo.brief", Title: "简报", Description: "按主题写一份简报", IntentID: "demo.brief", MaxRetries: 6, Steps: []WorkflowStep{ {ID: "length", Title: "篇幅", Kind: "hitl", Hitl: &HITLCardTemplate{ Question: "篇幅?", Kind: HITLKindChoice, AllowFreeInput: false, Choices: []HITLChoice{{ID: "short", Label: "短", Description: "一页", Impact: "更快"}}, }}, {ID: "draft", Title: "起草", Kind: "prompt", Agent: "research", Prompt: "写一份关于 {{slots.topic}} 的简报。用户说:{{input}}。上一步:{{steps.length.output}}", Critic: &StepCritic{Criteria: "必须点明主题", MaxRetries: 1}}, {ID: "lookup", Title: "查时间", Kind: "tool", Tool: "time_now", Args: json.RawMessage(`{"tz":"UTC"}`)}, {ID: "save", Title: "存稿", Kind: "artifact", Artifact: "brief.md", Prompt: "{{steps.draft.output}}"}, {ID: "note", Title: "通知", Kind: "card", Card: &ActivityCardTemplate{Kind: CardKindResult, Title: "简报已写好"}}, {ID: "run", Title: "脚本", Kind: "script", Script: "scripts/demo.py"}, }, }) } 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, }) }