detached 3aa968f

fix(provider): repair control characters in streamed tool arguments

commit
3aa968fd2bece4a56aa8b8e631a945bb5cecbbb5
author
brice · · 2026-10-02 04:43 UTC
committer
brice · · 2026-10-02 07:44 UTC
parent
b330f27
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +152 -25
M packages/provider/amazon_bedrock.go +1 -5
@@ -733,12 +733,8 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
733733 continue
734734 }
735735 if st.isToolUse {
736- args := st.toolArgs.String()
737- if args == "" {
738- args = "{}"
739- }
740736 finalMsg.Content = append(finalMsg.Content, ToolCallBlock{
741- ID: st.toolID, Name: st.toolName, Arguments: json.RawMessage(args),
737+ ID: st.toolID, Name: st.toolName, Arguments: normalizeToolArgs(st.toolArgs.String()),
742738 })
743739 out <- EventToolEnd{ID: st.toolID}
744740 } else if st.text.Len() > 0 {
@@ -733,12 +733,8 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
733 continue733 continue
734 }734 }
735 if st.isToolUse {735 if st.isToolUse {
736- args := st.toolArgs.String()
737- if args == "" {
738- args = "{}"
739- }
740 finalMsg.Content = append(finalMsg.Content, ToolCallBlock{736 finalMsg.Content = append(finalMsg.Content, ToolCallBlock{
741- ID: st.toolID, Name: st.toolName, Arguments: json.RawMessage(args),737+ ID: st.toolID, Name: st.toolName, Arguments: normalizeToolArgs(st.toolArgs.String()),
742 })738 })
743 out <- EventToolEnd{ID: st.toolID}739 out <- EventToolEnd{ID: st.toolID}
744 } else if st.text.Len() > 0 {740 } else if st.text.Len() > 0 {
M packages/provider/anthropic.go +1 -5
@@ -689,11 +689,7 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re
689689 }
690690 case "tool_use":
691691 tc := be.toolCall
692- args := be.toolArgs.String()
693- if args == "" {
694- args = "{}"
695- }
696- tc.Arguments = json.RawMessage(args)
692+ tc.Arguments = normalizeToolArgs(be.toolArgs.String())
697693 content = append(content, tc)
698694 }
699695 }
@@ -689,11 +689,7 @@ func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, re
689 }689 }
690 case "tool_use":690 case "tool_use":
691 tc := be.toolCall691 tc := be.toolCall
692- args := be.toolArgs.String()692+ tc.Arguments = normalizeToolArgs(be.toolArgs.String())
693- if args == "" {
694- args = "{}"
695- }
696- tc.Arguments = json.RawMessage(args)
697 content = append(content, tc)693 content = append(content, tc)
698 }694 }
699 }695 }
M packages/provider/gemini.go +1 -5
@@ -636,14 +636,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
636636 }
637637 }
638638 case "tool_use":
639- args := b.toolArgs.String()
640- if args == "" || !json.Valid([]byte(args)) {
641- args = "{}"
642- }
643639 content = append(content, ToolCallBlock{
644640 ID: b.toolID,
645641 Name: b.toolName,
646- Arguments: json.RawMessage(args),
642+ Arguments: normalizeToolArgs(b.toolArgs.String()),
647643 ThoughtSignature: b.thoughtSignature,
648644 })
649645 }
@@ -636,14 +636,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
636 }636 }
637 }637 }
638 case "tool_use":638 case "tool_use":
639- args := b.toolArgs.String()
640- if args == "" || !json.Valid([]byte(args)) {
641- args = "{}"
642- }
643 content = append(content, ToolCallBlock{639 content = append(content, ToolCallBlock{
644 ID: b.toolID,640 ID: b.toolID,
645 Name: b.toolName,641 Name: b.toolName,
646- Arguments: json.RawMessage(args),642+ Arguments: normalizeToolArgs(b.toolArgs.String()),
647 ThoughtSignature: b.thoughtSignature,643 ThoughtSignature: b.thoughtSignature,
648 })644 })
649 }645 }
M packages/provider/openai.go +1 -5
@@ -703,12 +703,8 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
703703 content = append(content, TextBlock{Text: b.textBuf.String()})
704704 }
705705 case "tool_use":
706- args := b.toolArgs.String()
707- if args == "" || !json.Valid([]byte(args)) {
708- args = "{}"
709- }
710706 content = append(content, ToolCallBlock{
711- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
707+ ID: b.toolID, Name: b.toolName, Arguments: normalizeToolArgs(b.toolArgs.String()),
712708 })
713709 }
714710 }
@@ -703,12 +703,8 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
703 content = append(content, TextBlock{Text: b.textBuf.String()})703 content = append(content, TextBlock{Text: b.textBuf.String()})
704 }704 }
705 case "tool_use":705 case "tool_use":
706- args := b.toolArgs.String()
707- if args == "" || !json.Valid([]byte(args)) {
708- args = "{}"
709- }
710 content = append(content, ToolCallBlock{706 content = append(content, ToolCallBlock{
711- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),707+ ID: b.toolID, Name: b.toolName, Arguments: normalizeToolArgs(b.toolArgs.String()),
712 })708 })
713 }709 }
714 }710 }
M packages/provider/openai_codex.go +1 -5
@@ -796,12 +796,8 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
796796 content = append(content, TextBlock{Text: it.textBuf.String(), Phase: it.phase})
797797 }
798798 case "function_call":
799- args := it.argsBuf.String()
800- if args == "" || !json.Valid([]byte(args)) {
801- args = "{}"
802- }
803799 content = append(content, ToolCallBlock{
804- ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
800+ ID: it.callID, Name: it.name, Arguments: normalizeToolArgs(it.argsBuf.String()),
805801 })
806802 case "reasoning":
807803 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
@@ -796,12 +796,8 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
796 content = append(content, TextBlock{Text: it.textBuf.String(), Phase: it.phase})796 content = append(content, TextBlock{Text: it.textBuf.String(), Phase: it.phase})
797 }797 }
798 case "function_call":798 case "function_call":
799- args := it.argsBuf.String()
800- if args == "" || !json.Valid([]byte(args)) {
801- args = "{}"
802- }
803 content = append(content, ToolCallBlock{799 content = append(content, ToolCallBlock{
804- ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),800+ ID: it.callID, Name: it.name, Arguments: normalizeToolArgs(it.argsBuf.String()),
805 })801 })
806 case "reasoning":802 case "reasoning":
807 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {803 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
A packages/provider/tool_args.go +67 -0
@@ -0,0 +1,67 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+)
7+
8+// normalizeToolArgs turns streamed tool-call arguments into valid JSON.
9+// Models sometimes emit raw control characters (tabs, newlines) inside JSON
10+// strings; those are escaped so the call keeps its intended arguments.
11+// Empty or otherwise unrepairable input becomes "{}" so the message can
12+// always be serialized to transcripts and sent back to the provider.
13+func normalizeToolArgs(raw string) json.RawMessage {
14+ if raw == "" {
15+ return json.RawMessage("{}")
16+ }
17+ if json.Valid([]byte(raw)) {
18+ return json.RawMessage(raw)
19+ }
20+ if repaired := escapeControlCharsInStrings(raw); json.Valid([]byte(repaired)) {
21+ return json.RawMessage(repaired)
22+ }
23+ return json.RawMessage("{}")
24+}
25+
26+// escapeControlCharsInStrings escapes raw U+0000–U+001F bytes that appear
27+// inside JSON string literals. Bytes outside strings are left untouched.
28+func escapeControlCharsInStrings(raw string) string {
29+ const hex = "0123456789abcdef"
30+ var b strings.Builder
31+ b.Grow(len(raw) + 8)
32+ inString, escaped := false, false
33+ for i := 0; i < len(raw); i++ {
34+ c := raw[i]
35+ if !inString {
36+ if c == '"' {
37+ inString = true
38+ }
39+ b.WriteByte(c)
40+ continue
41+ }
42+ switch {
43+ case escaped:
44+ escaped = false
45+ b.WriteByte(c)
46+ case c == '\\':
47+ escaped = true
48+ b.WriteByte(c)
49+ case c == '"':
50+ inString = false
51+ b.WriteByte(c)
52+ case c == '\t':
53+ b.WriteString(`\t`)
54+ case c == '\n':
55+ b.WriteString(`\n`)
56+ case c == '\r':
57+ b.WriteString(`\r`)
58+ case c < 0x20:
59+ b.WriteString(`\u00`)
60+ b.WriteByte(hex[c>>4])
61+ b.WriteByte(hex[c&0xf])
62+ default:
63+ b.WriteByte(c)
64+ }
65+ }
66+ return b.String()
67+}
@@ -0,0 +1,67 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+)
7+
8+// normalizeToolArgs turns streamed tool-call arguments into valid JSON.
9+// Models sometimes emit raw control characters (tabs, newlines) inside JSON
10+// strings; those are escaped so the call keeps its intended arguments.
11+// Empty or otherwise unrepairable input becomes "{}" so the message can
12+// always be serialized to transcripts and sent back to the provider.
13+func normalizeToolArgs(raw string) json.RawMessage {
14+ if raw == "" {
15+ return json.RawMessage("{}")
16+ }
17+ if json.Valid([]byte(raw)) {
18+ return json.RawMessage(raw)
19+ }
20+ if repaired := escapeControlCharsInStrings(raw); json.Valid([]byte(repaired)) {
21+ return json.RawMessage(repaired)
22+ }
23+ return json.RawMessage("{}")
24+}
25+
26+// escapeControlCharsInStrings escapes raw U+0000–U+001F bytes that appear
27+// inside JSON string literals. Bytes outside strings are left untouched.
28+func escapeControlCharsInStrings(raw string) string {
29+ const hex = "0123456789abcdef"
30+ var b strings.Builder
31+ b.Grow(len(raw) + 8)
32+ inString, escaped := false, false
33+ for i := 0; i < len(raw); i++ {
34+ c := raw[i]
35+ if !inString {
36+ if c == '"' {
37+ inString = true
38+ }
39+ b.WriteByte(c)
40+ continue
41+ }
42+ switch {
43+ case escaped:
44+ escaped = false
45+ b.WriteByte(c)
46+ case c == '\\':
47+ escaped = true
48+ b.WriteByte(c)
49+ case c == '"':
50+ inString = false
51+ b.WriteByte(c)
52+ case c == '\t':
53+ b.WriteString(`\t`)
54+ case c == '\n':
55+ b.WriteString(`\n`)
56+ case c == '\r':
57+ b.WriteString(`\r`)
58+ case c < 0x20:
59+ b.WriteString(`\u00`)
60+ b.WriteByte(hex[c>>4])
61+ b.WriteByte(hex[c&0xf])
62+ default:
63+ b.WriteByte(c)
64+ }
65+ }
66+ return b.String()
67+}
A packages/provider/tool_args_test.go +80 -0
@@ -0,0 +1,80 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestNormalizeToolArgs(t *testing.T) {
13+ tab, nl, bell := string(rune(9)), string(rune(10)), string(rune(7))
14+ tests := []struct {
15+ name string
16+ in string
17+ want string
18+ }{
19+ {name: "empty", in: "", want: `{}`},
20+ {name: "valid kept verbatim", in: `{"a":"b\tc"}`, want: `{"a":"b\tc"}`},
21+ {name: "raw tab in string", in: `{"old":"` + tab + `x"}`, want: `{"old":"\tx"}`},
22+ {name: "raw newline in string", in: `{"s":"a` + nl + `b"}`, want: `{"s":"a\nb"}`},
23+ {name: "other control char", in: `{"s":"` + bell + `"}`, want: `{"s":"\u0007"}`},
24+ {name: "whitespace outside strings untouched", in: "{" + nl + tab + `"s":"` + tab + `"}`, want: "{" + nl + tab + `"s":"\t"}`},
25+ {name: "escaped quote keeps string state", in: `{"s":"\"` + tab + `"}`, want: `{"s":"\"\t"}`},
26+ {name: "unrepairable", in: `{"s":`, want: `{}`},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ got := normalizeToolArgs(tt.in)
31+ if string(got) != tt.want {
32+ t.Fatalf("normalizeToolArgs(%q) = %q, want %q", tt.in, got, tt.want)
33+ }
34+ if !json.Valid(got) {
35+ t.Fatalf("result %q is not valid JSON", got)
36+ }
37+ })
38+ }
39+}
40+
41+func TestNormalizedToolArgsKeepMessageSerializable(t *testing.T) {
42+ msg := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{
43+ ID: "call-1", Name: "edit", Arguments: normalizeToolArgs(`{"old":"` + string(rune(9)) + `x"}`),
44+ }}}
45+ if _, err := json.Marshal(msg); err != nil {
46+ t.Fatalf("marshal message: %v", err)
47+ }
48+}
49+
50+func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
51+ // partial_json is a JSON string carrying the model's raw argument text;
52+ // "\\t" below decodes to a real tab byte inside the argument string.
53+ body := "event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"edit\",\"input\":{}}}\n\n" +
54+ "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"old\\\":\\\"\\tx\\\"}\"}}\n\n" +
55+ "event: content_block_stop\ndata: {\"index\":0}\n\n" +
56+ "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n"
57+ resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
58+ out := make(chan Event, 16)
59+ go (&anthropicClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
60+ var done EventDone
61+ for ev := range out {
62+ if d, ok := ev.(EventDone); ok {
63+ done = d
64+ }
65+ }
66+ if len(done.Message.Content) != 1 {
67+ t.Fatalf("content = %#v, want one tool call", done.Message.Content)
68+ }
69+ tc, ok := done.Message.Content[0].(ToolCallBlock)
70+ if !ok {
71+ t.Fatalf("content[0] = %T, want ToolCallBlock", done.Message.Content[0])
72+ }
73+ var args struct{ Old string }
74+ if err := json.Unmarshal(tc.Arguments, &args); err != nil {
75+ t.Fatalf("arguments %q not valid JSON: %v", tc.Arguments, err)
76+ }
77+ if args.Old != "\tx" {
78+ t.Fatalf("old = %q, want tab preserved", args.Old)
79+ }
80+}
@@ -0,0 +1,80 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestNormalizeToolArgs(t *testing.T) {
13+ tab, nl, bell := string(rune(9)), string(rune(10)), string(rune(7))
14+ tests := []struct {
15+ name string
16+ in string
17+ want string
18+ }{
19+ {name: "empty", in: "", want: `{}`},
20+ {name: "valid kept verbatim", in: `{"a":"b\tc"}`, want: `{"a":"b\tc"}`},
21+ {name: "raw tab in string", in: `{"old":"` + tab + `x"}`, want: `{"old":"\tx"}`},
22+ {name: "raw newline in string", in: `{"s":"a` + nl + `b"}`, want: `{"s":"a\nb"}`},
23+ {name: "other control char", in: `{"s":"` + bell + `"}`, want: `{"s":"\u0007"}`},
24+ {name: "whitespace outside strings untouched", in: "{" + nl + tab + `"s":"` + tab + `"}`, want: "{" + nl + tab + `"s":"\t"}`},
25+ {name: "escaped quote keeps string state", in: `{"s":"\"` + tab + `"}`, want: `{"s":"\"\t"}`},
26+ {name: "unrepairable", in: `{"s":`, want: `{}`},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ got := normalizeToolArgs(tt.in)
31+ if string(got) != tt.want {
32+ t.Fatalf("normalizeToolArgs(%q) = %q, want %q", tt.in, got, tt.want)
33+ }
34+ if !json.Valid(got) {
35+ t.Fatalf("result %q is not valid JSON", got)
36+ }
37+ })
38+ }
39+}
40+
41+func TestNormalizedToolArgsKeepMessageSerializable(t *testing.T) {
42+ msg := Message{Role: RoleAssistant, Content: []Content{ToolCallBlock{
43+ ID: "call-1", Name: "edit", Arguments: normalizeToolArgs(`{"old":"` + string(rune(9)) + `x"}`),
44+ }}}
45+ if _, err := json.Marshal(msg); err != nil {
46+ t.Fatalf("marshal message: %v", err)
47+ }
48+}
49+
50+func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
51+ // partial_json is a JSON string carrying the model's raw argument text;
52+ // "\\t" below decodes to a real tab byte inside the argument string.
53+ body := "event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call-1\",\"name\":\"edit\",\"input\":{}}}\n\n" +
54+ "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"old\\\":\\\"\\tx\\\"}\"}}\n\n" +
55+ "event: content_block_stop\ndata: {\"index\":0}\n\n" +
56+ "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n"
57+ resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
58+ out := make(chan Event, 16)
59+ go (&anthropicClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
60+ var done EventDone
61+ for ev := range out {
62+ if d, ok := ev.(EventDone); ok {
63+ done = d
64+ }
65+ }
66+ if len(done.Message.Content) != 1 {
67+ t.Fatalf("content = %#v, want one tool call", done.Message.Content)
68+ }
69+ tc, ok := done.Message.Content[0].(ToolCallBlock)
70+ if !ok {
71+ t.Fatalf("content[0] = %T, want ToolCallBlock", done.Message.Content[0])
72+ }
73+ var args struct{ Old string }
74+ if err := json.Unmarshal(tc.Arguments, &args); err != nil {
75+ t.Fatalf("arguments %q not valid JSON: %v", tc.Arguments, err)
76+ }
77+ if args.Old != "\tx" {
78+ t.Fatalf("old = %q, want tab preserved", args.Old)
79+ }
80+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close