- Add /api/chat/stream endpoint with Server-Sent Events (SSE) for real-time message streaming * Implement StreamEvent types (thought, tool_call, tool_result, final, error) * Add StreamEventCallback mechanism for event propagation * Create StreamChatHandler in webui/bot with proper HTTP headers and flushing - Implement LLM-based skill router for intelligent capability selection * Add optional routerLLM client for semantic routing * Implement routeSkillsWithLLM() to match user intent to available skills * Add matchSkillsByName() for fuzzy skill matching * Update buildUnifiedSystemPrompt() to use routed skills - Add streaming support to ReAct pipeline * Implement runUnifiedReActStream() for streaming thought/action/observation * Emit StreamEvent at each ReAct step * Support callback error handling in streaming mode - Integrate three new DevOps tools * tools/filedoc: Extract document content from file_id via OpenAI * tools/giteaticket: Create Gitea issues from PI plan items with SAFe metadata * tools/piplan: Publish PI planning blueprints with dependency tracking - Add SAFe PI Planning skill * Implement PM/SA/RTE (iron triangle) workflow * Support for Feature, Enabler, and Dependency definition * Automatic task decomposition and Gitea integration - Create frontend integration documentation * Complete SSE protocol specification * TypeScript fetch + ReadableStream example * LLM-ready refactoring template for other projects - Simplify file handling * Remove legacy file context structures and dual-mode processing * Consolidate file operations into UploadAndCacheFiles() * Remove FilePromptMode configuration and related complexity - Update configuration * Add Router model support (LLM_ROUTER_MODEL) * Add Gitea configuration (BaseURL, Token, Owner, Repo) * WebSearch and additional tool infrastructure Tests: All 22 test packages passing, 8/8 webui tests including 3 new stream tests
335 lines
8.7 KiB
Go
335 lines
8.7 KiB
Go
package piplan
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func validInput() PIPlanInput {
|
||
return PIPlanInput{
|
||
PIVision: "实现车云一体化 OTA 系统,支撑百万级终端设备的安全固件升级",
|
||
Features: []Feature{
|
||
{
|
||
FeatureID: "FEAT_OTA_001",
|
||
Title: "云端固件版本依赖检查",
|
||
BenefitHypothesis: "上线后减少 30% 的固件回退率",
|
||
AcceptanceCriteria: []string{
|
||
"上传固件时自动解析并记录版本依赖关系",
|
||
"下发升级任务时自动校验设备当前版本是否满足依赖",
|
||
"不满足依赖时返回明确的错误提示及所需前置版本",
|
||
},
|
||
},
|
||
{
|
||
FeatureID: "FEAT_OTA_002",
|
||
Title: "端侧断点续传",
|
||
BenefitHypothesis: "弱网环境下固件下载成功率提升至 99.5%",
|
||
AcceptanceCriteria: []string{
|
||
"支持分片下载与本地缓存校验",
|
||
"网络恢复后自动续传,无需用户干预",
|
||
},
|
||
},
|
||
},
|
||
Enablers: []Enabler{
|
||
{
|
||
EnablerID: "ENAB_KAFKA_001",
|
||
Title: "搭建跨可用区的高可用 Kafka 集群",
|
||
ArchitecturalPurpose: "为高并发 OTA 状态机提供可靠消息管道",
|
||
},
|
||
{
|
||
EnablerID: "ENAB_S3_001",
|
||
Title: "对象存储多区域同步",
|
||
ArchitecturalPurpose: "保证固件文件在多区域的低延迟分发",
|
||
},
|
||
},
|
||
NFRs: NFRs{
|
||
Performance: "API 响应时间 P99 < 200ms,吞吐量 > 10000 QPS",
|
||
Security: "车云通信必须使用 TLS 1.3,敏感数据必须脱敏",
|
||
},
|
||
Dependencies: []Dependency{
|
||
{
|
||
SourceID: "ENAB_KAFKA_001",
|
||
TargetID: "FEAT_OTA_001",
|
||
Reason: "版本检查服务依赖 Kafka 进行异步事件通知",
|
||
},
|
||
{
|
||
SourceID: "ENAB_S3_001",
|
||
TargetID: "FEAT_OTA_002",
|
||
Reason: "断点续传需要对象存储支持 Range 请求",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestParseInputValid(t *testing.T) {
|
||
in := validInput()
|
||
data, _ := json.Marshal(in)
|
||
plan, err := parseInput(string(data))
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if plan.PIVision != in.PIVision {
|
||
t.Errorf("pi_vision mismatch: got %q", plan.PIVision)
|
||
}
|
||
if len(plan.Features) != 2 {
|
||
t.Errorf("expected 2 features, got %d", len(plan.Features))
|
||
}
|
||
if len(plan.Enablers) != 2 {
|
||
t.Errorf("expected 2 enablers, got %d", len(plan.Enablers))
|
||
}
|
||
if len(plan.Dependencies) != 2 {
|
||
t.Errorf("expected 2 dependencies, got %d", len(plan.Dependencies))
|
||
}
|
||
}
|
||
|
||
func TestParseInputEmpty(t *testing.T) {
|
||
_, err := parseInput("")
|
||
if err == nil {
|
||
t.Fatal("expected error for empty input")
|
||
}
|
||
}
|
||
|
||
func TestParseInputInvalidJSON(t *testing.T) {
|
||
_, err := parseInput("{not json}")
|
||
if err == nil {
|
||
t.Fatal("expected error for invalid JSON")
|
||
}
|
||
}
|
||
|
||
func TestValidateMissingVision(t *testing.T) {
|
||
in := validInput()
|
||
in.PIVision = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "pi_vision") {
|
||
t.Fatalf("expected pi_vision error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateNoFeatures(t *testing.T) {
|
||
in := validInput()
|
||
in.Features = nil
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "feature") {
|
||
t.Fatalf("expected feature error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateFeatureMissingID(t *testing.T) {
|
||
in := validInput()
|
||
in.Features[0].FeatureID = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "feature_id") {
|
||
t.Fatalf("expected feature_id error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateFeatureMissingAC(t *testing.T) {
|
||
in := validInput()
|
||
in.Features[0].AcceptanceCriteria = nil
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "acceptance_criteria") {
|
||
t.Fatalf("expected acceptance_criteria error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateEnablerMissingPurpose(t *testing.T) {
|
||
in := validInput()
|
||
in.Enablers[0].ArchitecturalPurpose = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "architectural_purpose") {
|
||
t.Fatalf("expected architectural_purpose error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateNFRsMissingPerformance(t *testing.T) {
|
||
in := validInput()
|
||
in.NFRs.Performance = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "performance") {
|
||
t.Fatalf("expected performance error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateNFRsMissingSecurity(t *testing.T) {
|
||
in := validInput()
|
||
in.NFRs.Security = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "security") {
|
||
t.Fatalf("expected security error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidateDepMissingSourceID(t *testing.T) {
|
||
in := validInput()
|
||
in.Dependencies[0].SourceID = ""
|
||
err := validate(&in)
|
||
if err == nil || !strings.Contains(err.Error(), "source_id") {
|
||
t.Fatalf("expected source_id error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRenderContainsSections(t *testing.T) {
|
||
in := validInput()
|
||
out := render(&in)
|
||
|
||
sections := []string{
|
||
"# PI 规划架构蓝图与任务清单",
|
||
"## 1. PI 愿景",
|
||
"## 2. 业务特性清单 (Features)",
|
||
"## 3. 技术赋能特性 (Enablers / 架构跑道)",
|
||
"## 4. 非功能性需求 (NFRs)",
|
||
"## 5. 依赖关系",
|
||
"## 6. 建议执行顺序",
|
||
"## 7. 质量门禁检查清单",
|
||
}
|
||
for _, s := range sections {
|
||
if !strings.Contains(out, s) {
|
||
t.Errorf("output missing section: %s", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRenderContainsFeatureDetails(t *testing.T) {
|
||
in := validInput()
|
||
out := render(&in)
|
||
|
||
if !strings.Contains(out, "FEAT_OTA_001") {
|
||
t.Error("output missing FEAT_OTA_001")
|
||
}
|
||
if !strings.Contains(out, "云端固件版本依赖检查") {
|
||
t.Error("output missing feature title")
|
||
}
|
||
if !strings.Contains(out, "AC-1") {
|
||
t.Error("output missing acceptance criteria numbering")
|
||
}
|
||
}
|
||
|
||
func TestRenderContainsEnablerTable(t *testing.T) {
|
||
in := validInput()
|
||
out := render(&in)
|
||
|
||
if !strings.Contains(out, "ENAB_KAFKA_001") {
|
||
t.Error("output missing ENAB_KAFKA_001")
|
||
}
|
||
if !strings.Contains(out, "ENAB_S3_001") {
|
||
t.Error("output missing ENAB_S3_001")
|
||
}
|
||
}
|
||
|
||
func TestRenderContainsNFRs(t *testing.T) {
|
||
in := validInput()
|
||
out := render(&in)
|
||
|
||
if !strings.Contains(out, "P99 < 200ms") {
|
||
t.Error("output missing performance NFR")
|
||
}
|
||
if !strings.Contains(out, "TLS 1.3") {
|
||
t.Error("output missing security NFR")
|
||
}
|
||
}
|
||
|
||
func TestRenderContainsDependencies(t *testing.T) {
|
||
in := validInput()
|
||
out := render(&in)
|
||
|
||
if !strings.Contains(out, "ENAB_KAFKA_001") || !strings.Contains(out, "FEAT_OTA_001") {
|
||
t.Error("output missing dependency pair")
|
||
}
|
||
}
|
||
|
||
func TestComputeExecutionOrder(t *testing.T) {
|
||
in := validInput()
|
||
order := computeExecutionOrder(&in)
|
||
|
||
// Enablers should come before their dependent Features
|
||
enablerIdx := map[string]int{}
|
||
featureIdx := map[string]int{}
|
||
for i, id := range order {
|
||
if strings.HasPrefix(id, "ENAB_") {
|
||
enablerIdx[id] = i
|
||
} else if strings.HasPrefix(id, "FEAT_") {
|
||
featureIdx[id] = i
|
||
}
|
||
}
|
||
|
||
if enablerIdx["ENAB_KAFKA_001"] >= featureIdx["FEAT_OTA_001"] {
|
||
t.Error("ENAB_KAFKA_001 should come before FEAT_OTA_001")
|
||
}
|
||
if enablerIdx["ENAB_S3_001"] >= featureIdx["FEAT_OTA_002"] {
|
||
t.Error("ENAB_S3_001 should come before FEAT_OTA_002")
|
||
}
|
||
}
|
||
|
||
func TestComputeExecutionOrderNoDeps(t *testing.T) {
|
||
in := validInput()
|
||
in.Dependencies = nil
|
||
order := computeExecutionOrder(&in)
|
||
|
||
if len(order) != 4 {
|
||
t.Errorf("expected 4 items, got %d", len(order))
|
||
}
|
||
// Enablers first, then Features (stable order)
|
||
if order[0] != "ENAB_KAFKA_001" || order[1] != "ENAB_S3_001" {
|
||
t.Errorf("enablers should come first, got: %v", order)
|
||
}
|
||
}
|
||
|
||
func TestCallEndToEnd(t *testing.T) {
|
||
tool := New(0, nil)
|
||
in := validInput()
|
||
data, _ := json.Marshal(in)
|
||
|
||
result, err := tool.Call(context.Background(), string(data))
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if !strings.Contains(result, "PI 规划架构蓝图") {
|
||
t.Error("output missing title")
|
||
}
|
||
}
|
||
|
||
func TestCallInvalidInput(t *testing.T) {
|
||
tool := New(0, nil)
|
||
_, err := tool.Call(context.Background(), "not json")
|
||
if err == nil {
|
||
t.Fatal("expected error for invalid input")
|
||
}
|
||
}
|
||
|
||
func TestCallMissingRequiredField(t *testing.T) {
|
||
tool := New(0, nil)
|
||
in := validInput()
|
||
in.PIVision = ""
|
||
data, _ := json.Marshal(in)
|
||
|
||
_, err := tool.Call(context.Background(), string(data))
|
||
if err == nil {
|
||
t.Fatal("expected validation error")
|
||
}
|
||
}
|
||
|
||
func TestNameAndDescription(t *testing.T) {
|
||
tool := New(0, nil)
|
||
if tool.Name() != "publish_pi_plan" {
|
||
t.Errorf("unexpected name: %s", tool.Name())
|
||
}
|
||
if tool.Description() == "" {
|
||
t.Error("description should not be empty")
|
||
}
|
||
}
|
||
|
||
func TestMaxOutputTruncation(t *testing.T) {
|
||
tool := New(100, nil)
|
||
in := validInput()
|
||
data, _ := json.Marshal(in)
|
||
|
||
result, err := tool.Call(context.Background(), string(data))
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if len(result) > 100 {
|
||
t.Errorf("output should be truncated to 100 chars, got %d", len(result))
|
||
}
|
||
}
|