mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 12:10:53 +00:00
cd2e407b29
sanitizeHistory now returns dropped count so callers know when orphaned tool_use/tool_result messages were removed. When orphans are found in buildMessages, the full session history is sanitized and persisted, preventing repeated warnings on every request. - Add SetHistory() to SessionStore interface and both implementations - Adapt memoryflush caller to new two-return signature - Change sanitize log level from Warn to Debug
246 lines
6.5 KiB
Go
246 lines
6.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
func TestLimitHistoryTurns_NoLimit(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "m1"},
|
|
{Role: "assistant", Content: "r1"},
|
|
{Role: "user", Content: "m2"},
|
|
{Role: "assistant", Content: "r2"},
|
|
}
|
|
got := limitHistoryTurns(msgs, 0)
|
|
if len(got) != 4 {
|
|
t.Errorf("expected 4 messages, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestLimitHistoryTurns_KeepLast2(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "m1"},
|
|
{Role: "assistant", Content: "r1"},
|
|
{Role: "user", Content: "m2"},
|
|
{Role: "assistant", Content: "r2"},
|
|
{Role: "user", Content: "m3"},
|
|
{Role: "assistant", Content: "r3"},
|
|
}
|
|
got := limitHistoryTurns(msgs, 2)
|
|
|
|
if len(got) != 4 {
|
|
t.Fatalf("expected 4 messages, got %d", len(got))
|
|
}
|
|
if got[0].Content != "m2" {
|
|
t.Errorf("expected m2, got %s", got[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestLimitHistoryTurns_KeepLast1(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "m1"},
|
|
{Role: "assistant", Content: "r1"},
|
|
{Role: "user", Content: "m2"},
|
|
{Role: "assistant", Content: "r2"},
|
|
}
|
|
got := limitHistoryTurns(msgs, 1)
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(got))
|
|
}
|
|
if got[0].Content != "m2" {
|
|
t.Errorf("expected m2, got %s", got[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestLimitHistoryTurns_WithToolMessages(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "m1"},
|
|
{Role: "assistant", Content: "r1", ToolCalls: []providers.ToolCall{{ID: "tc1", Name: "read_file"}}},
|
|
{Role: "tool", Content: "result1", ToolCallID: "tc1"},
|
|
{Role: "assistant", Content: "final1"},
|
|
{Role: "user", Content: "m2"},
|
|
{Role: "assistant", Content: "r2"},
|
|
}
|
|
got := limitHistoryTurns(msgs, 1)
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 messages (last turn), got %d", len(got))
|
|
}
|
|
if got[0].Content != "m2" {
|
|
t.Errorf("expected m2, got %s", got[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestLimitHistoryTurns_Empty(t *testing.T) {
|
|
got := limitHistoryTurns(nil, 5)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestLimitHistoryTurns_LimitExceedsTotal(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "m1"},
|
|
{Role: "assistant", Content: "r1"},
|
|
}
|
|
got := limitHistoryTurns(msgs, 100)
|
|
if len(got) != 2 {
|
|
t.Errorf("expected 2, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_Empty(t *testing.T) {
|
|
got, _ := sanitizeHistory(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected empty, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_DropsLeadingOrphanedTools(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "tool", Content: "orphan1", ToolCallID: "tc1"},
|
|
{Role: "tool", Content: "orphan2", ToolCallID: "tc2"},
|
|
{Role: "user", Content: "hello"},
|
|
{Role: "assistant", Content: "hi"},
|
|
}
|
|
got, _ := sanitizeHistory(msgs)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(got))
|
|
}
|
|
if got[0].Role != "user" {
|
|
t.Errorf("expected user, got %s", got[0].Role)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_MatchesToolResults(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "do something"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file"},
|
|
{ID: "tc2", Name: "write_file"},
|
|
}},
|
|
{Role: "tool", Content: "file data", ToolCallID: "tc1"},
|
|
{Role: "tool", Content: "written", ToolCallID: "tc2"},
|
|
{Role: "assistant", Content: "done"},
|
|
}
|
|
got, _ := sanitizeHistory(msgs)
|
|
if len(got) != 5 {
|
|
t.Fatalf("expected 5, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_SynthesizesMissingToolResult(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "do something"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file"},
|
|
{ID: "tc2", Name: "write_file"},
|
|
}},
|
|
{Role: "tool", Content: "file data", ToolCallID: "tc1"},
|
|
// tc2 is missing
|
|
{Role: "user", Content: "next"},
|
|
}
|
|
got, _ := sanitizeHistory(msgs)
|
|
|
|
// user + assistant + tc1 result + synthesized tc2 result + user
|
|
if len(got) != 5 {
|
|
t.Fatalf("expected 5, got %d", len(got))
|
|
}
|
|
|
|
// The synthesized message should be for tc2
|
|
foundSynthesized := false
|
|
for _, m := range got {
|
|
if m.ToolCallID == "tc2" && m.Role == "tool" {
|
|
foundSynthesized = true
|
|
if m.Content != "[Tool result missing — session was compacted]" {
|
|
t.Errorf("unexpected synthesized content: %s", m.Content)
|
|
}
|
|
}
|
|
}
|
|
if !foundSynthesized {
|
|
t.Error("missing synthesized tool result for tc2")
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_DropsMismatchedToolResult(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "hello"},
|
|
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
|
|
{ID: "tc1", Name: "read_file"},
|
|
}},
|
|
{Role: "tool", Content: "ok", ToolCallID: "tc1"},
|
|
{Role: "tool", Content: "stray", ToolCallID: "unknown_id"},
|
|
{Role: "user", Content: "next"},
|
|
}
|
|
got, _ := sanitizeHistory(msgs)
|
|
|
|
// The stray tool message should be dropped, tc1 result kept
|
|
for _, m := range got {
|
|
if m.ToolCallID == "unknown_id" {
|
|
t.Error("mismatched tool result should be dropped")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeHistory_DropsOrphanedToolMidHistory(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "hello"},
|
|
{Role: "assistant", Content: "hi"},
|
|
{Role: "tool", Content: "orphan mid", ToolCallID: "tc_orphan"},
|
|
{Role: "user", Content: "bye"},
|
|
}
|
|
got, _ := sanitizeHistory(msgs)
|
|
|
|
for _, m := range got {
|
|
if m.ToolCallID == "tc_orphan" {
|
|
t.Error("orphaned mid-history tool should be dropped")
|
|
}
|
|
}
|
|
if len(got) != 3 {
|
|
t.Errorf("expected 3, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestEstimateTokens(t *testing.T) {
|
|
msgs := []providers.Message{
|
|
{Role: "user", Content: "Hello world!"}, // 12 chars → ~4 tokens
|
|
{Role: "assistant", Content: "Hi there, how are you?"}, // 22 chars → ~7 tokens
|
|
}
|
|
got := EstimateTokens(msgs)
|
|
if got <= 0 {
|
|
t.Errorf("expected positive token estimate, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestTruncateStr(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
maxLen int
|
|
want string
|
|
}{
|
|
{"short", "hello", 10, "hello"},
|
|
{"exact", "hello", 5, "hello"},
|
|
{"truncate", "hello world", 5, "hello..."},
|
|
{"empty", "", 5, ""},
|
|
{"unicode", "héllo wörld", 7, "héllo ..."},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := truncateStr(tt.input, tt.maxLen)
|
|
if tt.maxLen >= len(tt.input) {
|
|
if got != tt.input {
|
|
t.Errorf("got %q, want %q", got, tt.input)
|
|
}
|
|
} else {
|
|
if len(got) == 0 {
|
|
t.Error("truncation returned empty")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|