返回 DeepSeek-Reasonix
preview_test.go
根目录 / internal / agent / preview_test.go
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
186 lines GO