| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestHistoryReplayRecoversMalformedBatchesWithoutMutatingEvidence(t *testing.T) { |
| 12 | for _, args := range []string{`{"body":"quote ["text"]"}`, "", "null", "[]", `"value"`} { |
| 13 | for _, state := range []ToolRunState{"", ToolRunUnknown, ToolRunCompleted, ToolRunNotStarted, ToolRunCancelled} { |
| 14 | t.Run(args+"/"+string(state), func(t *testing.T) { |
| 15 | messages := []Message{ |
| 16 | {ID: "proposal", Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "call", Name: "write_file", Arguments: args}}, ReasoningSignature: "private-signature"}, |
| 17 | {ID: "result", Role: RoleTool, ToolCallID: "call", Name: "write_file", Content: "recorded result", ToolRunState: state, RawContent: "private-raw"}, |
| 18 | {ID: "next", Role: RoleUser, Content: "continue"}, |
| 19 | } |
| 20 | before, _ := json.Marshal(messages) |
| 21 | repaired := RepairHistoryForReplay(messages) |
| 22 | if err := ValidateModelTranscript(repaired); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | if state == ToolRunNotStarted || state == ToolRunCancelled { |
| 26 | if repaired[0].ToolCalls[0].Arguments != "{}" { |
| 27 | t.Fatal("refused call was not repaired") |
| 28 | } |
| 29 | } else if len(repaired[0].ToolCalls) != 0 || repaired[1].Role != RoleUser || !strings.Contains(repaired[1].Content, "recorded result") { |
| 30 | t.Fatal("unknown/executed call was not retained as historical observations") |
| 31 | } |
| 32 | wire, _ := json.Marshal(ModelMessages(repaired)) |
| 33 | if strings.Contains(string(wire), "private-raw") { |
| 34 | t.Fatal("local metadata leaked") |
| 35 | } |
| 36 | if !reflect.DeepEqual(repaired, RepairHistoryForReplay(repaired)) { |
| 37 | t.Fatal("repair is not idempotent") |
| 38 | } |
| 39 | after, _ := json.Marshal(messages) |
| 40 | if !bytes.Equal(before, after) { |
| 41 | t.Fatal("canonical evidence changed") |
| 42 | } |
| 43 | }) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestHistoryReplayIdentityAndImageRecovery(t *testing.T) { |
| 49 | for _, calls := range [][]ToolCall{ |
| 50 | {{ID: "same", Name: "read", Arguments: "{}"}, {ID: "same", Name: "read", Arguments: "{}"}}, |
| 51 | {{ID: "call", Name: "", Arguments: "{}"}}, |
| 52 | {{ID: " ", Name: "read", Arguments: "{}"}}, |
| 53 | } { |
| 54 | messages := []Message{ |
| 55 | {Role: RoleAssistant, ToolCalls: calls, ResponsesItems: []json.RawMessage{json.RawMessage(`{"type":"reasoning","id":"old"}`)}}, |
| 56 | {Role: RoleTool, ToolCallID: calls[0].ID, Name: "read", Content: "observation", Images: []string{"https://example.invalid/image.png"}}, |
| 57 | } |
| 58 | got := RepairHistoryForReplay(messages) |
| 59 | if err := ValidateModelTranscript(got); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if len(got[0].ResponsesItems) != 0 || got[1].Role != RoleUser || len(got[1].Images) != 1 { |
| 63 | t.Fatal("native replay or images were not safely converted") |
| 64 | } |
| 65 | } |
| 66 | orphan := Message{Role: RoleTool, ToolCallID: "missing", Name: "read", Content: "orphan observation"} |
| 67 | if got := RepairHistoryForReplay([]Message{orphan}); len(got) != 1 || !strings.Contains(got[0].Content, orphan.Content) { |
| 68 | t.Fatal("orphan facts were lost") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestHistoryReplayPreservesHealthyWireBytes(t *testing.T) { |
| 73 | messages := []Message{ |
| 74 | {Role: RoleSystem, Content: "stable system"}, |
| 75 | {Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "call", Name: "read", Arguments: ` { "path": "one" } `}}, ReasoningContent: "thinking"}, |
| 76 | {Role: RoleTool, ToolCallID: "call", Name: "read", Content: "result", ToolRunState: ToolRunCompleted}, |
| 77 | {Role: RoleUser, Content: "continue"}, |
| 78 | } |
| 79 | before, _ := json.Marshal(ModelMessages(messages)) |
| 80 | after, _ := json.Marshal(ModelMessages(RepairHistoryForReplay(messages))) |
| 81 | if !bytes.Equal(before, after) { |
| 82 | t.Fatalf("healthy prefix changed:\n%s\n%s", before, after) |
| 83 | } |
| 84 | } |
| 85 |