diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go index 5a2297e30..9357d355e 100644 --- a/pkg/session/jsonl_backend.go +++ b/pkg/session/jsonl_backend.go @@ -38,7 +38,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message { log.Printf("session: get history: %v", err) return []providers.Message{} } - return msgs + return sanitizeRecoveredHistory(msgs) } func (b *JSONLBackend) GetSummary(key string) string { diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 7f87d460a..312f540cf 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -97,7 +97,7 @@ func (sm *SessionManager) GetHistory(key string) []providers.Message { history := make([]providers.Message, len(session.Messages)) copy(history, session.Messages) - return history + return sanitizeRecoveredHistory(history) } func (sm *SessionManager) GetSummary(key string) string { diff --git a/pkg/session/recovery.go b/pkg/session/recovery.go new file mode 100644 index 000000000..d245d19d7 --- /dev/null +++ b/pkg/session/recovery.go @@ -0,0 +1,52 @@ +package session + +import "github.com/sipeed/picoclaw/pkg/providers" + +// sanitizeRecoveredHistory drops any dangling tail that starts with an +// assistant tool-call message whose tool results were never fully persisted. +// +// This prevents a restarted agent from restoring an unfinished runtime state +// (assistant tool calls plus later steering/user messages) as if it were valid +// history. We keep completed tool-call sequences intact and only trim the +// incomplete suffix. +func sanitizeRecoveredHistory(history []providers.Message) []providers.Message { + for i := 0; i < len(history); i++ { + msg := history[i] + if msg.Role != "assistant" || len(msg.ToolCalls) == 0 { + continue + } + + expected := make(map[string]bool, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + expected[tc.ID] = false + } + + j := i + 1 + for ; j < len(history); j++ { + next := history[j] + if next.ToolCallID == "" { + break + } + if _, ok := expected[next.ToolCallID]; ok { + expected[next.ToolCallID] = true + } + } + + complete := true + for _, found := range expected { + if !found { + complete = false + break + } + } + if !complete { + return append([]providers.Message(nil), history[:i]...) + } + + if j > i+1 { + i = j - 1 + } + } + + return append([]providers.Message(nil), history...) +} diff --git a/pkg/session/recovery_test.go b/pkg/session/recovery_test.go new file mode 100644 index 000000000..82056275e --- /dev/null +++ b/pkg/session/recovery_test.go @@ -0,0 +1,58 @@ +package session + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSanitizeRecoveredHistory_DropsDanglingToolCallTail(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "working", ToolCalls: []providers.ToolCall{{ID: "call_1"}}}, + {Role: "user", Content: "?"}, + } + + got := sanitizeRecoveredHistory(history) + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + if got[0].Content != "hello" { + t.Fatalf("got[0].Content = %q, want hello", got[0].Content) + } +} + +func TestSanitizeRecoveredHistory_KeepsCompletedToolCallSequence(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "working", ToolCalls: []providers.ToolCall{{ID: "call_1"}, {ID: "call_2"}}}, + {Role: "tool", ToolCallID: "call_1", Content: "done 1"}, + {Role: "tool", ToolCallID: "call_2", Content: "done 2"}, + {Role: "assistant", Content: "all set"}, + } + + got := sanitizeRecoveredHistory(history) + if len(got) != len(history) { + t.Fatalf("len(got) = %d, want %d", len(got), len(history)) + } + if got[len(got)-1].Content != "all set" { + t.Fatalf("last content = %q, want all set", got[len(got)-1].Content) + } +} + +func TestSanitizeRecoveredHistory_DropsPartialToolResultsAndFollowingMessages(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "working", ToolCalls: []providers.ToolCall{{ID: "call_1"}, {ID: "call_2"}}}, + {Role: "tool", ToolCallID: "call_1", Content: "done 1"}, + {Role: "user", Content: "still there?"}, + } + + got := sanitizeRecoveredHistory(history) + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + if got[0].Content != "hello" { + t.Fatalf("got[0].Content = %q, want hello", got[0].Content) + } +}