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
This commit is contained in:
SiYue-ZO 2026-03-28 14:52:46 +08:00
parent f1cb7cc8f5
commit 9fc2bed6a2
3 changed files with 98 additions and 1 deletions

View file

@ -2289,7 +2289,7 @@ turnLoop:
} }
} }
argsJSON, _ := json.Marshal(toolArgs) argsJSON, _ := utils.MarshalNoEscape(toolArgs)
argsPreview := utils.Truncate(string(argsJSON), 200) argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview),
map[string]any{ map[string]any{

23
pkg/utils/json.go Normal file
View file

@ -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
}

74
pkg/utils/json_test.go Normal file
View file

@ -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
}