Files
backend/internal/server/chat_test.go

308 lines
9.1 KiB
Go
Raw Normal View History

package server
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"code.littlelan.cn/CarrotAssistant/backend/internal/config"
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
)
// newTestDB opens an in-memory-ish SQLite (temp file) and returns the gorm DB
// plus a cleanup function.
func newTestDB(t *testing.T) (*gorm.DB, func()) {
t.Helper()
dir := t.TempDir()
db, err := store.Open(dir + "/test.db")
if err != nil {
t.Fatalf("open db: %v", err)
}
return db, func() {}
}
// newTestDeps builds a Deps wired to a mock LLM server, with crypto + skills
// dir under the test temp directory.
func newTestDeps(t *testing.T, mockLLMURL string) (Deps, *store.App, *skill.Skill, string, func()) {
t.Helper()
db, dbCleanup := newTestDB(t)
dir := t.TempDir()
crypto, err := security.NewCrypto("0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("crypto: %v", err)
}
// A model config pointing at the mock server.
mc := store.ModelConfig{
Name: "mock", BaseURL: mockLLMURL, APIKeyEncrypted: mustEnc(crypto, "sk-mock"),
APIKeyPreview: "mock", DefaultModel: "mock-model", SupportsTools: true,
}
if err := db.Create(&mc).Error; err != nil {
t.Fatalf("create mc: %v", err)
}
// An app that uses it, with a known token so the chat client can auth.
const appToken = "ca_live_testtoken"
app := &store.App{
Slug: "demo", Name: "Demo", TokenHash: security.HashToken(appToken),
ModelConfigID: &mc.ID, Status: "active",
}
if err := db.Create(app).Error; err != nil {
t.Fatalf("create app: %v", err)
}
// A published skill with one read tool and one mutating tool that
// requires confirmation.
sk := &skill.Skill{
Name: "demo-assistant",
Description: "demo",
AppSlug: "demo",
Permissions: skill.Permissions{
DefaultMode: "readonly",
RequireConfirmation: []string{"delete_post"},
},
Tools: []skill.Tool{
{
Name: "search", Description: "search",
Parameters: skill.MakeParameters([]byte(`{"type":"object","properties":{"q":{"type":"string"}}}`)),
Endpoint: skill.Endpoint{Method: "GET", URL: "https://example.invalid/search", Query: map[string]string{"q": "{{q}}"}},
},
{
Name: "delete_post", Description: "delete",
Parameters: skill.MakeParameters([]byte(`{"type":"object","properties":{"id":{"type":"integer"}}}`)),
Endpoint: skill.Endpoint{Method: "DELETE", URL: "https://example.invalid/posts/{{id}}"},
},
},
SystemPrompt: "you are a demo assistant",
}
mgr := skill.NewManager(dir)
if _, err := mgr.Write("demo", "demo-assistant", sk); err != nil {
t.Fatalf("write skill: %v", err)
}
row := store.Skill{
AppID: app.ID, Slug: "demo-assistant", Name: sk.Name, FilePath: mgr.RelPath("demo", "demo-assistant"),
Status: "published", Source: "manual",
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create skill: %v", err)
}
cfg := &config.Config{JWTSecret: "test-jwt"}
deps := Deps{DB: db, Config: cfg, Crypto: crypto, SkillsDir: dir}
return deps, app, sk, appToken, dbCleanup
}
func mustEnc(c *security.Crypto, s string) string {
out, err := c.Encrypt(s)
if err != nil {
panic(err)
}
return out
}
// mockLLM is a tiny SSE chat-completion server whose response sequence is
// driven by a script: a list of chunk-producers invoked in order, one per
// LLM call. This lets a test script a tool-call turn followed by a final
// answer turn.
type mockLLM struct {
turns [][]string // each turn is a list of SSE chunks (raw JSON delta lines)
idx int
}
func (m *mockLLM) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Read the request body to find how many turns we've served (we just
// advance the script on each call).
body, _ := io.ReadAll(r.Body)
_ = body
if m.idx >= len(m.turns) {
// No more scripted turns: emit a bare stop so the loop ends cleanly.
m.idx++
writeSSEChunk(w, map[string]any{"choices": []map[string]any{{"finish_reason": "stop", "delta": map[string]any{}}}})
fmt.Fprint(w, "data: [DONE]\n\n")
return
}
chunks := m.turns[m.idx]
m.idx++
w.Header().Set("Content-Type", "text/event-stream")
for _, c := range chunks {
fmt.Fprintf(w, "data: %s\n\n", c)
}
fmt.Fprint(w, "data: [DONE]\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// writeSSEChunk marshals one chunk object and writes it as a data line.
func writeSSEChunk(w http.ResponseWriter, chunk map[string]any) {
b, _ := json.Marshal(chunk)
fmt.Fprintf(w, "data: %s\n\n", b)
}
// TestChatToolCallFlow drives a chat request against a mock LLM that first
// requests the search tool (a read), then produces a final answer. Asserts
// the SSE stream emits the expected event sequence.
func TestChatToolCallFlow(t *testing.T) {
mock := &mockLLM{
turns: [][]string{
// Turn 1: model asks to call search(q="hi").
{
chunkJSON("search", "call_1", "", `{"q":"hi"}`, ""),
`{"choices":[{"finish_reason":"tool_calls","delta":{}}]}`,
},
// Turn 2: model produces the final text answer.
{
`{"choices":[{"finish_reason":"","delta":{"content":"found "}}]}`,
`{"choices":[{"finish_reason":"","delta":{"content":"2 posts"}}]}`,
`{"choices":[{"finish_reason":"stop","delta":{}}]}`,
},
},
}
mockSrv := httptest.NewServer(mock)
defer mockSrv.Close()
deps, _, _, appToken, cleanup := newTestDeps(t, mockSrv.URL)
defer cleanup()
// Also stand up a fake "target site" so the tool call resolves.
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`["post1","post2"]`))
}))
defer target.Close()
// Patch the search tool URL to point at the fake target. (We do this by
// editing the published skill file in place.)
patchSkillURL(t, deps, "demo", "demo-assistant", "search", target.URL+"/search")
gin.SetMode(gin.TestMode)
r := gin.New()
deps.mountChatRoutes(r)
// POST /api/v1/chat and collect the SSE stream.
body := bytes.NewBufferString(`{"message":"hi"}`)
req := httptest.NewRequest("POST", "/api/v1/chat", body)
req.Header.Set("X-App-Token", appToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
events := parseSSE(rec.Body.String())
kinds := eventKinds(events)
wantSeq := []string{"session", "tool_call", "tool_result", "token", "token", "done"}
if !containsSeq(kinds, wantSeq) {
t.Fatalf("event kinds = %v, want sequence containing %v", kinds, wantSeq)
}
}
// patchSkillURL rewrites a tool's endpoint URL in the published skill file
// for the test, so tool calls hit the in-process fake target.
func patchSkillURL(t *testing.T, deps Deps, appSlug, skillSlug, toolName, newURL string) {
t.Helper()
mgr := skill.NewManager(deps.SkillsDir)
var row store.Skill
deps.DB.Where("slug = ? AND app_id IN (SELECT id FROM apps WHERE slug = ?)", skillSlug, appSlug).First(&row)
sk, err := mgr.Load(&row)
if err != nil {
t.Fatalf("load skill: %v", err)
}
for i := range sk.Tools {
if sk.Tools[i].Name == toolName {
sk.Tools[i].Endpoint.URL = newURL
}
}
if _, err := mgr.Write(appSlug, skillSlug, sk); err != nil {
t.Fatalf("rewrite skill: %v", err)
}
}
// chunkJSON builds a delta chunk for a tool call (function arguments arrive
// as a JSON string). When content is non-empty it's a content delta instead.
func chunkJSON(name, id, content, argsJSON, _ string) string {
if content != "" {
b, _ := json.Marshal(map[string]any{"choices": []map[string]any{{
"delta": map[string]any{"content": content},
}}})
return string(b)
}
// tool-call delta
b, _ := json.Marshal(map[string]any{"choices": []map[string]any{{
"delta": map[string]any{
"tool_calls": []map[string]any{{
"index": 0, "id": id,
"function": map[string]any{"name": name, "arguments": argsJSON},
}},
},
}}})
return string(b)
}
// parseSSE turns a raw SSE body into a list of (event, data) pairs.
func parseSSE(raw string) []sseEvent {
var out []sseEvent
var cur sseEvent
sc := bufio.NewScanner(strings.NewReader(raw))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
ln := sc.Text()
if ln == "" {
if cur.event != "" || cur.data != "" {
out = append(out, cur)
}
cur = sseEvent{}
continue
}
if strings.HasPrefix(ln, "event: ") {
cur.event = strings.TrimPrefix(ln, "event: ")
} else if strings.HasPrefix(ln, "data: ") {
cur.data += strings.TrimPrefix(ln, "data: ")
}
}
if cur.event != "" || cur.data != "" {
out = append(out, cur)
}
return out
}
type sseEvent struct {
event string
data string
}
func eventKinds(es []sseEvent) []string {
out := make([]string, 0, len(es))
for _, e := range es {
out = append(out, e.event)
}
return out
}
// containsSeq reports whether want appears as a contiguous subsequence of got.
func containsSeq(got, want []string) bool {
if len(want) > len(got) {
return false
}
for i := 0; i+len(want) <= len(got); i++ {
match := true
for j := range want {
if got[i+j] != want[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}