Files

417 lines
13 KiB
Go

package openai
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
)
func TestToolCallTruncated(t *testing.T) {
valid := `{"tool_calls": [{"id": "a", "type": "function", "function": {"name": "write", "arguments": "{\"filePath\": \"x\"}"}}]}`
truncated := valid[:40]
prose := "the model mentions tool_calls but writes no JSON at all"
fenced := "```json\n" + truncated
cases := []struct {
in string
want bool
}{
{valid, false},
{truncated, true},
{prose, false},
{fenced, true},
{"", false},
{`{"other": true}`, false},
// prose before the payload: mirrored from TryParseToolCalls
{"我先说明一下:\n\n" + valid, false},
{"我先说明一下:\n\n" + truncated, true},
{"我先说明一下:\n\n" + fenced, true},
}
for _, c := range cases {
if got := ToolCallTruncated(c.in); got != c.want {
t.Errorf("ToolCallTruncated(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestEscapeControlChars(t *testing.T) {
cases := []struct{ in, want string }{
{`{"a": "line1` + "\n" + `line2"}`, `{"a": "line1\nline2"}`},
{`{"a": "tab` + "\t" + `x"}`, `{"a": "tab\tx"}`},
// Escaped sequences inside strings stay as-is.
{`{"a": "already\nescaped"}`, `{"a": "already\nescaped"}`},
// Pretty-printed structure newlines outside strings survive.
{"{\n \"a\": \"x\"\n}", "{\n \"a\": \"x\"\n}"},
// Real newline inside a second string while first had escapes.
{`{"a": "x\ny", "b": "p` + "\n" + `q"}`, `{"a": "x\ny", "b": "p\nq"}`},
// Other control characters get \u-escaped.
{`{"a": "` + string(rune(0x01)) + `"}`, `{"a": "\u0001"}`},
}
for _, c := range cases {
if got := EscapeControlChars(c.in); got != c.want {
t.Errorf("EscapeControlChars(%q) = %q, want %q", c.in, got, c.want)
}
}
// Round-trip: repaired text must be valid JSON with the same value.
raw := "{\"a\": \"line1\nline2\"}"
fixed := EscapeControlChars(raw)
var v struct {
A string `json:"a"`
}
if err := json.Unmarshal([]byte(fixed), &v); err != nil {
t.Fatalf("repaired JSON invalid: %v", err)
}
if v.A != "line1\nline2" {
t.Fatalf("repaired value = %q", v.A)
}
}
func TestEscapeControlCharsDoublesInvalidEscapes(t *testing.T) {
cases := []struct{ in, want string }{
// \q is not a JSON escape — the backslash must be doubled
{`{"a": "x\qy"}`, `{"a": "x\\qy"}`},
// valid pairs survive, invalid ones heal
{`{"a": "ok\nstill\q"}`, `{"a": "ok\nstill\\q"}`},
// dangling escape at the end is carried, not doubled
{`{"a": "dangling\`, `{"a": "dangling\`},
// no backslash at all: untouched
{`{"a": "plain"}`, `{"a": "plain"}`},
}
for _, c := range cases {
if got := EscapeControlChars(c.in); got != c.want {
t.Errorf("EscapeControlChars(%q) = %q, want %q", c.in, got, c.want)
}
}
if !json.Valid([]byte(EscapeControlChars(`{"a": "x\qy"}`))) {
t.Fatal("healed text is not valid JSON")
}
}
// TestInnerEscapeMatchesParseHeal pins the contract that keeps streamed tool
// arguments identical to the final repaired parse.
func TestInnerEscapeMatchesParseHeal(t *testing.T) {
inner := `{"content": "a\qb` + "\n" + `c\qd"}`
streamed := innerEscape(inner)
healed := EscapeControlChars(inner)
if streamed != healed {
t.Fatalf("stream/parse heal mismatch:\n stream=%q\n parse =%q", streamed, healed)
}
if !json.Valid([]byte(healed)) {
t.Fatalf("healed inner invalid: %q", healed)
}
}
func TestTrimOverlap(t *testing.T) {
cases := []struct{ acc, head, want string }{
{"...fillStyle='#F4", "F4E2B8';draw()", "E2B8';draw()"},
{"...fillStyle='#F4", "E2B8';draw()", "E2B8';draw()"},
{"abcdef", "xyz", "xyz"},
{"abcabcabc", "abcabcTAIL", "TAIL"},
{"", "anything", "anything"},
}
for _, c := range cases {
if got := TrimOverlap(c.acc, c.head); got != c.want {
t.Errorf("TrimOverlap(%q, %q) = %q, want %q", c.acc, c.head, got, c.want)
}
}
}
func TestEscapeControlsAfterCarriesPrefixState(t *testing.T) {
prefix := `{"tool_calls": [{"function": {"arguments": "{\"content\": \"abc`
head := "def\n" + `ghi\""}}]}`
got := EscapeControlsAfter(prefix, head)
want := `def\nghi\""}}]}`
if got != want {
t.Fatalf("EscapeControlsAfter = %q, want %q", got, want)
}
// Without prefix context the same head starts outside a string, so the
// newline is structure whitespace and must survive.
if plain := EscapeControlChars(head); plain != head {
t.Fatalf("EscapeControlChars = %q, want unchanged", plain)
}
full := prefix + got
if !json.Valid([]byte(full)) {
t.Fatalf("repaired document invalid: %s", full)
}
}
func TestContinueInstructionMentionsTail(t *testing.T) {
partial := strings.Repeat("x", 200) + "TAIL80CHARS"
got := ContinueInstruction(partial, 3, 12)
if !strings.Contains(got, "3/12") {
t.Errorf("instruction missing round progress: %s", got)
}
if !strings.Contains(got, "TAIL80CHARS") {
t.Errorf("instruction missing tail: %s", got)
}
if strings.Contains(got, strings.Repeat("x", 100)) {
t.Errorf("instruction embedded too much of the partial")
}
if !strings.Contains(got, `\n`) {
t.Errorf("instruction missing escape-sequence rule")
}
}
func TestBalanceJSON(t *testing.T) {
cases := []struct{ name, in, want string }{
{"unterminated content", `{"filePath": "x.html", "content": "<html></html>`, `{"filePath": "x.html", "content": "<html></html>"}`},
{"trailing comma", `{"a": "b",`, `{"a": "b"}`},
{"trailing colon", `{"a":`, `{"a": ""}`},
{"dangling escape", `{"a": "b\`, `{"a": "b"}`},
{"nested array", `{"a": [1, 2`, `{"a": [1, 2]}`},
{"already valid", `{"a": "b"}`, `{"a": "b"}`},
{"comma inside string survives", `{"a": "b,c`, `{"a": "b,c"` + `}`},
}
for _, c := range cases {
if got := balanceJSON(c.in); got != c.want {
t.Errorf("%s: balanceJSON(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
// Unfixable damage passes through untouched.
broken := `{"a": 12.}`
if got := balanceJSON(broken); got != broken {
t.Errorf("unfixable: got %q", got)
}
}
func TestTryParseToolCallsHealsBrokenInnerJSON(t *testing.T) {
text := `{"tool_calls": [{"id": "a", "type": "function", "function": {"name": "write", "arguments": "{\"content\": \"<html></html>"}}]}`
_, calls, ok := TryParseToolCalls(text)
if !ok {
t.Fatal("expected healed tool call")
}
var inner struct {
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(calls[0].Function.Arguments), &inner); err != nil {
t.Fatalf("healed arguments invalid: %v", err)
}
if inner.Content != "<html></html>" {
t.Fatalf("content = %q", inner.Content)
}
}
func TestTryParseToolCallsRejectsUnfixableInnerJSON(t *testing.T) {
text := `{"tool_calls": [{"id": "a", "type": "function", "function": {"name": "write", "arguments": "{\"content\": 12.}"}}]}`
_, calls, ok := TryParseToolCalls(text)
if ok {
t.Fatalf("expected rejection, got calls=%v", calls)
}
}
func TestRunContinuedStitches(t *testing.T) {
turns := []string{
`{"tool_calls": [{"id": "a", "function": {"name": "write", "arguments": "{\"content\": \"part1`,
// Continuation repeats one char, emits a raw newline mid-string, then
// closes the inner JSON, the arguments string and the outer document.
"1\n" + `part2\"}"}}]}`,
}
prompts := 0
var got strings.Builder
err := RunContinued(context.Background(),
"first-prompt",
func(partial string, _ int) string {
if !strings.Contains(partial, "part1") {
t.Errorf("cont prompt built from unexpected partial: %s", partial)
}
return "continue-prompt"
},
3,
func(_ context.Context, prompt string, emit func(string) error) error {
prompts++
idx := prompts - 1
if idx >= len(turns) {
t.Fatalf("unexpected extra send #%d", prompts)
}
if idx > 0 && prompt != "continue-prompt" {
t.Errorf("round %d prompt = %q", idx, prompt)
}
return emit(turns[idx])
},
func(text string) error { got.WriteString(text); return nil },
func(string, ...any) {},
func(string, ...any) {},
)
if err != nil {
t.Fatalf("RunContinued: %v", err)
}
if prompts != 2 {
t.Fatalf("prompts = %d, want 2", prompts)
}
want := `{"tool_calls": [{"id": "a", "function": {"name": "write", "arguments": "{\"content\": \"part1\npart2\"}"}}]}`
if got.String() != want {
t.Fatalf("stitched = %q\nwant %q", got.String(), want)
}
if ToolCallTruncated(got.String()) {
t.Fatal("stitched reply still truncated")
}
_, calls, ok := TryParseToolCalls(got.String())
if !ok {
t.Fatalf("TryParseToolCalls failed on stitched reply")
}
var inner struct {
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(calls[0].Function.Arguments), &inner); err != nil {
t.Fatalf("arguments JSON invalid: %v", err)
}
if inner.Content != "part1\npart2" {
t.Fatalf("content = %q", inner.Content)
}
}
func TestRunContinuedPassthroughWithoutCont(t *testing.T) {
prompts := 0
var got strings.Builder
err := RunContinued(context.Background(), "p", nil, 6,
func(_ context.Context, prompt string, emit func(string) error) error {
prompts++
return emit(`{"tool_calls": [{"trunc`)
},
func(text string) error { got.WriteString(text); return nil },
func(string, ...any) {}, func(string, ...any) {},
)
if err != nil {
t.Fatalf("RunContinued: %v", err)
}
if prompts != 1 {
t.Fatalf("prompts = %d, want 1", prompts)
}
if !ToolCallTruncated(got.String()) {
t.Fatal("passthrough should keep the truncated reply")
}
}
func TestRunContinuedContinuationFailureKeepsPartial(t *testing.T) {
prompts := 0
warns := 0
var got strings.Builder
err := RunContinued(context.Background(), "first",
func(string, int) string { return "next" }, 3,
func(_ context.Context, _ string, emit func(string) error) error {
prompts++
if prompts == 1 {
return emit(`{"tool_calls": [{"trunc`)
}
return errors.New("boom")
},
func(text string) error { got.WriteString(text); return nil },
func(string, ...any) {},
func(string, ...any) { warns++ },
)
if err != nil {
t.Fatalf("RunContinued returned error despite partial text: %v", err)
}
if warns != 5 {
t.Fatalf("warns = %d, want 5 (3 retry notices + turn failure + exhaustion)", warns)
}
if got.String() != `{"tool_calls": [{"trunc` {
t.Fatalf("got = %q", got.String())
}
}
func TestRunContinuedStopsWhenClosed(t *testing.T) {
prompts := 0
err := RunContinued(context.Background(), "first",
func(string, int) string { return "next" }, 6,
func(_ context.Context, _ string, emit func(string) error) error {
prompts++
return emit(`{"tool_calls": [{"id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}`)
},
func(string) error { return nil },
func(string, ...any) {}, func(string, ...any) {},
)
if err != nil {
t.Fatalf("RunContinued: %v", err)
}
if prompts != 1 {
t.Fatalf("prompts = %d, want 1 (valid JSON must not trigger continuation)", prompts)
}
}
func TestRunContinuedEmitErrorPropagates(t *testing.T) {
sentinel := errors.New("emit failed")
err := RunContinued(context.Background(), "first", nil, 0,
func(_ context.Context, _ string, emit func(string) error) error {
return emit("text")
},
func(string) error { return sentinel },
func(string, ...any) {}, func(string, ...any) {},
)
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
}
func TestTryParseToolCallsShortCloseSequence(t *testing.T) {
inner := `{\"filePath\": \"a.html\", \"content\": \"<html></html>\"}`
raw := `{"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "write", "arguments": "` + inner + `}}]}`
remaining, calls, ok := TryParseToolCalls(raw)
if !ok {
t.Fatal("TryParseToolCalls ok = false, want true (short close sequence must heal)")
}
if remaining != "" {
t.Fatalf("remaining = %q, want empty", remaining)
}
if len(calls) != 1 {
t.Fatalf("calls = %d, want 1", len(calls))
}
args := calls[0].Function.Arguments
if !json.Valid([]byte(args)) {
t.Fatalf("args invalid: %s", args)
}
var obj struct {
FilePath string `json:"filePath"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(args), &obj); err != nil {
t.Fatal(err)
}
if obj.Content != "<html></html>" {
t.Fatalf("content = %q, want html", obj.Content)
}
}
func TestTryParseToolCallsLeakedQuoteCutoff(t *testing.T) {
raw := `{"tool_calls": [{"id": "call_001", "type": "function", "function": {"name": "write", "arguments": "{\"filePath\": \"tihu.html\", \"content\": \"<html><body>canvas scene leak "and then cut`
_, calls, ok := TryParseToolCalls(raw)
if !ok {
t.Fatal("TryParseToolCalls ok = false, want force-closed tool call")
}
args := calls[0].Function.Arguments
if !json.Valid([]byte(args)) {
t.Fatalf("args invalid after force close: %s", args)
}
var inner struct {
FilePath string `json:"filePath"`
Content string `json:"content"`
}
if err := json.Unmarshal([]byte(args), &inner); err != nil {
t.Fatal(err)
}
if inner.FilePath != "tihu.html" {
t.Fatalf("filePath = %q", inner.FilePath)
}
if !strings.Contains(inner.Content, "canvas scene leak") || !strings.Contains(inner.Content, `"and then cut`) {
t.Fatalf("content lost pre-cut text: %q", inner.Content)
}
}
func TestTryParseToolCallsPlainCutoffReturnsPartial(t *testing.T) {
raw := `{"tool_calls": [{"id": "c", "type": "function", "function": {"name": "write", "arguments": "{\"filePath\": \"a.html\", \"content\": \"<html><body><canvas id=\"scene\">stopped mid`
_, calls, ok := TryParseToolCalls(raw)
if !ok {
t.Fatal("TryParseToolCalls ok = false, want partial tool call")
}
args := calls[0].Function.Arguments
if !json.Valid([]byte(args)) {
t.Fatalf("args invalid: %s", args)
}
if !strings.Contains(args, "stopped mid") {
t.Fatalf("args lost tail: %s", args)
}
}