2026-03-10 10:23:53 +08:00
|
|
|
package webui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
feat: implement streaming chat, skill routing, and SAFe PI planning tools
- 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
2026-03-11 17:58:19 +08:00
|
|
|
"errors"
|
2026-03-10 10:23:53 +08:00
|
|
|
"mime/multipart"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"laodingbot/internal/config"
|
|
|
|
|
"laodingbot/internal/llm"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func newTestBot(t *testing.T, maxUploadBytes int64) *Bot {
|
|
|
|
|
t.Helper()
|
|
|
|
|
b, err := NewBot(config.WebUIConfig{ListenAddr: ":8090", MaxUploadBytes: maxUploadBytes}, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("NewBot failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChatSuccess(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.chatHandler = func(_ context.Context, msg IncomingMessage) (string, error) {
|
|
|
|
|
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
|
|
|
|
t.Fatalf("unexpected message: %+v", msg)
|
|
|
|
|
}
|
|
|
|
|
return "ok", nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChat(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var out chatResponse
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
|
|
|
t.Fatalf("decode response failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if out.Reply != "ok" || out.SessionID != "s1" || out.UserID != "u1" {
|
|
|
|
|
t.Fatalf("unexpected response: %+v", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChatMissingText(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.chatHandler = func(_ context.Context, _ IncomingMessage) (string, error) { return "", nil }
|
|
|
|
|
|
|
|
|
|
body := strings.NewReader(`{"text":" "}`)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", body)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChat(w, req)
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleUploadSuccess(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.uploadHandler = func(_ context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
|
|
|
|
if chatID != "s1" || userID != "u1" {
|
|
|
|
|
t.Fatalf("unexpected ids chat=%s user=%s", chatID, userID)
|
|
|
|
|
}
|
|
|
|
|
if len(files) != 1 {
|
|
|
|
|
t.Fatalf("unexpected files len=%d", len(files))
|
|
|
|
|
}
|
|
|
|
|
if files[0].FileName != "doc.pdf" || len(files[0].Content) == 0 {
|
|
|
|
|
t.Fatalf("unexpected file payload: %+v", files[0])
|
|
|
|
|
}
|
|
|
|
|
return []string{"file_123"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var payload bytes.Buffer
|
|
|
|
|
writer := multipart.NewWriter(&payload)
|
|
|
|
|
_ = writer.WriteField("session_id", "s1")
|
|
|
|
|
_ = writer.WriteField("user_id", "u1")
|
|
|
|
|
fw, err := writer.CreateFormFile("file", "doc.pdf")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateFormFile failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, _ = fw.Write([]byte("pdf-content"))
|
|
|
|
|
_ = writer.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
|
|
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleUpload(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var out uploadResponse
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
|
|
|
|
t.Fatalf("decode response failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if out.FileID != "file_123" || out.SessionID != "s1" || out.UserID != "u1" {
|
|
|
|
|
t.Fatalf("unexpected response: %+v", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleUploadTooLarge(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 3)
|
|
|
|
|
b.uploadHandler = func(_ context.Context, _ string, _ string, _ []llm.InputFile) ([]string, error) {
|
|
|
|
|
return []string{"file_should_not_reach"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var payload bytes.Buffer
|
|
|
|
|
writer := multipart.NewWriter(&payload)
|
|
|
|
|
fw, err := writer.CreateFormFile("file", "a.txt")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateFormFile failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, _ = fw.Write([]byte("12345"))
|
|
|
|
|
_ = writer.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
|
|
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleUpload(w, req)
|
|
|
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
|
|
|
t.Fatalf("expected 413, got %d body=%s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleUploadMissingFile(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.uploadHandler = func(_ context.Context, _ string, _ string, _ []llm.InputFile) ([]string, error) {
|
|
|
|
|
return []string{"file_should_not_reach"}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var payload bytes.Buffer
|
|
|
|
|
writer := multipart.NewWriter(&payload)
|
|
|
|
|
_ = writer.WriteField("session_id", "s1")
|
|
|
|
|
_ = writer.Close()
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/upload", &payload)
|
|
|
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleUpload(w, req)
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: implement streaming chat, skill routing, and SAFe PI planning tools
- 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
2026-03-11 17:58:19 +08:00
|
|
|
|
|
|
|
|
func TestHandleChatStreamSuccess(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.streamChatHandler = func(_ context.Context, msg IncomingMessage, cb StreamEventCallback) (string, error) {
|
|
|
|
|
if msg.ChatID != "s1" || msg.UserID != "u1" || msg.Text != "hello" {
|
|
|
|
|
t.Fatalf("unexpected message: %+v", msg)
|
|
|
|
|
}
|
|
|
|
|
if err := cb(StreamEvent{Type: StreamEventTypeThought, Content: "thinking", Step: 1}); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := cb(StreamEvent{Type: StreamEventTypeToolCall, Content: "{\"input\":\"pwd\"}", Step: 1, ToolName: "shell"}); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := cb(StreamEvent{Type: StreamEventTypeToolResult, Content: "C:/Project", Step: 1, ToolName: "shell"}); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := cb(StreamEvent{Type: StreamEventTypeFinal, Content: "done", Step: 2}); err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return "done", nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := strings.NewReader(`{"text":"hello","session_id":"s1","user_id":"u1"}`)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
|
|
|
|
|
t.Fatalf("expected text/event-stream, got %q", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var events []StreamEvent
|
|
|
|
|
chunks := strings.Split(strings.TrimSpace(w.Body.String()), "\n\n")
|
|
|
|
|
for _, chunk := range chunks {
|
|
|
|
|
line := strings.TrimSpace(chunk)
|
|
|
|
|
if line == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
|
|
|
t.Fatalf("invalid sse line: %q", line)
|
|
|
|
|
}
|
|
|
|
|
payload := strings.TrimPrefix(line, "data: ")
|
|
|
|
|
var ev StreamEvent
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal stream event failed: %v payload=%s", err, payload)
|
|
|
|
|
}
|
|
|
|
|
events = append(events, ev)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(events) != 4 {
|
|
|
|
|
t.Fatalf("expected 4 events, got %d", len(events))
|
|
|
|
|
}
|
|
|
|
|
if events[0].Type != StreamEventTypeThought {
|
|
|
|
|
t.Fatalf("event[0] type mismatch: %s", events[0].Type)
|
|
|
|
|
}
|
|
|
|
|
if events[1].Type != StreamEventTypeToolCall || events[1].ToolName != "shell" {
|
|
|
|
|
t.Fatalf("event[1] mismatch: %+v", events[1])
|
|
|
|
|
}
|
|
|
|
|
if events[2].Type != StreamEventTypeToolResult || events[2].ToolName != "shell" {
|
|
|
|
|
t.Fatalf("event[2] mismatch: %+v", events[2])
|
|
|
|
|
}
|
|
|
|
|
if events[3].Type != StreamEventTypeFinal || events[3].Content != "done" {
|
|
|
|
|
t.Fatalf("event[3] mismatch: %+v", events[3])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChatStreamHandlerError(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
|
|
|
|
return "", errors.New("boom")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body := strings.NewReader(`{"text":"hello"}`)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", body)
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
respBody := w.Body.String()
|
|
|
|
|
if !strings.Contains(respBody, `"type":"error"`) {
|
|
|
|
|
t.Fatalf("expected error event in stream, body=%q", respBody)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(respBody, "stream error: boom") {
|
|
|
|
|
t.Fatalf("expected error detail in stream, body=%q", respBody)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChatStreamValidation(t *testing.T) {
|
|
|
|
|
t.Run("method not allowed", func(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/chat/stream", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
|
|
|
t.Fatalf("expected 405, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("content type must be json", func(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("handler not ready", func(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":"hello"}`))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
|
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("text required", func(t *testing.T) {
|
|
|
|
|
b := newTestBot(t, 1024*1024)
|
|
|
|
|
b.streamChatHandler = func(_ context.Context, _ IncomingMessage, _ StreamEventCallback) (string, error) {
|
|
|
|
|
return "", nil
|
|
|
|
|
}
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/chat/stream", strings.NewReader(`{"text":" "}`))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
|
|
|
|
|
b.handleChatStream(w, req)
|
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|