fix session recovery for stale tool-call tails

This commit is contained in:
Liu Yuan 2026-04-05 23:09:55 +08:00
parent 15a70ac45c
commit 2455cf3c0f
4 changed files with 112 additions and 2 deletions

View file

@ -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 {

View file

@ -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 {

52
pkg/session/recovery.go Normal file
View file

@ -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...)
}

View file

@ -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)
}
}