fix: remove bufio.Scanner 2MiB line limit for SSE; support quasi_status direct patch

Replace bufio.Scanner with bufio.NewReaderSize + ReadBytes('\n') across all
SSE read paths to preserve long single-line data (e.g. write_file content).
Add quasi_status and auto_continue handling as direct path-based patches in
both Go continue observer and Node vercel_stream_impl, mirroring existing
batch-patch logic. Add 2MiB+ line throughput tests at every SSE layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
CJACK
2026-05-01 15:45:17 +08:00
parent fd0ec29991
commit 0a6ef8e3f2
11 changed files with 301 additions and 76 deletions

View File

@@ -133,33 +133,51 @@ func pumpAutoContinue(ctx context.Context, pw *io.PipeWriter, initial io.ReadClo
// sentinels are consumed (not forwarded) so that the downstream only sees
// one final [DONE] at the very end.
func streamBodyWithContinueState(ctx context.Context, pw *io.PipeWriter, body io.Reader, state *continueState) (bool, error) {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
reader := bufio.NewReaderSize(body, 64*1024)
hadDone := false
for scanner.Scan() {
for {
select {
case <-ctx.Done():
return hadDone, ctx.Err()
default:
}
line := append([]byte{}, scanner.Bytes()...)
trimmed := strings.TrimSpace(string(line))
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if data == "[DONE]" {
hadDone = true
continue
line, err := reader.ReadBytes('\n')
if len(line) == 0 && err != nil {
if err == io.EOF {
return hadDone, nil
}
state.observe(data)
return hadDone, err
}
if _, err := io.Copy(pw, bytes.NewReader(append(line, '\n'))); err != nil {
trimmed := strings.TrimSpace(string(line))
if trimmed != "" {
if strings.HasPrefix(trimmed, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if data == "[DONE]" {
hadDone = true
if err != nil && err != io.EOF {
return hadDone, err
}
if err == io.EOF {
return hadDone, nil
}
continue
}
state.observe(data)
}
if !strings.HasSuffix(string(line), "\n") {
line = append(line, '\n')
}
if _, copyErr := io.Copy(pw, bytes.NewReader(line)); copyErr != nil {
return hadDone, copyErr
}
}
if err != nil {
if err == io.EOF {
return hadDone, nil
}
return hadDone, err
}
}
return hadDone, scanner.Err()
}
// observe extracts continue-relevant signals from an SSE JSON chunk.
@@ -175,34 +193,48 @@ func (s *continueState) observe(data string) {
if id := intFrom(chunk["response_message_id"]); id > 0 {
s.responseMessageID = id
}
// Path-based status: {"p": "response/status", "v": "FINISHED"}
if p, _ := chunk["p"].(string); p == "response/status" {
s.setStatus(asString(chunk["v"]))
}
s.observeDirectPatch(asString(chunk["p"]), chunk["v"])
if p, _ := chunk["p"].(string); p == "response" {
s.observeBatchPatches("response", chunk["v"])
} else {
s.observeBatchPatches("", chunk["v"])
}
// Nested v.response
v, _ := chunk["v"].(map[string]any)
if response, _ := v["response"].(map[string]any); response != nil {
if id := intFrom(response["message_id"]); id > 0 {
s.responseMessageID = id
}
s.setStatus(asString(response["status"]))
if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue {
if v, _ := chunk["v"].(map[string]any); v != nil {
s.observeResponseObject(v["response"])
}
if message, _ := chunk["message"].(map[string]any); message != nil {
s.observeResponseObject(message["response"])
}
}
func (s *continueState) observeDirectPatch(path string, value any) {
if s == nil {
return
}
switch strings.Trim(strings.TrimSpace(path), "/") {
case "response/status", "status", "response/quasi_status", "quasi_status":
s.setStatus(asString(value))
case "response/auto_continue", "auto_continue":
if v, ok := value.(bool); ok && v {
s.lastStatus = "AUTO_CONTINUE"
}
}
// Nested message.response
if message, _ := chunk["message"].(map[string]any); message != nil {
if response, _ := message["response"].(map[string]any); response != nil {
if id := intFrom(response["message_id"]); id > 0 {
s.responseMessageID = id
}
s.setStatus(asString(response["status"]))
}
}
func (s *continueState) observeResponseObject(raw any) {
if s == nil {
return
}
response, _ := raw.(map[string]any)
if response == nil {
return
}
if id := intFrom(response["message_id"]); id > 0 {
s.responseMessageID = id
}
s.setStatus(asString(response["status"]))
if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue {
s.lastStatus = "AUTO_CONTINUE"
}
}
@@ -230,6 +262,10 @@ func (s *continueState) observeBatchPatches(parentPath string, raw any) {
switch strings.Trim(strings.TrimSpace(fullPath), "/") {
case "response/status", "status", "response/quasi_status", "quasi_status":
s.setStatus(asString(m["v"]))
case "response/auto_continue", "auto_continue":
if v, ok := m["v"].(bool); ok && v {
s.lastStatus = "AUTO_CONTINUE"
}
}
}
}

View File

@@ -150,6 +150,62 @@ func TestAutoContinueDoesNotTriggerOnPlainWIPWithoutExplicitContinuationSignal(t
}
}
func TestAutoContinuePassesThroughLongSingleSSELine(t *testing.T) {
payload := strings.Repeat("x", 2*1024*1024+4096)
initialBody := `data: {"p":"response/content","v":"` + payload + `"}` + "\n" +
`data: [DONE]` + "\n"
body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) {
return nil, errors.New("continue should not have been called")
})
defer func() { _ = body.Close() }()
out, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body failed: %v", err)
}
if !bytes.Contains(out, []byte(payload)) {
t.Fatalf("expected long SSE payload to pass through, got len=%d want payload len=%d", len(out), len(payload))
}
if !bytes.Contains(out, []byte(`data: [DONE]`)) {
t.Fatalf("expected final DONE sentinel in body, got len=%d", len(out))
}
}
func TestAutoContinueTriggersOnDirectQuasiStatusIncomplete(t *testing.T) {
initialBody := strings.Join([]string{
`data: {"response_message_id":321,"p":"response/content","v":"<tool_calls><invoke name=\"write_file\"><parameter name=\"content\"><![CDATA[part-one"}`,
`data: {"p":"response/quasi_status","v":"INCOMPLETE"}`,
`data: [DONE]`,
}, "\n") + "\n"
var continueCalls atomic.Int32
body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) {
continueCalls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(
`data: {"response_message_id":322,"p":"response/content","v":"-part-two]]></parameter></invoke></tool_calls>"}` + "\n" +
`data: {"p":"response/status","v":"FINISHED"}` + "\n" +
`data: [DONE]` + "\n",
)),
}, nil
})
defer func() { _ = body.Close() }()
out, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body failed: %v", err)
}
if continueCalls.Load() != 1 {
t.Fatalf("expected exactly one continue call, got %d", continueCalls.Load())
}
if !bytes.Contains(out, []byte("part-one")) || !bytes.Contains(out, []byte("-part-two")) {
t.Fatalf("expected continued tool content in body, got=%s", string(out))
}
}
func TestAutoContinueTriggersOnResponseBatchQuasiStatusIncomplete(t *testing.T) {
initialBody := strings.Join([]string{
`data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":false}}}`,

View File

@@ -2,20 +2,24 @@ package protocol
import (
"bufio"
"io"
"net/http"
)
func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error {
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 2*1024*1024)
for scanner.Scan() {
if !onLine(scanner.Bytes()) {
break
reader := bufio.NewReaderSize(resp.Body, 64*1024)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
if !onLine(line) {
return nil
}
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,26 @@
package protocol
import (
"io"
"net/http"
"strings"
"testing"
)
func TestScanSSELinesHandlesLongSingleLine(t *testing.T) {
payload := strings.Repeat("x", 2*1024*1024+4096)
body := "data: {\"p\":\"response/content\",\"v\":\"" + payload + "\"}\n"
resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
var got string
err := ScanSSELines(resp, func(line []byte) bool {
got = string(line)
return true
})
if err != nil {
t.Fatalf("ScanSSELines returned error: %v", err)
}
if !strings.Contains(got, payload) {
t.Fatalf("long SSE line was not preserved: got len=%d want payload len=%d", len(got), len(payload))
}
}

View File

@@ -516,32 +516,51 @@ function observeContinueState(state, chunk) {
if (topID > 0) {
state.responseMessageID = topID;
}
if (chunk.p === 'response/status') {
setContinueStatus(state, asString(chunk.v));
}
observeContinueDirectPatch(state, chunk.p, chunk.v);
if (chunk.p === 'response') {
observeContinueBatchPatches(state, 'response', chunk.v);
} else {
observeContinueBatchPatches(state, '', chunk.v);
}
const response = chunk.v && typeof chunk.v === 'object' ? chunk.v.response : null;
if (response && typeof response === 'object') {
const id = numberValue(response.message_id);
if (id > 0) {
state.responseMessageID = id;
}
setContinueStatus(state, asString(response.status));
if (response.auto_continue === true) {
state.lastStatus = 'AUTO_CONTINUE';
}
}
observeContinueResponseObject(state, response);
const messageResponse = chunk.message && typeof chunk.message === 'object' && chunk.message.response;
if (messageResponse && typeof messageResponse === 'object') {
const id = numberValue(messageResponse.message_id);
if (id > 0) {
state.responseMessageID = id;
}
setContinueStatus(state, asString(messageResponse.status));
observeContinueResponseObject(state, messageResponse);
}
function observeContinueDirectPatch(state, path, value) {
if (!state) {
return;
}
switch (asString(path).trim().replace(/^\/+|\/+$/g, '')) {
case 'response/status':
case 'status':
case 'response/quasi_status':
case 'quasi_status':
setContinueStatus(state, asString(value));
break;
case 'response/auto_continue':
case 'auto_continue':
if (value === true) {
state.lastStatus = 'AUTO_CONTINUE';
}
break;
default:
break;
}
}
function observeContinueResponseObject(state, response) {
if (!state || !response || typeof response !== 'object') {
return;
}
const id = numberValue(response.message_id);
if (id > 0) {
state.responseMessageID = id;
}
setContinueStatus(state, asString(response.status));
if (response.auto_continue === true) {
state.lastStatus = 'AUTO_CONTINUE';
}
}
@@ -569,6 +588,12 @@ function observeContinueBatchPatches(state, parentPath, raw) {
case 'quasi_status':
setContinueStatus(state, asString(patch.v));
break;
case 'response/auto_continue':
case 'auto_continue':
if (patch.v === true) {
state.lastStatus = 'AUTO_CONTINUE';
}
break;
default:
break;
}

View File

@@ -41,6 +41,15 @@ func TestCollectStreamTextOnly(t *testing.T) {
}
}
func TestCollectStreamHandlesLongSingleSSELine(t *testing.T) {
payload := strings.Repeat("x", 2*1024*1024+4096)
resp := makeHTTPResponse(makeLargeContentSSEBody(t, payload))
result := CollectStream(resp, false, true)
if result.Text != payload {
t.Fatalf("long SSE line payload mismatch: got len=%d want len=%d", len(result.Text), len(payload))
}
}
func TestCollectStreamThinkingAndText(t *testing.T) {
resp := makeHTTPResponse(
"data: {\"p\":\"response/thinking_content\",\"v\":\"Thinking...\"}\n" +

View File

@@ -9,8 +9,7 @@ import (
const (
parsedLineBufferSize = 128
scannerBufferSize = 64 * 1024
maxScannerLineSize = 2 * 1024 * 1024
lineReaderBufferSize = 64 * 1024
minFlushChars = 160
maxFlushWait = 80 * time.Millisecond
)
@@ -29,8 +28,8 @@ func StartParsedLinePump(ctx context.Context, body io.Reader, thinkingEnabled bo
eof bool
}
lineCh := make(chan scanItem, 1)
stopScanner := make(chan struct{})
defer close(stopScanner)
stopReader := make(chan struct{})
defer close(stopReader)
go func() {
sendScanItem := func(item scanItem) bool {
select {
@@ -38,20 +37,28 @@ func StartParsedLinePump(ctx context.Context, body io.Reader, thinkingEnabled bo
return true
case <-ctx.Done():
return false
case <-stopScanner:
case <-stopReader:
return false
}
}
defer close(lineCh)
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 0, scannerBufferSize), maxScannerLineSize)
for scanner.Scan() {
line := append([]byte{}, scanner.Bytes()...)
if !sendScanItem(scanItem{line: line}) {
reader := bufio.NewReaderSize(body, lineReaderBufferSize)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
line = append([]byte{}, line...)
if !sendScanItem(scanItem{line: line}) {
return
}
}
if err != nil {
if err == io.EOF {
err = nil
}
_ = sendScanItem(scanItem{err: err, eof: true})
return
}
}
_ = sendScanItem(scanItem{err: scanner.Err(), eof: true})
}()
ticker := time.NewTicker(maxFlushWait)

View File

@@ -2,10 +2,23 @@ package sse
import (
"context"
"encoding/json"
"strings"
"testing"
)
func makeLargeContentSSEBody(t *testing.T, payload string) string {
t.Helper()
line, err := json.Marshal(map[string]any{
"p": "response/content",
"v": payload,
})
if err != nil {
t.Fatalf("marshal SSE line failed: %v", err)
}
return "data: " + string(line) + "\n" + "data: [DONE]\n"
}
func TestStartParsedLinePumpParsesAndStops(t *testing.T) {
body := strings.NewReader("data: {\"p\":\"response/content\",\"v\":\"hi\"}\n\ndata: [DONE]\n")
results, done := StartParsedLinePump(context.Background(), body, false, "text")
@@ -28,3 +41,28 @@ func TestStartParsedLinePumpParsesAndStops(t *testing.T) {
t.Fatalf("expected last line to stop stream, got parsed=%v stop=%v", last.Parsed, last.Stop)
}
}
func TestStartParsedLinePumpHandlesLongSingleSSELine(t *testing.T) {
payload := strings.Repeat("x", 2*1024*1024+4096)
results, done := StartParsedLinePump(context.Background(), strings.NewReader(makeLargeContentSSEBody(t, payload)), false, "text")
var got strings.Builder
var sawDone bool
for r := range results {
for _, p := range r.Parts {
got.WriteString(p.Text)
}
if r.Stop {
sawDone = true
}
}
if err := <-done; err != nil {
t.Fatalf("unexpected long-line read error: %v", err)
}
if got.String() != payload {
t.Fatalf("long SSE line payload mismatch: got len=%d want len=%d", got.Len(), len(payload))
}
if !sawDone {
t.Fatal("expected DONE after long SSE line")
}
}