detached 145189e

test(provider): cover Bedrock and scanner edge cases for tool argument repair

commit
145189ecc9773205fd6d04781126176d389a0312
author
brice · · 2026-10-02 04:46 UTC
committer
brice · · 2026-10-02 07:44 UTC
parent
3aa968f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +37 -11
M packages/provider/tool_args_test.go +37 -11
@@ -1,7 +1,9 @@
11 package provider
22
33 import (
4+ "bytes"
45 "context"
6+ "encoding/binary"
57 "encoding/json"
68 "io"
79 "net/http"
@@ -23,6 +25,8 @@ func TestNormalizeToolArgs(t *testing.T) {
2325 {name: "other control char", in: `{"s":"` + bell + `"}`, want: `{"s":"\u0007"}`},
2426 {name: "whitespace outside strings untouched", in: "{" + nl + tab + `"s":"` + tab + `"}`, want: "{" + nl + tab + `"s":"\t"}`},
2527 {name: "escaped quote keeps string state", in: `{"s":"\"` + tab + `"}`, want: `{"s":"\"\t"}`},
28+ {name: "escaped backslash closes string", in: `{"a":"x\\"` + tab + `}`, want: `{"a":"x\\"` + tab + `}`},
29+ {name: "multibyte utf-8 kept", in: `{"a":"é` + tab + `"}`, want: `{"a":"é\t"}`},
2630 {name: "unrepairable", in: `{"s":`, want: `{}`},
2731 }
2832 for _, tt := range tests {
@@ -38,15 +42,6 @@ func TestNormalizeToolArgs(t *testing.T) {
3842 }
3943 }
4044
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-
5045 func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
5146 // partial_json is a JSON string carrying the model's raw argument text;
5247 // "\\t" below decodes to a real tab byte inside the argument string.
@@ -57,12 +52,43 @@ func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
5752 resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
5853 out := make(chan Event, 16)
5954 go (&anthropicClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
55+ assertToolArgOld(t, out, "\tx")
56+}
57+
58+func TestBedrockStreamRepairsRawTabInToolArgs(t *testing.T) {
59+ // Header-less event-stream frames; the reader takes the event type from
60+ // the wrapped payload and does not validate CRCs. The `\t` escape in the input
61+ // field decodes to a real tab byte inside the argument string.
62+ var body bytes.Buffer
63+ for _, payload := range []string{
64+ `{"contentBlockStart":{"contentBlockIndex":0,"start":{"toolUse":{"toolUseId":"call-1","name":"edit"}}}}`,
65+ `{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"toolUse":{"input":"{\"old\":\"\tx\"}"}}}}`,
66+ `{"contentBlockStop":{"contentBlockIndex":0}}`,
67+ `{"messageStop":{"stopReason":"tool_use"}}`,
68+ } {
69+ var prelude [12]byte
70+ binary.BigEndian.PutUint32(prelude[0:4], uint32(16+len(payload)))
71+ body.Write(prelude[:])
72+ body.WriteString(payload)
73+ body.Write(make([]byte, 4))
74+ }
75+ resp := &http.Response{Body: io.NopCloser(&body)}
76+ out := make(chan Event, 16)
77+ go (&bedrockClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
78+ assertToolArgOld(t, out, "\tx")
79+}
80+
81+func assertToolArgOld(t *testing.T, out <-chan Event, want string) {
82+ t.Helper()
6083 var done EventDone
6184 for ev := range out {
6285 if d, ok := ev.(EventDone); ok {
6386 done = d
6487 }
6588 }
89+ if done.Err != nil {
90+ t.Fatalf("stream error: %v", done.Err)
91+ }
6692 if len(done.Message.Content) != 1 {
6793 t.Fatalf("content = %#v, want one tool call", done.Message.Content)
6894 }
@@ -74,7 +100,7 @@ func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
74100 if err := json.Unmarshal(tc.Arguments, &args); err != nil {
75101 t.Fatalf("arguments %q not valid JSON: %v", tc.Arguments, err)
76102 }
77- if args.Old != "\tx" {
78- t.Fatalf("old = %q, want tab preserved", args.Old)
103+ if args.Old != want {
104+ t.Fatalf("old = %q, want %q", args.Old, want)
79105 }
80106 }
@@ -1,7 +1,9 @@
1 package provider1 package provider
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "encoding/binary"
5 "encoding/json"7 "encoding/json"
6 "io"8 "io"
7 "net/http"9 "net/http"
@@ -23,6 +25,8 @@ func TestNormalizeToolArgs(t *testing.T) {
23 {name: "other control char", in: `{"s":"` + bell + `"}`, want: `{"s":"\u0007"}`},25 {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"}`},26 {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"}`},27 {name: "escaped quote keeps string state", in: `{"s":"\"` + tab + `"}`, want: `{"s":"\"\t"}`},
28+ {name: "escaped backslash closes string", in: `{"a":"x\\"` + tab + `}`, want: `{"a":"x\\"` + tab + `}`},
29+ {name: "multibyte utf-8 kept", in: `{"a":"é` + tab + `"}`, want: `{"a":"é\t"}`},
26 {name: "unrepairable", in: `{"s":`, want: `{}`},30 {name: "unrepairable", in: `{"s":`, want: `{}`},
27 }31 }
28 for _, tt := range tests {32 for _, tt := range tests {
@@ -38,15 +42,6 @@ func TestNormalizeToolArgs(t *testing.T) {
38 }42 }
39 }43 }
40 44
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) {45 func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
51 // partial_json is a JSON string carrying the model's raw argument text;46 // 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.47 // "\\t" below decodes to a real tab byte inside the argument string.
@@ -57,12 +52,43 @@ func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
57 resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}52 resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
58 out := make(chan Event, 16)53 out := make(chan Event, 16)
59 go (&anthropicClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)54 go (&anthropicClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
55+ assertToolArgOld(t, out, "\tx")
56+}
57+
58+func TestBedrockStreamRepairsRawTabInToolArgs(t *testing.T) {
59+ // Header-less event-stream frames; the reader takes the event type from
60+ // the wrapped payload and does not validate CRCs. The `\t` escape in the input
61+ // field decodes to a real tab byte inside the argument string.
62+ var body bytes.Buffer
63+ for _, payload := range []string{
64+ `{"contentBlockStart":{"contentBlockIndex":0,"start":{"toolUse":{"toolUseId":"call-1","name":"edit"}}}}`,
65+ `{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"toolUse":{"input":"{\"old\":\"\tx\"}"}}}}`,
66+ `{"contentBlockStop":{"contentBlockIndex":0}}`,
67+ `{"messageStop":{"stopReason":"tool_use"}}`,
68+ } {
69+ var prelude [12]byte
70+ binary.BigEndian.PutUint32(prelude[0:4], uint32(16+len(payload)))
71+ body.Write(prelude[:])
72+ body.WriteString(payload)
73+ body.Write(make([]byte, 4))
74+ }
75+ resp := &http.Response{Body: io.NopCloser(&body)}
76+ out := make(chan Event, 16)
77+ go (&bedrockClient{}).runStream(context.Background(), resp, Request{Model: "test"}, out)
78+ assertToolArgOld(t, out, "\tx")
79+}
80+
81+func assertToolArgOld(t *testing.T, out <-chan Event, want string) {
82+ t.Helper()
60 var done EventDone83 var done EventDone
61 for ev := range out {84 for ev := range out {
62 if d, ok := ev.(EventDone); ok {85 if d, ok := ev.(EventDone); ok {
63 done = d86 done = d
64 }87 }
65 }88 }
89+ if done.Err != nil {
90+ t.Fatalf("stream error: %v", done.Err)
91+ }
66 if len(done.Message.Content) != 1 {92 if len(done.Message.Content) != 1 {
67 t.Fatalf("content = %#v, want one tool call", done.Message.Content)93 t.Fatalf("content = %#v, want one tool call", done.Message.Content)
68 }94 }
@@ -74,7 +100,7 @@ func TestAnthropicStreamRepairsRawTabInToolArgs(t *testing.T) {
74 if err := json.Unmarshal(tc.Arguments, &args); err != nil {100 if err := json.Unmarshal(tc.Arguments, &args); err != nil {
75 t.Fatalf("arguments %q not valid JSON: %v", tc.Arguments, err)101 t.Fatalf("arguments %q not valid JSON: %v", tc.Arguments, err)
76 }102 }
77- if args.Old != "\tx" {103+ if args.Old != want {
78- t.Fatalf("old = %q, want tab preserved", args.Old)104+ t.Fatalf("old = %q, want %q", args.Old, want)
79 }105 }
80 }106 }

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