| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // realContract mirrors the shape compileExecutionContract emits: the |
| 12 | // source_event lives under planner_ir, and the block replaces the whole user |
| 13 | // turn. |
| 14 | func realContract(sourceEvent string) string { |
| 15 | return "<memory-compiler-execution>\n" + |
| 16 | `{"type":"memory_v5_execution_contract","instruction":"Execute source_event through planner_ir.",` + |
| 17 | `"ir_explanation":{},"planner_ir":{"version":5,"goal":"g","source_event":` + jsonString(sourceEvent) + `}}` + |
| 18 | "\n</memory-compiler-execution>" |
| 19 | } |
| 20 | |
| 21 | func jsonString(s string) string { |
| 22 | // minimal JSON string quoting for test fixtures (no control chars used here) |
| 23 | return `"` + s + `"` |
| 24 | } |
| 25 | |
| 26 | // TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution guards the #5307 |
| 27 | // contract: the Memory v5 <memory-compiler-execution> block REPLACES the user |
| 28 | // turn (the prompt survives only inside the contract's source_event), so the |
| 29 | // display/preview path must unwrap it to the original prompt — not drop it like |
| 30 | // a prepended transient block, which would blank out the turn. |
| 31 | func TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution(t *testing.T) { |
| 32 | cases := []struct { |
| 33 | name string |
| 34 | in string |
| 35 | want string |
| 36 | }{ |
| 37 | { |
| 38 | name: "block only (compiled contract replaced the whole turn)", |
| 39 | in: realContract("add a config loader"), |
| 40 | want: "add a config loader", |
| 41 | }, |
| 42 | { |
| 43 | name: "language blocks before the compiler block", |
| 44 | // Real composition order: withTurnPreferences wraps the compiled |
| 45 | // contract, so the language blocks lead and the compiler block |
| 46 | // follows. Both must resolve to the original prompt. |
| 47 | in: "<reasoning-language>zh</reasoning-language>\n\n" + |
| 48 | "<response-language>zh</response-language>\n\n" + realContract("do the thing"), |
| 49 | want: "do the thing", |
| 50 | }, |
| 51 | { |
| 52 | name: "top-level source_event fallback shape", |
| 53 | in: "<memory-compiler-execution>\n{\"source_event\":\"older shape\"}\n</memory-compiler-execution>", |
| 54 | want: "older shape", |
| 55 | }, |
| 56 | { |
| 57 | name: "unrecoverable contract falls back to empty", |
| 58 | in: "<memory-compiler-execution>\n{\"type\":\"memory_v5_execution_contract\"}\n</memory-compiler-execution>", |
| 59 | want: "", |
| 60 | }, |
| 61 | { |
| 62 | name: "non-contract content is untouched", |
| 63 | in: "just a normal prompt", |
| 64 | want: "just a normal prompt", |
| 65 | }, |
| 66 | { |
| 67 | name: "hook context prefix is stripped", |
| 68 | in: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nship it", |
| 69 | want: "ship it", |
| 70 | }, |
| 71 | { |
| 72 | name: "active goal prefix is stripped", |
| 73 | in: "<active-goal>\nFix all bugs\n</active-goal>\n\nfix the auth bug", |
| 74 | want: "fix the auth bug", |
| 75 | }, |
| 76 | { |
| 77 | name: "automatic memory recall suffix is stripped", |
| 78 | in: "fix AuthHandler\n\n<memory-recall>\n- recalled fact\n</memory-recall>", |
| 79 | want: "fix AuthHandler", |
| 80 | }, |
| 81 | { |
| 82 | name: "active goal after other transient prefixes is stripped", |
| 83 | in: "<reasoning-language>\nuse Chinese\n</reasoning-language>\n\n" + |
| 84 | "<memory-update>\n- note\n</memory-update>\n\n" + |
| 85 | "<active-goal>\nDo X\n</active-goal>\n\nhelp me", |
| 86 | want: "help me", |
| 87 | }, |
| 88 | { |
| 89 | name: "capability route prefix is stripped", |
| 90 | in: "<capability-route version=\"1\">\nRelevant capabilities:\n- skill:review prefer\n</capability-route>\n\nreview this", |
| 91 | want: "review this", |
| 92 | }, |
| 93 | } |
| 94 | for _, tc := range cases { |
| 95 | t.Run(tc.name, func(t *testing.T) { |
| 96 | if got := StripTransientUserBlocks(tc.in); got != tc.want { |
| 97 | t.Fatalf("StripTransientUserBlocks(%q) = %q, want %q", tc.in, got, tc.want) |
| 98 | } |
| 99 | }) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // TestUserPreviewTextPreservesCompiledTurnPrompt is the regression for the bot |
| 104 | // finding: a session whose first turn was compiled must still show the user's |
| 105 | // prompt in history/sidebar previews, not a blank line. |
| 106 | func TestUserPreviewTextPreservesCompiledTurnPrompt(t *testing.T) { |
| 107 | in := realContract("ship the refactor") |
| 108 | if got := UserPreviewText(in); got != "ship the refactor" { |
| 109 | t.Fatalf("UserPreviewText = %q, want %q (compiled turn must not blank the preview)", got, "ship the refactor") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // TestSessionPreviewFromMessagesPreservesCompiledFirstTurn proves the end-to-end |
| 114 | // preview path (used for the picker/sidebar) recovers the prompt when the first |
| 115 | // persisted user turn is a compiled contract. |
| 116 | func TestSessionPreviewFromMessagesPreservesCompiledFirstTurn(t *testing.T) { |
| 117 | msgs := []provider.Message{ |
| 118 | {Role: provider.RoleSystem, Content: "sys"}, |
| 119 | {Role: provider.RoleUser, Content: realContract("add pagination to the users endpoint")}, |
| 120 | {Role: provider.RoleAssistant, Content: "done"}, |
| 121 | } |
| 122 | preview, turns := SessionPreviewFromMessages(msgs) |
| 123 | if preview != "add pagination to the users endpoint" { |
| 124 | t.Fatalf("preview = %q, want the compiled turn's source_event", preview) |
| 125 | } |
| 126 | if turns != 1 { |
| 127 | t.Fatalf("user turns = %d, want 1", turns) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // Reproduces #5361: the v1.12.0 goal loop (fixed in #5387) accreted nested |
| 132 | // memory-compiler-execution contracts — each turn's source_event string |
| 133 | // embedded the previous turn's full <memory-compiler-execution> block. The |
| 134 | // non-greedy unwrap regex stops at the FIRST </memory-compiler-execution> |
| 135 | // (which is inside the outer contract's JSON string), so it captures a |
| 136 | // truncated, invalid JSON body and leaves dangling tag/JSON garbage in the |
| 137 | // transcript ("一堆字符串"). Existing corrupted sessions must still render |
| 138 | // cleanly, so the display layer must unwrap robustly. |
| 139 | func TestUserPreviewTextUnwrapsNestedCompilerContracts(t *testing.T) { |
| 140 | // Deeply accreted contract (a long goal loop re-compiled the echoed contract |
| 141 | // many times). Two unwrap passes are not enough for N levels. |
| 142 | deep := "fix the login bug" |
| 143 | for range 6 { |
| 144 | deep = mcContract(t, "follow-up step\n"+deep) |
| 145 | } |
| 146 | assertNoContractLeak(t, UserPreviewText(deep), "follow-up step") |
| 147 | |
| 148 | // A dangling / truncated block (streaming cut, or the model echoing a partial |
| 149 | // contract) has no closing tag, so the strict regex never matches it. |
| 150 | partial := "do the thing\n<memory-compiler-execution>\n{\"planner_ir\":{\"source_event\":\"do the thing\"," + strings.Repeat("x", 40) |
| 151 | assertNoContractLeak(t, UserPreviewText(partial), "do the thing") |
| 152 | } |
| 153 | |
| 154 | func assertNoContractLeak(t *testing.T, got, want string) { |
| 155 | t.Helper() |
| 156 | if strings.Contains(got, "<memory-compiler-execution>") || strings.Contains(got, "</memory-compiler-execution>") { |
| 157 | t.Fatalf("preview leaked a contract tag (raw JSON shown to the user):\n%q", got) |
| 158 | } |
| 159 | if strings.Contains(got, "planner_ir") || strings.Contains(got, "memory_v5_execution_contract") { |
| 160 | t.Fatalf("preview leaked contract JSON:\n%q", got) |
| 161 | } |
| 162 | if !strings.Contains(got, want) { |
| 163 | t.Fatalf("preview lost the user's actual text %q, got:\n%q", want, got) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // mcContract builds a <memory-compiler-execution> block whose |
| 168 | // planner_ir.source_event is the given text, matching the real contract shape. |
| 169 | func mcContract(t *testing.T, sourceEvent string) string { |
| 170 | t.Helper() |
| 171 | body, err := json.Marshal(struct { |
| 172 | Type string `json:"type"` |
| 173 | PlannerIR struct { |
| 174 | Version int `json:"version"` |
| 175 | SourceEvent string `json:"source_event"` |
| 176 | } `json:"planner_ir"` |
| 177 | }{Type: "memory_v5_execution_contract", PlannerIR: struct { |
| 178 | Version int `json:"version"` |
| 179 | SourceEvent string `json:"source_event"` |
| 180 | }{Version: 5, SourceEvent: sourceEvent}}) |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | return "<memory-compiler-execution>\n" + string(body) + "\n</memory-compiler-execution>" |
| 185 | } |
| 186 |