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:
parent
04d047fd37
commit
394c5494c9
2 changed files with 111 additions and 4 deletions
|
|
@ -294,8 +294,8 @@ func checkSessions(opts Options) Result {
|
||||||
for _, p := range problems {
|
for _, p := range problems {
|
||||||
r.AddFixable(check, SeverityError,
|
r.AddFixable(check, SeverityError,
|
||||||
fmt.Sprintf("%s: %s", entry.Name(), p),
|
fmt.Sprintf("%s: %s", entry.Name(), p),
|
||||||
"remove corrupt session file",
|
"repair corrupt session (inject synthetic tool results, drop orphans)",
|
||||||
makeSessionDeleteFunc(filePath),
|
makeSessionRepairFunc(filePath, sess),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -380,9 +380,69 @@ func checkSessionMessages(msgs []providers.Message) []string {
|
||||||
return problems
|
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 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestSeverityString(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
sev Severity
|
sev Severity
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue