fix: doctor --fix now repairs corrupt sessions instead of deleting them

Adds repairSessionMessages that injects synthetic tool_result messages
for orphaned tool_use blocks and drops orphaned tool_result messages.
Sessions are written back to disk with the repairs applied, preserving
conversation history.
This commit is contained in:
Rahul Bansal 2026-02-21 11:36:52 +05:30
parent 04d047fd37
commit 394c5494c9
2 changed files with 111 additions and 4 deletions

View file

@ -294,8 +294,8 @@ func checkSessions(opts Options) Result {
for _, p := range problems {
r.AddFixable(check, SeverityError,
fmt.Sprintf("%s: %s", entry.Name(), p),
"remove corrupt session file",
makeSessionDeleteFunc(filePath),
"repair corrupt session (inject synthetic tool results, drop orphans)",
makeSessionRepairFunc(filePath, sess),
)
}
}
@ -380,9 +380,69 @@ func checkSessionMessages(msgs []providers.Message) []string {
return problems
}
func makeSessionDeleteFunc(path string) func() error {
// repairSessionMessages fixes orphaned tool_use/tool_result pairs in a message slice.
func repairSessionMessages(msgs []providers.Message) []providers.Message {
if len(msgs) == 0 {
return msgs
}
// Collect all tool_call IDs
toolCallIDs := map[string]bool{}
for _, m := range msgs {
if m.Role == "assistant" {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
toolCallIDs[tc.ID] = true
}
}
}
}
// Collect all tool_result IDs
toolResultIDs := map[string]bool{}
for _, m := range msgs {
if m.Role == "tool" && m.ToolCallID != "" {
toolResultIDs[m.ToolCallID] = true
}
}
// Build repaired slice: drop orphan results, inject missing results
repaired := make([]providers.Message, 0, len(msgs))
for _, m := range msgs {
// Drop orphaned tool_result
if m.Role == "tool" && m.ToolCallID != "" && !toolCallIDs[m.ToolCallID] {
continue
}
repaired = append(repaired, m)
// Inject missing tool_results after assistant+tool_calls
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
if tc.ID == "" {
continue
}
if !toolResultIDs[tc.ID] {
repaired = append(repaired, providers.Message{
Role: "tool",
ToolCallID: tc.ID,
Content: "[tool result unavailable - session was repaired by picoclaw doctor]",
})
toolResultIDs[tc.ID] = true
}
}
}
}
return repaired
}
func makeSessionRepairFunc(path string, sess sessionFile) func() error {
return func() error {
return os.Remove(path)
sess.Messages = repairSessionMessages(sess.Messages)
data, err := json.MarshalIndent(sess, "", " ")
if err != nil {
return fmt.Errorf("marshaling repaired session: %w", err)
}
return os.WriteFile(path, data, 0o644)
}
}

View file

@ -88,6 +88,53 @@ func TestCheckSessionMessages_ConsecutiveUserMessages(t *testing.T) {
}
}
func TestRepairSessionMessages_InjectsResult(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{
Role: "assistant", Content: "checking",
ToolCalls: []providers.ToolCall{{ID: "call_1", Name: "exec"}},
},
{Role: "assistant", Content: "done"},
}
repaired := repairSessionMessages(msgs)
if len(repaired) != 4 {
t.Fatalf("expected 4 messages, got %d", len(repaired))
}
if repaired[2].Role != "tool" || repaired[2].ToolCallID != "call_1" {
t.Errorf("expected injected tool_result at index 2, got role=%q id=%q",
repaired[2].Role, repaired[2].ToolCallID)
}
}
func TestRepairSessionMessages_DropsOrphanResult(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{Role: "tool", Content: "orphaned", ToolCallID: "call_x"},
{Role: "assistant", Content: "hi"},
}
repaired := repairSessionMessages(msgs)
if len(repaired) != 2 {
t.Fatalf("expected 2 messages, got %d", len(repaired))
}
}
func TestRepairSessionMessages_AlreadyClean(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "hello"},
{
Role: "assistant", Content: "checking",
ToolCalls: []providers.ToolCall{{ID: "call_1", Name: "exec"}},
},
{Role: "tool", Content: "output", ToolCallID: "call_1"},
{Role: "assistant", Content: "done"},
}
repaired := repairSessionMessages(msgs)
if len(repaired) != 4 {
t.Errorf("clean messages should be unchanged, got %d", len(repaired))
}
}
func TestSeverityString(t *testing.T) {
tests := []struct {
sev Severity