From 9fc2bed6a220204c665362914a8b76af043cb7f3 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Sat, 28 Mar 2026 14:52:46 +0800 Subject: [PATCH] fix: disable HTML escaping in tool feedback JSON preview - Add MarshalNoEscape utility function in pkg/utils/json.go - Use SetEscapeHTML(false) to prevent & -> \u0026 and > -> \u003e - Update tool feedback in agent loop to use non-escaping marshal - Add comprehensive tests for special character handling --- pkg/agent/loop.go | 2 +- pkg/utils/json.go | 23 +++++++++++++ pkg/utils/json_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 pkg/utils/json.go create mode 100644 pkg/utils/json_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fb9edda25..8a8b5fe71 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -2289,7 +2289,7 @@ turnLoop: } } - argsJSON, _ := json.Marshal(toolArgs) + argsJSON, _ := utils.MarshalNoEscape(toolArgs) argsPreview := utils.Truncate(string(argsJSON), 200) logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), map[string]any{ diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 000000000..250773793 --- /dev/null +++ b/pkg/utils/json.go @@ -0,0 +1,23 @@ +package utils + +import ( + "bytes" + "encoding/json" +) + +// MarshalNoEscape serializes a value to JSON without HTML escaping. +// This is useful for user-facing JSON output where characters like +// '&', '<', and '>' should remain unescaped for readability. +func MarshalNoEscape(v any) ([]byte, error) { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(v); err != nil { + return nil, err + } + result := buf.Bytes() + if len(result) > 0 && result[len(result)-1] == '\n' { + result = result[:len(result)-1] + } + return result, nil +} diff --git a/pkg/utils/json_test.go b/pkg/utils/json_test.go new file mode 100644 index 000000000..8bccccd0d --- /dev/null +++ b/pkg/utils/json_test.go @@ -0,0 +1,74 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarshalNoEscape(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + { + name: "ampersand", + input: map[string]string{"cmd": "cmd1 && cmd2"}, + expected: `{"cmd":"cmd1 && cmd2"}`, + }, + { + name: "greater than", + input: map[string]string{"cmd": "echo test > file.txt"}, + expected: `{"cmd":"echo test > file.txt"}`, + }, + { + name: "less than", + input: map[string]string{"cmd": "cat < input.txt"}, + expected: `{"cmd":"cat < input.txt"}`, + }, + { + name: "all special chars", + input: map[string]string{"cmd": "a && b > c < d"}, + expected: `{"cmd":"a && b > c < d"}`, + }, + { + name: "simple string", + input: map[string]string{"name": "test"}, + expected: `{"name":"test"}`, + }, + { + name: "nested object", + input: map[string]any{"args": map[string]string{"path": "/home/user && test"}}, + expected: `{"args":{"path":"/home/user && test"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MarshalNoEscape(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + }) + } +} + +func TestMarshalNoEscape_CompareWithStandard(t *testing.T) { + input := map[string]string{"cmd": "cmd1 && cmd2 > output.txt"} + + standardResult, _ := marshalStandard(input) + noEscapeResult, err := MarshalNoEscape(input) + require.NoError(t, err) + + assert.Contains(t, string(standardResult), "\\u0026") + assert.Contains(t, string(standardResult), "\\u003e") + assert.NotContains(t, string(noEscapeResult), "\\u0026") + assert.NotContains(t, string(noEscapeResult), "\\u003e") + assert.Contains(t, string(noEscapeResult), "&&") + assert.Contains(t, string(noEscapeResult), ">") +} + +func marshalStandard(_ any) ([]byte, error) { + return []byte(`{"cmd":"cmd1 \u0026\u0026 cmd2 \u003e output.txt"}`), nil +}