Files
2026-07-13 13:00:08 +08:00

181 lines
7.1 KiB
Go

package agent
import (
"encoding/json"
"strings"
"testing"
"reasonix/internal/provider"
)
// realContract mirrors the shape compileExecutionContract emits: the
// source_event lives under planner_ir, and the block replaces the whole user
// turn.
func realContract(sourceEvent string) string {
return "<memory-compiler-execution>\n" +
`{"type":"memory_v5_execution_contract","instruction":"Execute source_event through planner_ir.",` +
`"ir_explanation":{},"planner_ir":{"version":5,"goal":"g","source_event":` + jsonString(sourceEvent) + `}}` +
"\n</memory-compiler-execution>"
}
func jsonString(s string) string {
// minimal JSON string quoting for test fixtures (no control chars used here)
return `"` + s + `"`
}
// TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution guards the #5307
// contract: the Memory v5 <memory-compiler-execution> block REPLACES the user
// turn (the prompt survives only inside the contract's source_event), so the
// display/preview path must unwrap it to the original prompt — not drop it like
// a prepended transient block, which would blank out the turn.
func TestStripTransientUserBlocksUnwrapsMemoryCompilerExecution(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "block only (compiled contract replaced the whole turn)",
in: realContract("add a config loader"),
want: "add a config loader",
},
{
name: "language blocks before the compiler block",
// Real composition order: withTurnPreferences wraps the compiled
// contract, so the language blocks lead and the compiler block
// follows. Both must resolve to the original prompt.
in: "<reasoning-language>zh</reasoning-language>\n\n" +
"<response-language>zh</response-language>\n\n" + realContract("do the thing"),
want: "do the thing",
},
{
name: "top-level source_event fallback shape",
in: "<memory-compiler-execution>\n{\"source_event\":\"older shape\"}\n</memory-compiler-execution>",
want: "older shape",
},
{
name: "unrecoverable contract falls back to empty",
in: "<memory-compiler-execution>\n{\"type\":\"memory_v5_execution_contract\"}\n</memory-compiler-execution>",
want: "",
},
{
name: "non-contract content is untouched",
in: "just a normal prompt",
want: "just a normal prompt",
},
{
name: "hook context prefix is stripped",
in: "<hook-context event=\"SessionStart\">\nLoad conventions.\n</hook-context>\n\nship it",
want: "ship it",
},
{
name: "active goal prefix is stripped",
in: "<active-goal>\nFix all bugs\n</active-goal>\n\nfix the auth bug",
want: "fix the auth bug",
},
{
name: "active goal after other transient prefixes is stripped",
in: "<reasoning-language>\nuse Chinese\n</reasoning-language>\n\n" +
"<memory-update>\n- note\n</memory-update>\n\n" +
"<active-goal>\nDo X\n</active-goal>\n\nhelp me",
want: "help me",
},
{
name: "capability route prefix is stripped",
in: "<capability-route version=\"1\">\nRelevant capabilities:\n- skill:review prefer\n</capability-route>\n\nreview this",
want: "review this",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := StripTransientUserBlocks(tc.in); got != tc.want {
t.Fatalf("StripTransientUserBlocks(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestUserPreviewTextPreservesCompiledTurnPrompt is the regression for the bot
// finding: a session whose first turn was compiled must still show the user's
// prompt in history/sidebar previews, not a blank line.
func TestUserPreviewTextPreservesCompiledTurnPrompt(t *testing.T) {
in := realContract("ship the refactor")
if got := UserPreviewText(in); got != "ship the refactor" {
t.Fatalf("UserPreviewText = %q, want %q (compiled turn must not blank the preview)", got, "ship the refactor")
}
}
// TestSessionPreviewFromMessagesPreservesCompiledFirstTurn proves the end-to-end
// preview path (used for the picker/sidebar) recovers the prompt when the first
// persisted user turn is a compiled contract.
func TestSessionPreviewFromMessagesPreservesCompiledFirstTurn(t *testing.T) {
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: realContract("add pagination to the users endpoint")},
{Role: provider.RoleAssistant, Content: "done"},
}
preview, turns := SessionPreviewFromMessages(msgs)
if preview != "add pagination to the users endpoint" {
t.Fatalf("preview = %q, want the compiled turn's source_event", preview)
}
if turns != 1 {
t.Fatalf("user turns = %d, want 1", turns)
}
}
// Reproduces #5361: the v1.12.0 goal loop (fixed in #5387) accreted nested
// memory-compiler-execution contracts — each turn's source_event string
// embedded the previous turn's full <memory-compiler-execution> block. The
// non-greedy unwrap regex stops at the FIRST </memory-compiler-execution>
// (which is inside the outer contract's JSON string), so it captures a
// truncated, invalid JSON body and leaves dangling tag/JSON garbage in the
// transcript ("一堆字符串"). Existing corrupted sessions must still render
// cleanly, so the display layer must unwrap robustly.
func TestUserPreviewTextUnwrapsNestedCompilerContracts(t *testing.T) {
// Deeply accreted contract (a long goal loop re-compiled the echoed contract
// many times). Two unwrap passes are not enough for N levels.
deep := "fix the login bug"
for range 6 {
deep = mcContract(t, "follow-up step\n"+deep)
}
assertNoContractLeak(t, UserPreviewText(deep), "follow-up step")
// A dangling / truncated block (streaming cut, or the model echoing a partial
// contract) has no closing tag, so the strict regex never matches it.
partial := "do the thing\n<memory-compiler-execution>\n{\"planner_ir\":{\"source_event\":\"do the thing\"," + strings.Repeat("x", 40)
assertNoContractLeak(t, UserPreviewText(partial), "do the thing")
}
func assertNoContractLeak(t *testing.T, got, want string) {
t.Helper()
if strings.Contains(got, "<memory-compiler-execution>") || strings.Contains(got, "</memory-compiler-execution>") {
t.Fatalf("preview leaked a contract tag (raw JSON shown to the user):\n%q", got)
}
if strings.Contains(got, "planner_ir") || strings.Contains(got, "memory_v5_execution_contract") {
t.Fatalf("preview leaked contract JSON:\n%q", got)
}
if !strings.Contains(got, want) {
t.Fatalf("preview lost the user's actual text %q, got:\n%q", want, got)
}
}
// mcContract builds a <memory-compiler-execution> block whose
// planner_ir.source_event is the given text, matching the real contract shape.
func mcContract(t *testing.T, sourceEvent string) string {
t.Helper()
body, err := json.Marshal(struct {
Type string `json:"type"`
PlannerIR struct {
Version int `json:"version"`
SourceEvent string `json:"source_event"`
} `json:"planner_ir"`
}{Type: "memory_v5_execution_contract", PlannerIR: struct {
Version int `json:"version"`
SourceEvent string `json:"source_event"`
}{Version: 5, SourceEvent: sourceEvent}})
if err != nil {
t.Fatal(err)
}
return "<memory-compiler-execution>\n" + string(body) + "\n</memory-compiler-execution>"
}