|
| @@ -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 EventDone | 83 | | 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 = d | 86 | | 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 | | } |