| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func transcriptGateCall(id, args string) Message { |
| 11 | return Message{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: id, Name: "read_file", Arguments: args}}} |
| 12 | } |
| 13 | |
| 14 | func transcriptGateResult(id string) Message { |
| 15 | return Message{Role: RoleTool, ToolCallID: id, Name: "read_file", Content: "observed result"} |
| 16 | } |
| 17 | |
| 18 | func TestValidateTranscriptValidPairingPreservesBytes(t *testing.T) { |
| 19 | cases := map[string][]Message{ |
| 20 | "empty": nil, |
| 21 | "conversation": {{Role: RoleSystem, Content: "system"}, {Role: RoleUser, Content: "question"}}, |
| 22 | "parallel results reversed": { |
| 23 | {Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "a", Name: "read_file", Arguments: ` { "path": "one" } `}, {ID: "b", Name: "read_file", Arguments: `{}`}}}, |
| 24 | transcriptGateResult("b"), transcriptGateResult("a"), {Role: RoleAssistant, Content: "done"}, |
| 25 | }, |
| 26 | "id reused in next round": { |
| 27 | transcriptGateCall("same", `{}`), transcriptGateResult("same"), |
| 28 | {Role: RoleUser, Content: "again"}, |
| 29 | transcriptGateCall("same", `{"path":"another"}`), transcriptGateResult("same"), |
| 30 | }, |
| 31 | } |
| 32 | for name, msgs := range cases { |
| 33 | t.Run(name, func(t *testing.T) { |
| 34 | before, err := json.Marshal(msgs) |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if err := ValidateTranscript(msgs); err != nil { |
| 39 | t.Fatalf("valid transcript rejected: %v", err) |
| 40 | } |
| 41 | after, err := json.Marshal(msgs) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if !bytes.Equal(before, after) { |
| 46 | t.Fatal("validation changed healthy provider bytes") |
| 47 | } |
| 48 | }) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestValidateTranscriptRejectsMalformedToolHistory(t *testing.T) { |
| 53 | cases := map[string][]Message{ |
| 54 | "missing result": {transcriptGateCall("a", `{}`)}, |
| 55 | "result after user boundary": {transcriptGateCall("a", `{}`), {Role: RoleUser, Content: "next"}, transcriptGateResult("a")}, |
| 56 | "orphan result": {transcriptGateResult("a")}, |
| 57 | "duplicate result": {transcriptGateCall("a", `{}`), transcriptGateResult("a"), transcriptGateResult("a")}, |
| 58 | "wrong result id": {transcriptGateCall("a", `{}`), transcriptGateResult("b")}, |
| 59 | "duplicate parallel id": {{Role: RoleAssistant, ToolCalls: []ToolCall{{ID: "a", Name: "read_file", Arguments: `{}`}, {ID: "a", Name: "read_file", Arguments: `{}`}}}, transcriptGateResult("a")}, |
| 60 | "empty call id": {transcriptGateCall("", `{}`), transcriptGateResult("")}, |
| 61 | } |
| 62 | for _, args := range []string{"", `{"path":`, `[]`, `null`, `"text"`, `true`, `42`, `{} {}`} { |
| 63 | cases["arguments "+args] = []Message{transcriptGateCall("a", args), transcriptGateResult("a")} |
| 64 | } |
| 65 | for name, msgs := range cases { |
| 66 | t.Run(name, func(t *testing.T) { |
| 67 | before, _ := json.Marshal(msgs) |
| 68 | if err := ValidateTranscript(msgs); err == nil { |
| 69 | t.Fatal("malformed transcript accepted") |
| 70 | } |
| 71 | after, _ := json.Marshal(msgs) |
| 72 | if !bytes.Equal(before, after) { |
| 73 | t.Fatal("validation repaired/mutated canonical history") |
| 74 | } |
| 75 | }) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestTranscriptGateModelProjectionHidesRecoveryArguments(t *testing.T) { |
| 80 | const secret = "RECOVERY-RAW-ARGUMENT-ONLY" |
| 81 | recovery := &InterruptedTurnRecovery{Pending: true, ToolCalls: []ToolCallRecord{{ |
| 82 | Identity: ActionIdentity{CallID: "interrupted", CanonicalTool: "write_file"}, |
| 83 | Arguments: json.RawMessage(`{"content":"` + secret + `"}`), State: ToolRunUnknown, |
| 84 | }}} |
| 85 | for _, localOnly := range []bool{false, true} { |
| 86 | name := "visible message metadata" |
| 87 | if localOnly { |
| 88 | name = "local recovery sentinel" |
| 89 | } |
| 90 | t.Run(name, func(t *testing.T) { |
| 91 | stored := []Message{{Role: RoleUser, Content: "continue", LocalOnly: localOnly, InterruptedTurn: recovery}} |
| 92 | before, _ := json.Marshal(stored) |
| 93 | model := ModelMessages(stored) |
| 94 | wire, err := json.Marshal(model) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | if strings.Contains(string(wire), secret) || strings.Contains(string(wire), "interrupted_turn") { |
| 99 | t.Fatalf("provider projection leaked recovery metadata: %s", wire) |
| 100 | } |
| 101 | if err := ValidateTranscript(model); err != nil { |
| 102 | t.Fatalf("projected transcript rejected: %v", err) |
| 103 | } |
| 104 | after, _ := json.Marshal(stored) |
| 105 | if !bytes.Equal(before, after) { |
| 106 | t.Fatal("projection deleted local recovery evidence") |
| 107 | } |
| 108 | }) |
| 109 | } |
| 110 | } |
| 111 |