248 lines
9.6 KiB
Go
248 lines
9.6 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
func memoryConversation(t *testing.T, s *Service, n int) Conversation {
|
||
t.Helper()
|
||
c, err := s.CreateConversation()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for i := 1; i <= n; i++ {
|
||
c.Messages = append(c.Messages, ConversationMessage{ID: fmt.Sprintf("u%d", i), Role: "user", Content: fmt.Sprintf("原始需求-%d", i)}, ConversationMessage{ID: fmt.Sprintf("a%d", i), Role: "assistant", Content: fmt.Sprintf("确认结果-%d", i), Status: "complete"})
|
||
}
|
||
if _, err := s.SaveConversation(c); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func memoryReply(w http.ResponseWriter, text string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": text}, "finish_reason": "stop"}}})
|
||
}
|
||
|
||
func TestAutomaticMemoryPersistsIncrementallyAndSurvivesStaleSave(t *testing.T) {
|
||
var summaryInputs []string
|
||
var answerInputs [][]modelMessage
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Messages []modelMessage `json:"messages"`
|
||
Tools json.RawMessage `json:"tools"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
}
|
||
json.NewDecoder(r.Body).Decode(&body)
|
||
if body.Messages[0].Content == memoryInstruction {
|
||
if len(body.Tools) > 0 {
|
||
t.Error("summary requested tools")
|
||
}
|
||
summaryInputs = append(summaryInputs, fmt.Sprint(body.Messages[1].Content))
|
||
memoryReply(w, "关键记忆:预算3500元,数量500,只起草不发送。")
|
||
} else {
|
||
answerInputs = append(answerInputs, body.Messages)
|
||
memoryReply(w, "继续执行需求分析。")
|
||
}
|
||
}))
|
||
defer server.Close()
|
||
s, secrets := testService(t)
|
||
cfg := testConfig(server.URL)
|
||
cfg.ContextTurns = 1
|
||
if _, err := s.SaveModelConfig(ModelConfigInput{Config: cfg, APIKey: "key"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
c := memoryConversation(t, s, 3)
|
||
c.Messages[0].Content = "预算3500元,数量500,只起草不发送。原始需求-1"
|
||
s.SaveConversation(c)
|
||
if _, err := s.Chat(c.ID, "继续"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, err := s.GetConversation(c.ID)
|
||
if err != nil || stored.Memory == nil || stored.Memory.MessageCount != 4 || len(stored.Messages) != 6 {
|
||
t.Fatalf("checkpoint: %+v %v", stored.Memory, err)
|
||
}
|
||
if len(summaryInputs) != 1 || !strings.Contains(summaryInputs[0], "原始需求-2") || strings.Contains(summaryInputs[0], "原始需求-3") {
|
||
t.Fatalf("wrong summary source: %v", summaryInputs)
|
||
}
|
||
serialized, _ := json.Marshal(answerInputs[0])
|
||
if !strings.Contains(string(serialized), "关键记忆") || !strings.Contains(string(serialized), "原始需求-3") || strings.Contains(string(serialized), "原始需求-2") {
|
||
t.Fatalf("context not compacted: %s", serialized)
|
||
}
|
||
// The frontend loaded c before the summary, then saves messages and a draft.
|
||
c.Draft = "新草稿"
|
||
s.SaveConversation(c)
|
||
stored, _ = s.GetConversation(c.ID)
|
||
if stored.Memory == nil || stored.Draft != "新草稿" {
|
||
t.Fatal("stale save erased server-owned memory")
|
||
}
|
||
restarted := newService(s.configPath, secrets)
|
||
if _, err := restarted.Chat(c.ID, "重启后继续"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(summaryInputs) != 1 {
|
||
t.Fatal("unchanged history was summarized again")
|
||
}
|
||
c.Messages = append(c.Messages, ConversationMessage{ID: "u4", Role: "user", Content: "第四轮"}, ConversationMessage{ID: "a4", Role: "assistant", Content: "第四轮确认", Status: "complete"})
|
||
s.SaveConversation(c)
|
||
if _, err := restarted.Chat(c.ID, "继续增量"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(summaryInputs) != 2 || !strings.Contains(summaryInputs[1], "关键记忆") || !strings.Contains(summaryInputs[1], "原始需求-3") || strings.Contains(summaryInputs[1], "原始需求-2") {
|
||
t.Fatalf("not incremental: %v", summaryInputs)
|
||
}
|
||
other := memoryConversation(t, s, 0)
|
||
if _, err := restarted.Chat(other.ID, "另一会话"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
serialized, _ = json.Marshal(answerInputs[len(answerInputs)-1])
|
||
if strings.Contains(string(serialized), "关键记忆") {
|
||
t.Fatal("memory leaked across conversations")
|
||
}
|
||
c.Messages[0].Content = "用户修改了早期需求"
|
||
s.SaveConversation(c)
|
||
stored, _ = s.GetConversation(c.ID)
|
||
if stored.Memory != nil {
|
||
t.Fatal("edited prefix kept stale memory")
|
||
}
|
||
}
|
||
|
||
func TestLongHistoryChunksWithoutSplittingUnicode(t *testing.T) {
|
||
s, _ := testService(t)
|
||
c := memoryConversation(t, s, 2)
|
||
c.Messages[1].Content = strings.Repeat("重要信息🙂", 10000)
|
||
s.SaveConversation(c)
|
||
chunks := 0
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Messages []modelMessage `json:"messages"`
|
||
}
|
||
json.NewDecoder(r.Body).Decode(&body)
|
||
if body.Messages[0].Content != memoryInstruction {
|
||
t.Error("unexpected answer request")
|
||
}
|
||
text := fmt.Sprint(body.Messages[1].Content)
|
||
if !utf8.ValidString(text) || strings.ContainsRune(text, '\ufffd') {
|
||
t.Error("Unicode split")
|
||
}
|
||
chunks++
|
||
memoryReply(w, "记住重要信息,继续保留关键要求。")
|
||
}))
|
||
defer server.Close()
|
||
cfg := testConfig(server.URL)
|
||
cfg.ContextTurns = 10
|
||
turns, memory, found, err := s.prepareMemory(context.Background(), c.ID, cfg, "", nil)
|
||
if err != nil || !found || memory == nil || memory.MessageCount != 2 || len(turns) != 2 || chunks < 2 {
|
||
t.Fatalf("long history: %d %d %+v %v", chunks, len(turns), memory, err)
|
||
}
|
||
}
|
||
|
||
func TestSummaryFailureAndCancellationPreserveOriginal(t *testing.T) {
|
||
for _, mode := range []string{"provider-error", "truncated", "cancel", "changed", "deleted"} {
|
||
t.Run(mode, func(t *testing.T) {
|
||
s, _ := testService(t)
|
||
c := memoryConversation(t, s, 2)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch mode {
|
||
case "provider-error":
|
||
http.Error(w, "failed", 500)
|
||
return
|
||
case "truncated":
|
||
fmt.Fprint(w, `{"choices":[{"message":{"content":"半截摘要"},"finish_reason":"length"}]}`)
|
||
return
|
||
case "cancel":
|
||
cancel()
|
||
return
|
||
case "changed":
|
||
c.Messages[0].Content = "已修改"
|
||
s.SaveConversation(c)
|
||
case "deleted":
|
||
s.DeleteConversation(c.ID)
|
||
}
|
||
memoryReply(w, "摘要")
|
||
}))
|
||
defer server.Close()
|
||
cfg := testConfig(server.URL)
|
||
cfg.ContextTurns = 1
|
||
_, _, _, err := s.prepareMemory(ctx, c.ID, cfg, "", nil)
|
||
if err == nil {
|
||
t.Fatal("summary failure was hidden")
|
||
}
|
||
if mode == "cancel" && !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("cancellation lost: %v", err)
|
||
}
|
||
path, _ := s.conversationPath(c.ID)
|
||
if mode == "deleted" {
|
||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||
t.Fatal("summary recreated deleted conversation")
|
||
}
|
||
return
|
||
}
|
||
stored, err := s.GetConversation(c.ID)
|
||
if err != nil || stored.Memory != nil || len(stored.Messages) != 4 {
|
||
t.Fatalf("failed summary changed record: %+v %v", stored, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestZeroContextDoesNotReadOrGenerateMemory(t *testing.T) {
|
||
s, _ := testService(t)
|
||
c := memoryConversation(t, s, 3)
|
||
cfg := testConfig("http://127.0.0.1:1")
|
||
cfg.ContextTurns = 0
|
||
turns, memory, found, err := s.prepareMemory(context.Background(), c.ID, cfg, "", nil)
|
||
if err != nil || !found || len(turns) != 0 || memory != nil {
|
||
t.Fatalf("zero context: %v %+v %v", turns, memory, err)
|
||
}
|
||
}
|
||
|
||
func TestSummaryPreservesExistingCheckpointOnFailureAndIncludesToolStates(t *testing.T) {
|
||
s, _ := testService(t)
|
||
c := memoryConversation(t, s, 3)
|
||
c.Messages[1].Tools = []ToolExecution{{ID: "tool", Server: "Phone", Tool: "launch", Status: "complete", Result: "settings opened"}}
|
||
c.Messages = append(c.Messages[:2], append([]ConversationMessage{{ID: "failed-user", Role: "user", Content: "继续操作"}, {ID: "failed-answer", Role: "assistant", Content: "已中断", Status: "stopped", Tools: []ToolExecution{{ID: "tool-failed", Server: "Phone", Tool: "tap", Status: "error", Result: "device offline"}}}}, c.Messages[2:]...)...)
|
||
if _, err := s.SaveConversation(c); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var source string
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Messages []modelMessage `json:"messages"`
|
||
}
|
||
json.NewDecoder(r.Body).Decode(&body)
|
||
source = fmt.Sprint(body.Messages[1].Content)
|
||
memoryReply(w, "已打开设置;后续操作中断,设备离线,不代表后续动作完成。")
|
||
}))
|
||
defer server.Close()
|
||
cfg := testConfig(server.URL)
|
||
cfg.ContextTurns = 1
|
||
_, memory, _, err := s.prepareMemory(context.Background(), c.ID, cfg, "", nil)
|
||
if err != nil || memory == nil || !strings.Contains(source, "状态=stopped") || !strings.Contains(source, "状态=error") || !strings.Contains(source, "settings opened") || !strings.Contains(source, "device offline") {
|
||
t.Fatalf("tool states lost: %s %v", source, err)
|
||
}
|
||
c.Messages = append(c.Messages, ConversationMessage{ID: "new-u", Role: "user", Content: "新的完成回合"}, ConversationMessage{ID: "new-a", Role: "assistant", Content: "新的结果", Status: "complete"})
|
||
s.SaveConversation(c)
|
||
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
|
||
defer failing.Close()
|
||
cfg.BaseURL = failing.URL
|
||
if _, _, _, err = s.prepareMemory(context.Background(), c.ID, cfg, "", nil); err == nil {
|
||
t.Fatal("failure hidden")
|
||
}
|
||
stored, _ := s.GetConversation(c.ID)
|
||
if stored.Memory == nil || stored.Memory.Text != memory.Text || stored.Memory.ThroughID != memory.ThroughID {
|
||
t.Fatal("failed update erased previous checkpoint")
|
||
}
|
||
}
|