| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | ) |
| 9 | |
| 10 | func TestRestoreCheckpointRepairsLegacyMissingRecordIdentities(t *testing.T) { |
| 11 | state := Checkpoint{ |
| 12 | Version: ProtocolVersion, Identity: testIdentity, CoveredThroughSeq: 7, |
| 13 | Records: []Message{ |
| 14 | {Role: "notice", Content: "first"}, |
| 15 | {Role: "notice", Content: "second"}, |
| 16 | {Role: "assistant", MessageID: "answer", Content: "answer"}, |
| 17 | {Role: "tool", ToolCallID: "call", Content: "result"}, |
| 18 | }, |
| 19 | } |
| 20 | want := []string{"view:checkpoint:7:0", "view:checkpoint:7:1", "m:answer", "tool:call"} |
| 21 | for range 2 { |
| 22 | projection, err := RestoreCheckpoint(state, testIdentity) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | messages := projection.buffer.Messages() |
| 27 | got := make([]string, len(messages)) |
| 28 | for index := range messages { |
| 29 | got[index] = messages[index].RecordID |
| 30 | } |
| 31 | if !slices.Equal(got, want) { |
| 32 | t.Fatalf("repaired identities = %v, want %v", got, want) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestRestoreCheckpointStillRejectsNonEmptyDuplicateIdentity(t *testing.T) { |
| 38 | state := Checkpoint{Version: ProtocolVersion, Identity: testIdentity, Records: []Message{ |
| 39 | {RecordID: "duplicate", Role: "notice", Content: "first"}, |
| 40 | {RecordID: "duplicate", Role: "notice", Content: "second"}, |
| 41 | }} |
| 42 | if _, err := RestoreCheckpoint(state, testIdentity); err == nil { |
| 43 | t.Fatal("duplicate checkpoint identity was accepted") |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestRepairCheckpointToolResultsRestoresUniqueFormalIdentityAndMetadata(t *testing.T) { |
| 48 | execution := &provider.ToolExecution{Kind: "shell", State: "completed", DurationMs: 42} |
| 49 | records := []Message{{RecordID: "tool:call-1", Role: "tool", ToolCallID: "call-1", ToolName: "PowerShell", |
| 50 | Content: "event display", ToolResultError: "", HistoryTurn: 1}} |
| 51 | canonical := []Message{ |
| 52 | {RecordID: "m:user-1", MessageID: "user-1", Role: "user"}, |
| 53 | {RecordID: "tool:call-1", MessageID: "result-1", Role: "tool", ToolCallID: "call-1", ToolName: "PowerShell", |
| 54 | Content: "canonical body", CreatedAt: 123, Execution: execution, ToolResultArchived: true}, |
| 55 | } |
| 56 | |
| 57 | got, stats := RepairCheckpointToolResults(records, canonical) |
| 58 | if stats.Repaired != 1 || stats.Missing != 0 || stats.Conflicts != 0 { |
| 59 | t.Fatalf("repair stats = %+v", stats) |
| 60 | } |
| 61 | if len(got) != 1 || got[0].MessageID != "result-1" || got[0].RecordID != "tool:call-1" || got[0].Content != "event display" || |
| 62 | got[0].HistoryTurn != 1 || got[0].CreatedAt != 123 || got[0].Execution != execution || !got[0].ToolResultArchived { |
| 63 | t.Fatalf("repaired checkpoint row = %+v", got) |
| 64 | } |
| 65 | if records[0].MessageID != "" { |
| 66 | t.Fatal("repair mutated the caller-owned checkpoint slice") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestRepairCheckpointToolResultsRefusesAmbiguousOrConflictingIdentity(t *testing.T) { |
| 71 | records := []Message{ |
| 72 | {RecordID: "tool:ambiguous", Role: "tool", ToolCallID: "ambiguous", HistoryTurn: 1}, |
| 73 | {RecordID: "tool:wrong-turn", Role: "tool", ToolCallID: "wrong-turn", HistoryTurn: 2}, |
| 74 | } |
| 75 | canonical := []Message{ |
| 76 | {MessageID: "user-1", Role: "user"}, |
| 77 | {MessageID: "a", Role: "tool", ToolCallID: "ambiguous"}, |
| 78 | {MessageID: "b", Role: "tool", ToolCallID: "ambiguous"}, |
| 79 | {MessageID: "c", Role: "tool", ToolCallID: "wrong-turn"}, |
| 80 | } |
| 81 | |
| 82 | got, stats := RepairCheckpointToolResults(records, canonical) |
| 83 | if stats.Repaired != 0 || stats.Conflicts != 1 || stats.Missing != 1 { |
| 84 | t.Fatalf("repair stats = %+v", stats) |
| 85 | } |
| 86 | if got[0].MessageID != "" || got[1].MessageID != "" { |
| 87 | t.Fatalf("inconclusive rows were guessed: %+v", got) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestRepairCheckpointToolResultsDoesNotReuseOccupiedFormalIdentity(t *testing.T) { |
| 92 | records := []Message{ |
| 93 | {RecordID: "m:result", MessageID: "result", Role: "tool", ToolCallID: "call"}, |
| 94 | {RecordID: "tool:call", Role: "tool", ToolCallID: "call"}, |
| 95 | } |
| 96 | canonical := []Message{{RecordID: "m:result", MessageID: "result", Role: "tool", ToolCallID: "call"}} |
| 97 | got, stats := RepairCheckpointToolResults(records, canonical) |
| 98 | if stats.Repaired != 0 || stats.Conflicts != 1 || got[1].MessageID != "" { |
| 99 | t.Fatalf("occupied formal identity was reused: stats=%+v rows=%+v", stats, got) |
| 100 | } |
| 101 | if !NeedsToolResultRepair(records) || NeedsToolResultRepair(records[:1]) { |
| 102 | t.Fatal("repair fast-path predicate does not match missing tool identities") |
| 103 | } |
| 104 | } |
| 105 |