Skip to content

fix(authbridge): Stream text/event-stream responses frame-by-frame#480

Merged
huang195 merged 3 commits into
mainfrom
fix/477-streaming-mcp-responses
Jun 5, 2026
Merged

fix(authbridge): Stream text/event-stream responses frame-by-frame#480
huang195 merged 3 commits into
mainfrom
fix/477-streaming-mcp-responses

Conversation

@kellyaa

@kellyaa kellyaa commented Jun 4, 2026

Copy link
Copy Markdown
Member

Fixes #477.

The forward proxy fully buffered outbound response bodies via io.ReadAll whenever any plugin in the outbound pipeline declared ReadsBody -- which today is every realistic pipeline (mcp-parser, inference-parser, ibac all set ReadsBody=true). For MCP servers using Streamable HTTP, tools/call responses arrive as text/event-stream that may stay open for the full duration of slow tool execution; buffering converted those streams into blocking Client.Do reads bounded by the hard-coded 30s http.Client.Timeout, producing 502s or apparent hangs. The reverse proxy had the same buffering for inbound A2A message/stream responses.

This stream-aware path detects text/event-stream at the response header, then forwards SSE frames to the client and dispatches each JSON-RPC message to plugins as it arrives. application/json keeps the buffered path. Per-frame memory is capped at 1 MiB; total stream length is unbounded.

  • Add an optional pipeline.StreamingResponder interface with OnResponseFrame(ctx, pctx, frame, last). Plumbed via pipeline.Pipeline.RunResponseFrame and pipeline.Holder.
  • Drop the outbound http.Client.Timeout (which covered the body read and broke streaming); replace with Transport.ResponseHeaderTimeout (30s, time-to-headers ceiling) and a per-read idle deadline on streaming bodies.
  • forwardproxy: branch on Content-Type per response. SSE streams with HasStreamingResponders take a flush-per-frame path that invokes OnResponseFrame for each event and a final last=true call; RunResponse is not called on this path. application/json and any other Content-Type keep the existing buffered path, plus a single last=true OnResponseFrame call so streaming-aware plugins use one code path for both shapes. WritesBody=true forces buffered fallback with a warning (a body mutator can't rewrite a body already on the wire).
  • reverseproxy: mirror the same branch in modifyResponse via a streaming response body that pulls one SSE frame per Read, dispatches it to OnResponseFrame, and re-emits the SSE event; ReverseProxy.FlushInterval=-1 ferries each frame to the client immediately. Same WritesBody fallback contract.
  • Convert the aggregating parsers to fold-and-finalize:
    • mcp-parser: per-message recording (one JSON-RPC result per frame); OnResponse stays as a fallback for non-streaming-aware listeners.
    • inference-parser: accumulates content deltas + usage on a private SetState scratch across frames; finalizes on last=true.
    • a2a-parser: folds artifact text and final-status across message/stream events; finalizes on last=true. New extractStreamEvent helper shared by the buffered and streaming paths so one envelope shape is parsed in one place.
  • New authlib/listener/internal/sseframe package: a narrow SSE reader emitting one ([]byte, error) per data-bearing event. Per-frame size cap (default 1 MiB), CRLF tolerance, multi-line-data folding, comment lines silently skipped, trailing unterminated event delivered before EOF.
  • New tests cover: the SSE reader (multi-frame, CRLF, oversize, long lines, only-comments, multi-line data); RunResponseFrame dispatch ordering, off-policy skip, reject-stops-chain, and cancel; per-plugin OnResponseFrame for all three parsers (per-message MCP, fold-and-finalize inference + A2A, application/json one-shot, no-extension no-op, empty-stream skip pairing); end-to-end streaming through the forward and reverse proxies (frames arrive before upstream closes -- the regression check; WritesBody buffered fallback; application/json single last=true frame).

The OnResponseFrame hook leaves room for per-message response-side enforcement later. Today's plugins are observability-only on the response side (IBAC's OnResponse is a no-op; it gates at request time), so the listener forwards-then-dispatches each frame; an inspect-before-forward variant would be a clean follow-up.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary

Related issue(s)

(Optional) Testing Instructions

Fixes #

Summary by CodeRabbit

  • New Features

    • Frame-by-frame SSE streaming: proxy and server now relay Server-Sent Events incrementally and finalize per-stream.
    • Streaming-aware plugin hooks: plugins can process individual response frames and finalize on stream end.
  • Bug Fixes / Reliability

    • Revised timeout/idle-read handling to prevent premature stream termination and protect against stalled upstreams.
    • Buffered-fallback when body-writing plugins are present to preserve compatibility.
  • Tests

    • New integration and unit tests covering streaming, buffered fallbacks, parsers, and edge cases.

Fixes #477.

The forward proxy fully buffered outbound response bodies via
io.ReadAll whenever any plugin in the outbound pipeline declared
ReadsBody -- which today is every realistic pipeline (mcp-parser,
inference-parser, ibac all set ReadsBody=true). For MCP servers using
Streamable HTTP, tools/call responses arrive as text/event-stream
that may stay open for the full duration of slow tool execution;
buffering converted those streams into blocking Client.Do reads
bounded by the hard-coded 30s http.Client.Timeout, producing 502s
or apparent hangs. The reverse proxy had the same buffering for
inbound A2A message/stream responses.

This stream-aware path detects text/event-stream at the response
header, then forwards SSE frames to the client and dispatches each
JSON-RPC message to plugins as it arrives. application/json keeps
the buffered path. Per-frame memory is capped at 1 MiB; total stream
length is unbounded.

- Add an optional pipeline.StreamingResponder interface with
  OnResponseFrame(ctx, pctx, frame, last). Plumbed via
  pipeline.Pipeline.RunResponseFrame and pipeline.Holder.
- Drop the outbound http.Client.Timeout (which covered the body
  read and broke streaming); replace with
  Transport.ResponseHeaderTimeout (30s, time-to-headers ceiling)
  and a per-read idle deadline on streaming bodies.
- forwardproxy: branch on Content-Type per response. SSE streams
  with HasStreamingResponders take a flush-per-frame path that
  invokes OnResponseFrame for each event and a final last=true call;
  RunResponse is not called on this path. application/json and any
  other Content-Type keep the existing buffered path, plus a single
  last=true OnResponseFrame call so streaming-aware plugins use one
  code path for both shapes. WritesBody=true forces buffered fallback
  with a warning (a body mutator can't rewrite a body already on the
  wire).
- reverseproxy: mirror the same branch in modifyResponse via a
  streaming response body that pulls one SSE frame per Read,
  dispatches it to OnResponseFrame, and re-emits the SSE event;
  ReverseProxy.FlushInterval=-1 ferries each frame to the client
  immediately. Same WritesBody fallback contract.
- Convert the aggregating parsers to fold-and-finalize:
  - mcp-parser: per-message recording (one JSON-RPC result per
    frame); OnResponse stays as a fallback for non-streaming-aware
    listeners.
  - inference-parser: accumulates content deltas + usage on a
    private SetState scratch across frames; finalizes on last=true.
  - a2a-parser: folds artifact text and final-status across
    message/stream events; finalizes on last=true. New
    extractStreamEvent helper shared by the buffered and streaming
    paths so one envelope shape is parsed in one place.
- New authlib/listener/internal/sseframe package: a narrow SSE
  reader emitting one ([]byte, error) per data-bearing event.
  Per-frame size cap (default 1 MiB), CRLF tolerance,
  multi-line-data folding, comment lines silently skipped, trailing
  unterminated event delivered before EOF.
- New tests cover: the SSE reader (multi-frame, CRLF, oversize,
  long lines, only-comments, multi-line data); RunResponseFrame
  dispatch ordering, off-policy skip, reject-stops-chain, and
  cancel; per-plugin OnResponseFrame for all three parsers
  (per-message MCP, fold-and-finalize inference + A2A,
  application/json one-shot, no-extension no-op,
  empty-stream skip pairing); end-to-end streaming through the
  forward and reverse proxies (frames arrive before upstream
  closes -- the regression check; WritesBody buffered fallback;
  application/json single last=true frame).

The OnResponseFrame hook leaves room for per-message response-side
enforcement later. Today's plugins are observability-only on the
response side (IBAC's OnResponse is a no-op; it gates at request
time), so the listener forwards-then-dispatches each frame; an
inspect-before-forward variant would be a clean follow-up.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20841667-f44f-454a-8f86-098a501f0ecf

📥 Commits

Reviewing files that changed from the base of the PR and between 2724eda and 2fd8c0f.

📒 Files selected for processing (2)
  • authbridge/authlib/listener/extproc/server.go
  • authbridge/authlib/listener/extproc/server_test.go

📝 Walkthrough

Walkthrough

Adds SSE frame parsing, a StreamingResponder pipeline hook and RunResponseFrame dispatch, streaming-aware forward/reverse proxy paths, and streaming adapters for A2A, Inference, and MCP parsers with tests.

Changes

Streaming Response Support

Layer / File(s) Summary
SSE frame reader and tests
authbridge/authlib/listener/internal/sseframe/reader.go, reader_test.go
Adds sseframe.Reader with per-event data: concatenation, CR/LF/CRLF support, max-frame size, and unit tests for parsing and size limits.
Pipeline: StreamingResponder interface & dispatch
authbridge/authlib/pipeline/plugin.go, pipeline.go, holder.go, streaming_test.go
Adds StreamingResponder and Pipeline.RunResponseFrame, HasStreamingResponders, plus Holder delegates and tests for ordering, last semantics, reject/deny behavior, cancellation, and policy skipping.
Forward proxy streaming path
authbridge/authlib/listener/forwardproxy/server.go, streaming_test.go
Removes request-wide http.Client timeout in favor of transport header deadline and per-read idle timeout; detects text/event-stream and streams frames with RunResponseFrame and flushing, or buffers+replays when WritesBody plugins force buffered path; includes SSE framing and idleReader. Integration tests validate streaming, fallback, buffered last-frame delivery, and regression for early frame delivery.
Reverse proxy streaming path
authbridge/authlib/listener/reverseproxy/server.go, streaming_test.go
Sets FlushInterval=-1, installs streamingResponseBody backed by sseframe.Reader for SSE responses, dispatches frames via RunResponseFrame, reframes as SSE for downstream, finalizes on close, and records inbound session events. Tests cover streaming flow, flushing, and buffered behavior.
ExtProc buffered dispatch
authbridge/authlib/listener/extproc/server.go, server_test.go
Adds header-phase final-frame dispatch and dispatchBufferedFrames to re-parse buffered SSE into frames or dispatch whole body as a terminal frame for streaming responders; tests for buffered JSON and buffered SSE frame dispatch.
A2A & Inference parser streaming adapters
authbridge/authlib/plugins/a2aparser/plugin.go, inferenceparser/plugin.go, streaming_test.go
Add OnResponseFrame to fold streaming frames into extension state, accumulate artifacts/completions, capture context/session IDs as needed, and finalize on last=true. Buffered paths guard against double-finalization. Tests cover folding, error/status extraction, one-shot JSON, session ID handling, token usage, and empty-stream cases.
MCP parser streaming adapter
authbridge/authlib/plugins/mcpparser/plugin.go, streaming_test.go
Adds OnResponseFrame for per-frame JSON-RPC recording, centralizes applyMCPResponseRPC for buffered/streaming paths, caps per-stream observes, and tests per-message recording, error frames, malformed-frame skipping, and empty-stream invocation recording.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • pdettori
  • mrsabath

🐰 I hopped in to parse each stream,
data lines stitched like a dream.
Frames now pass without the stall,
plugins hear each little call.
Flushed and nimble — that’s my beam.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(authbridge): Stream text/event-stream responses frame-by-frame' accurately and clearly describes the main change—implementing frame-by-frame streaming for SSE responses to prevent full buffering of MCP tool-call responses.
Linked Issues check ✅ Passed The PR fully addresses issue #477's objectives: implements streaming detection for text/event-stream, dispatches frames to plugins incrementally, preserves buffered handling for application/json, removes hard timeouts in favor of idle/read deadlines, and maintains WritesBody fallback and IBAC request-time semantics.
Out of Scope Changes check ✅ Passed All changes directly support the streaming response implementation: SSE frame reader, plugin interface extensions (StreamingResponder), parser implementations (mcp/inference/a2a), listener streaming dispatch logic, pipeline routing, and comprehensive tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/477-streaming-mcp-responses

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
authbridge/authlib/listener/forwardproxy/server.go (2)

778-787: 💤 Low value

Minor race between timer firing and timer.Stop().

If Read returns just as the timer fires (narrow window), the body could be closed after a successful read, causing the next Read to fail unexpectedly. The 5-minute timeout makes this extremely unlikely, and the failure mode is a clean error rather than data corruption, so this is low risk. If you want to eliminate it, a sync.Once around the close would ensure idempotency at the cost of slight complexity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/listener/forwardproxy/server.go` around lines 778 - 787,
idleReadCloser.Read has a tiny race where the timer callback can call
i.rc.Close() just after a successful Read, causing the next Read to fail; wrap
the close in a sync.Once to make the timer callback idempotent so Close() is
only executed once. Add a sync.Once field (e.g. closeOnce) to the idleReadCloser
struct and change the timer callback to call closeOnce.Do(func(){ _ =
i.rc.Close() }), leaving the rest of Read (timer creation, stop, return)
unchanged.

791-802: 💤 Low value

Function name is misleading.

indexByteASCIICaseInsensitive performs an exact byte match (as noted in the comment), not a case-insensitive search. Consider renaming to indexByte or indexASCIIByte to avoid confusion, since the "CaseInsensitive" suffix suggests behavior that doesn't apply to the byte parameter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/listener/forwardproxy/server.go` around lines 791 - 802,
The function indexByteASCIICaseInsensitive is misnamed because it performs an
exact byte match; rename it to a clear name like indexByte or indexASCIIByte
(choose one) and update all call sites to use the new name (e.g., replace
indexByteASCIICaseInsensitive(...) with indexByte(...)); also update the
function comment to reflect exact ASCII byte lookup semantics and run the
build/tests to ensure no references remain to the old name.
authbridge/authlib/plugins/a2aparser/plugin.go (1)

331-337: 💤 Low value

Unbounded artifact concatenation could accumulate large strings.

For very long streaming responses with many artifact-update events, ext.Artifact += part.Text accumulates all text parts into one string without bounds. While the SSE frame reader has per-frame limits (1 MiB default per PR summary), a stream could have many frames. Consider whether a truncation or size cap is appropriate for observability purposes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/plugins/a2aparser/plugin.go` around lines 331 - 337, The
ext.Artifact concatenation in the artifact handling branch can grow unbounded:
in the loop over event.Result.Artifact.Parts (checking part.Kind == "text") the
code uses ext.Artifact += part.Text; introduce a fixed cap (e.g., const
maxArtifactSize) and track ext.Artifact length before appending so you only
append up to the remaining allowed bytes, and if you hit the cap append a
sentinel like "…(truncated)" or stop further concatenation; update the loop that
processes event.Result.Artifact.Parts to enforce this cap and ensure subsequent
artifact-update events respect the same limit.
authbridge/authlib/listener/reverseproxy/server.go (1)

648-686: 💤 Low value

Consider stdlib string functions for clarity.

strings.IndexByte, strings.TrimSpace, and strings.EqualFold would reduce code and improve readability. The ASCII-only behavior here matches stdlib for the expected inputs (HTTP header values).

That said, these custom implementations are correct and the duplication with forwardproxy is acceptable to keep the packages decoupled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/listener/reverseproxy/server.go` around lines 648 - 686,
Replace the custom implementations of indexByteASCII, trimASCIISpace, and
equalASCIIFold with calls to the stdlib string helpers: use strings.IndexByte
for indexByteASCII, strings.TrimSpace (or strings.Trim(s, " \t") if you want to
strictly preserve only space and tab) for trimASCIISpace, and strings.EqualFold
for equalASCIIFold; also add the "strings" import and update the three function
bodies to delegate to these stdlib functions (keeping the same function names so
callers in this file stay unchanged).
authbridge/authlib/pipeline/plugin.go (1)

194-202: ⚡ Quick win

Update the StreamingResponder contract comment to match current proxy ordering.

The comment says current listeners forward+flush before OnResponseFrame, but reverse proxy flow invokes RunResponseFrame before emitting the current frame bytes. Please align this contract text to avoid misleading plugin implementers about mid-stream reject behavior.

📝 Suggested doc tweak
-//   - A non-Continue Action returned mid-stream stops further
-//     dispatch for that frame and rejects the response. The current
-//     forwardproxy/reverseproxy implementations forward+flush before
-//     calling OnResponseFrame for record-only observability, so a
-//     mid-stream Reject from a plugin would not un-send already-sent
-//     bytes; today no plugin returns Reject from this hook. The hook
-//     leaves the door open for per-message enforcement to be added
-//     later (the listener would have to inspect-before-forward at
-//     that point).
+//   - A non-Continue Action returned mid-stream stops further
+//     dispatch for that frame and rejects the response. Depending on
+//     listener implementation, previously forwarded bytes may already
+//     be on the wire, so a mid-stream Reject can result in a truncated
+//     stream. Today no in-tree plugin returns Reject from this hook.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/pipeline/plugin.go` around lines 194 - 202, Update the
StreamingResponder contract comment to reflect the actual proxy ordering: state
that reverse proxy flow invokes RunResponseFrame (and thus emits frame bytes)
before calling OnResponseFrame, so a mid-stream non-Continue Action cannot
un-send already-emitted bytes; remove or correct the sentence that claims
listeners forward+flush before OnResponseFrame. Edit the comment block near the
StreamingResponder type and the OnResponseFrame / RunResponseFrame mentions in
plugin.go to explicitly describe current behavior and its implication for
mid-stream Reject handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@authbridge/authlib/listener/forwardproxy/server.go`:
- Around line 530-536: Capture the return Action from the finalization call to
OutboundPipeline.RunResponseFrame and handle Reject the same way the per-frame
loop does: assign the result (e.g., action :=
s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, nil, true)), then if
action == pipeline.ActionReject (or the package's Reject constant) log the
rejection via s.recordOutboundResponseEvent or the existing logger so the policy
violation is recorded even though headers are already sent; keep the call to
s.recordOutboundResponseEvent(pctx, resp.StatusCode) afterward as before.
- Around line 563-565: In streamFallbackBuffered, do not ignore the return value
from s.OutboundPipeline.RunResponseFrame; call RunResponseFrame(r.Context(),
pctx, respBody, true) and inspect its error/response result (same way as the
buffered path that checks lines 359-364) and if it indicates a rejection or
error (e.g., instructing a 502/403) stop further handling and return that result
instead of proceeding to write headers/body; update the logic around
s.OutboundPipeline.HasStreamingResponders and RunResponseFrame so rejections are
honored before headers are written.

In `@authbridge/authlib/listener/internal/sseframe/reader.go`:
- Around line 153-188: readLine currently only treats '\n' as a terminator and
allows unbounded accumulation for long non-`data:` lines; update Reader.readLine
to also accept a lone '\r' as a line terminator (handle chunks that end with
'\r' returned by r.br.ReadSlice and treat them like terminated lines, stripping
the CR) and enforce a cap on accumulated non-`data:` lines to prevent unbounded
growth (introduce a maxLineBytes constant and when appending to line check if
the line does not start with "data:" and its length exceeds maxLineBytes, return
a clear error). Use the existing r.br.ReadSlice and bufio.ErrBufferFull loop but
add the CR-termination branch and the non-data length check to safely bound
memory.

---

Nitpick comments:
In `@authbridge/authlib/listener/forwardproxy/server.go`:
- Around line 778-787: idleReadCloser.Read has a tiny race where the timer
callback can call i.rc.Close() just after a successful Read, causing the next
Read to fail; wrap the close in a sync.Once to make the timer callback
idempotent so Close() is only executed once. Add a sync.Once field (e.g.
closeOnce) to the idleReadCloser struct and change the timer callback to call
closeOnce.Do(func(){ _ = i.rc.Close() }), leaving the rest of Read (timer
creation, stop, return) unchanged.
- Around line 791-802: The function indexByteASCIICaseInsensitive is misnamed
because it performs an exact byte match; rename it to a clear name like
indexByte or indexASCIIByte (choose one) and update all call sites to use the
new name (e.g., replace indexByteASCIICaseInsensitive(...) with indexByte(...));
also update the function comment to reflect exact ASCII byte lookup semantics
and run the build/tests to ensure no references remain to the old name.

In `@authbridge/authlib/listener/reverseproxy/server.go`:
- Around line 648-686: Replace the custom implementations of indexByteASCII,
trimASCIISpace, and equalASCIIFold with calls to the stdlib string helpers: use
strings.IndexByte for indexByteASCII, strings.TrimSpace (or strings.Trim(s, "
\t") if you want to strictly preserve only space and tab) for trimASCIISpace,
and strings.EqualFold for equalASCIIFold; also add the "strings" import and
update the three function bodies to delegate to these stdlib functions (keeping
the same function names so callers in this file stay unchanged).

In `@authbridge/authlib/pipeline/plugin.go`:
- Around line 194-202: Update the StreamingResponder contract comment to reflect
the actual proxy ordering: state that reverse proxy flow invokes
RunResponseFrame (and thus emits frame bytes) before calling OnResponseFrame, so
a mid-stream non-Continue Action cannot un-send already-emitted bytes; remove or
correct the sentence that claims listeners forward+flush before OnResponseFrame.
Edit the comment block near the StreamingResponder type and the OnResponseFrame
/ RunResponseFrame mentions in plugin.go to explicitly describe current behavior
and its implication for mid-stream Reject handling.

In `@authbridge/authlib/plugins/a2aparser/plugin.go`:
- Around line 331-337: The ext.Artifact concatenation in the artifact handling
branch can grow unbounded: in the loop over event.Result.Artifact.Parts
(checking part.Kind == "text") the code uses ext.Artifact += part.Text;
introduce a fixed cap (e.g., const maxArtifactSize) and track ext.Artifact
length before appending so you only append up to the remaining allowed bytes,
and if you hit the cap append a sentinel like "…(truncated)" or stop further
concatenation; update the loop that processes event.Result.Artifact.Parts to
enforce this cap and ensure subsequent artifact-update events respect the same
limit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5923a09c-defe-4843-9c2c-81ad62f311bc

📥 Commits

Reviewing files that changed from the base of the PR and between aa2b115 and 194c450.

📒 Files selected for processing (16)
  • authbridge/authlib/listener/forwardproxy/server.go
  • authbridge/authlib/listener/forwardproxy/streaming_test.go
  • authbridge/authlib/listener/internal/sseframe/reader.go
  • authbridge/authlib/listener/internal/sseframe/reader_test.go
  • authbridge/authlib/listener/reverseproxy/server.go
  • authbridge/authlib/listener/reverseproxy/streaming_test.go
  • authbridge/authlib/pipeline/holder.go
  • authbridge/authlib/pipeline/pipeline.go
  • authbridge/authlib/pipeline/plugin.go
  • authbridge/authlib/pipeline/streaming_test.go
  • authbridge/authlib/plugins/a2aparser/plugin.go
  • authbridge/authlib/plugins/a2aparser/streaming_test.go
  • authbridge/authlib/plugins/inferenceparser/plugin.go
  • authbridge/authlib/plugins/inferenceparser/streaming_test.go
  • authbridge/authlib/plugins/mcpparser/plugin.go
  • authbridge/authlib/plugins/mcpparser/streaming_test.go

Comment thread authbridge/authlib/listener/forwardproxy/server.go Outdated
Comment thread authbridge/authlib/listener/forwardproxy/server.go
Comment thread authbridge/authlib/listener/internal/sseframe/reader.go
@huang195

huang195 commented Jun 4, 2026

Copy link
Copy Markdown
Member

One concern on the timeout design — I think the hard-coded ResponseHeaderTimeout = 30s re-creates the very bug #477 is fixing, just on the other response shape.

The break

Streamable HTTP lets the server pick application/json vs text/event-stream per call. For application/json the server holds everything until the tool finishes, then sends headers and body together. So a slow tool that returns JSON sends no headers until it's done — and a 30s ResponseHeaderTimeout 502s it at 30s. That's the same "slow tool execution → 502" failure #477 reported, now on the JSON branch. (The comment's "tools that took longer to return headers never worked before either" doesn't quite hold: under the old Client.Timeout, a JSON tool returning at 25s worked.)

Why a stream-declared timeout can't save us

No layer here lets the sender declare "expect my response in N seconds": HTTP has no such header, SSE's retry: is a client-reconnect hint, and MCP's notifications/progress is a liveness signal the receiver resets its clock on — not a value the proxy can read off the wire. What senders provide is liveness (SSE comment heartbeats, MCP progress), which is exactly what an idle timer consumes. So the right control is an idle/reset-on-activity timer, not a fixed time-to-headers ceiling.

Proposal — remove a clock, don't add config

Rather than tuning or configuring the header timeout, collapse to a single proxy-imposed timeout:

  • Set ResponseHeaderTimeout: 0 (drop the responseHeaderTimeout const). This is what kills slow JSON tools.
  • Make streamReadIdleTimeout the only proxy timeout, and apply it to both paths — the SSE per-frame read and the buffered application/json body read. One constant, used everywhere — net fewer moving parts than today.
  • Total bound = the caller's request context, already inherited via Client.Do(r). The agent is the authority on "how long may this call run," and it's wired already.

Result: connect timeout (unchanged) + one idle timeout (now used on both paths) + caller context. No new config; a constant removed, not added.

"What if the server never terminates the stream?"

Every realistic broken-server case goes silent → the idle timer catches it (hang after headers, hang mid-stream, dropped conn). The only case it misses is a server looping forever emitting valid frames/heartbeats — and that's bounded by the agent's own request deadline, which the proxy already carries. The proxy shouldn't out-guess the caller on total runtime.

If we ever want a hard proxy-side stop even when the agent sets no deadline, that's one in-code constant (const maxStreamLifetime = 30 * time.Minute, mirroring the idle const already in the file) wrapping the request in context.WithTimeout — same category as the idle constant, not a user-facing knob. Deferrable; the idle timer + caller context cover the real failures.

Net: delete the header timeout, reuse the idle timer on both paths, lean on the caller's context — simpler than the current two-clock setup and it closes the slow-JSON regression.

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The streaming design is sound — the true text/event-stream path is single-dispatch and correct, per-request state lives on pctx (no shared-instance race across concurrent requests), nil-safety and empty-stream pairing are handled, and RunResponseFrame mirrors RunResponse's skip/gating/cancel semantics. Nicely done.

One root-cause must-fix blocks it: the buffered application/json finalize runs both RunResponse and RunResponseFrame(last=true) on the same body, so migrated plugins record every JSON response twice. Details inline (#1/#2). The remaining items are suggestions/nits.

Areas reviewed: Go (forward/reverse proxy streaming, SSE reader, pipeline plumbing, mcp/inference/a2a parsers). Commits: 1, signed-off ✓. CI: all green.

Fix for #1/#2/#3 is one change: have RunResponse skip plugins implementing StreamingResponder (the buffered path then finalizes them via the single RunResponseFrame(last=true) call you already make), or conversely only deliver the buffered finalize through one hook. That makes the documented "framework picks one path" contract actually true.

// Plugins that didn't migrate — i.e. don't implement
// StreamingResponder — are unaffected (RunResponseFrame skips them).
if s.OutboundPipeline.HasStreamingResponders() && resp.Body != nil {
respFrameAction := s.OutboundPipeline.RunResponseFrame(r.Context(), pctx, pctx.ResponseBody, true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — buffered application/json double-dispatches. This runs after RunResponse (line 348), so for a plugin implementing StreamingResponder both hooks fire on the same buffered body. Traced it: OnResponse runs first, its "already-finalized" guard is false on a fresh response, so it does the full parse + Observe; then OnResponseFrame(last=true) re-parses the same bytes and Observes again → two identical matched_<method>_response rows per JSON response (confirmed in mcp-parser and inference-parser). The guards in each OnResponse check fields that OnResponseFrame populates, but since OnResponse runs first they never fire — the guard is in the wrong order relative to the actual call order. The doc on StreamingResponder says "OnResponse is then NOT called for plugins that implement this interface," but nothing in the framework enforces that on the buffered path.

Fix: skip StreamingResponder plugins inside RunResponse (then this single RunResponseFrame(last=true) finalizes them), so JSON and SSE both go through exactly one hook.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. Pipeline.RunResponse now skips plugins implementing StreamingResponder so the buffered application/json path delivers the body through OnResponseFrame(last=true) only — single dispatch on both shapes. Added a regression test in TestForwardProxy_BufferedDeliversLastTrueFrame that asserts OnResponse is not called for a StreamingResponder plugin.

// for buffered application/json we deliver the body as one
// last=true frame so plugins can finalize via OnResponseFrame.
if s.InboundPipeline.HasStreamingResponders() && resp.Body != nil {
frameAction := s.InboundPipeline.RunResponseFrame(resp.Request.Context(), pctx, pctx.ResponseBody, true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — same buffered double-dispatch inbound. RunResponse (330) + RunResponseFrame(last=true) (339) both run on the buffered application/json body, so A2A message/send (non-stream) responses through this listener double-record the same way as the forward proxy (see the forwardproxy:360 comment). Same one-line fix resolves both listeners.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda by the same Pipeline.RunResponse change — reverseproxy.modifyResponse no longer double-dispatches buffered application/json bodies. Regression test added in TestReverseProxy_BufferedDeliversLastTrueFrame.

}

if last {
ext.Completion = state.completion.String()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — fallback can clobber a correctly-parsed completion to empty. On the non-flusher fallback (streamFallbackBuffered) with Stream==true: OnResponse runs parseInferenceSSE and sets Completion correctly; then OnResponseFrame(whole-SSE-blob, last=true) takes the streaming branch, json.Unmarshal of the multi-line blob as one chunk fails, and this line then overwrites ext.Completion with the empty state.completion. Production ResponseWriters implement http.Flusher so this is mostly a test-surface hazard, but it's the same root cause as the buffered double-dispatch — fixing RunResponse to skip StreamingResponder plugins removes it too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. Two parts: (1) Pipeline.RunResponse skipping StreamingResponder plugins removes the OnResponse pre-population path entirely; (2) streamFallbackBuffered now re-parses the buffered SSE body frame-by-frame through sseframe.NewReader and dispatches each event as a non-last frame, so the streaming branch sees properly-shaped chunks instead of one giant blob that would fail json.Unmarshal.

return
}
if _, err := w.Write(frame); err != nil {
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — client-write error skips finalize + record. These w.Write error branches return directly, bypassing the bottom RunResponseFrame(nil, true) and recordOutboundResponseEvent. So when the downstream client disconnects mid-stream, aggregating plugins (inference/a2a) never get last=true to finalize, and no response SessionEvent is recorded for the request. (Upstream read errors take the break path and finalize correctly — only the client-write path returns early.) Consider a defer-based finalize or routing these through the same end-of-stream tail.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. handleStreamingResponse now uses a defer for the final RunResponseFrame(nil, true) and recordOutboundResponseEvent so every exit path — normal EOF, upstream read error, and downstream client-write error — finalizes plugins and records the SessionResponse event. Client-write errors break out of the loop instead of returning early.

// Closing the body unblocks Read with an error. Idempotent on
// http.Response.Body (subsequent Closes are no-ops).
_ = i.rc.Close()
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — idle-timeout close races with a healthy Read. time.AfterFunc + i.rc.Close(), with timer.Stop() after the Read: Stop() doesn't wait for an already-fired callback, so if the timer fires just as a Read returns successfully, the body is closed under a live stream and the next ReadFrame aborts it. Window is small per read, but the timer is re-armed on every Read across a multi-minute stream, so the cumulative chance of a spurious truncation is nonzero. Also: closing the body concurrently with an in-flight Read is relied-upon-but-undocumented for HTTP/2 response bodies. Consider one long-lived timer reset between reads (and a guard flag) rather than per-Read allocation, or at least document the close-to-unblock assumption.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. idleReadCloser now wraps the close in a sync.Once and Close() routes through the same idempotent path, so a late-firing timer after a successful Read is harmless: the underlying body is closed at most once, and a successful in-flight Read keeps its bytes either way. Added a doc note about the close-to-unblock assumption (which is what the stdlib http transport relies on for forced disconnects).

// complete JSON-RPC response message. We parse + record per message
// rather than waiting for end-of-stream, so a long-running tools/call
// surfaces partial results in the session timeline as they arrive.
func (p *MCPParser) OnResponseFrame(_ context.Context, pctx *pipeline.Context, frame []byte, last bool) pipeline.Action {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — per-frame Observe is unbounded and asymmetric. OnResponseFrame records one invocation per result frame, so a long tools/call that streams many envelopes appends an unbounded number of matched_<method>_response rows onto pctx.Extensions.Invocations for a single request — both noisy in the session timeline and a memory-growth point. inference/a2a fold and emit once at last=true; mcp is the odd one out. If per-message rows are intended, fine — but worth confirming, and maybe capping.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. Capped at 50 per-frame Observe rows per stream; further frames update pctx.Extensions.MCP (so the latest result is still observable off-stream) but no Invocation row is appended. A single matched__response_truncated row is emitted at last=true so operators see the truncation explicitly. Per-message rows are still the design — abctl benefits from partial visibility on long tools/call streams — but the cap removes the unboundedness.

@@ -70,6 +96,10 @@ func NewServer(outbound *pipeline.Holder, sessions *session.Store, mtls *MTLSOpt
IdleConnTimeout: 90 * time.Second,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — hard-coded ResponseHeaderTimeout=30s re-creates #477 for application/json. Streamable HTTP lets the server return JSON instead of SSE per call; for JSON the server holds headers until the tool finishes, so a slow tool 502s at 30s — the same failure #477 is fixing, on the other response shape. (Raised in a standalone comment already; folding into the review for completeness.) Suggestion there: drop this and let the inbound request context + the existing idle timer bound things, so no new config is added.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. Dropped ResponseHeaderTimeout entirely. Streamable HTTP servers can hold headers open on either response shape, so any fixed time-to-headers ceiling reproduces #477 on the JSON path. The inbound request context and the per-Read idle timer (streamReadIdleTimeout, 5min) remain as bounds; an unrecoverably wedged upstream is closed when the client cancels.

if _, err := w.Write([]byte("data: ")); err != nil {
slog.Debug("forward-proxy: streaming write error", "host", r.Host, "error", err)
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — multi-line data: not round-tripped faithfully. sseframe folds multi-line data: with \n; re-emitting as one data: <frame>\n\n embeds those \ns, which a downstream SSE parser reads as line boundaries. Harmless for the single-line JSON-RPC payloads this targets (as the comment notes), but it'd corrupt genuine multi-line SSE. A guard or a re-split-per-line on emit would make it shape-faithful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2724eda. Both listeners now re-split the frame on \n at emit time so each original data line gets its own data: prefix, with one trailing blank line as the event terminator. For single-line JSON-RPC payloads this is equivalent to the old data: \n\n; for genuine multi-line data the wire shape is now faithful to what the upstream produced.

Addresses review items from PR #480:

- Fix double-dispatch on buffered application/json: Pipeline.RunResponse
  now skips plugins implementing StreamingResponder so the same body is
  never delivered through both OnResponse and OnResponseFrame. The
  framework picks one path per the StreamingResponder contract.
- Fix inference clobber on streamFallbackBuffered: re-parse the buffered
  SSE body frame-by-frame (matches the real streaming dispatch shape)
  instead of delivering the whole blob as one frame, which would fail
  json.Unmarshal in the streaming branch and zero out a parsed
  completion.
- Defer-based finalize + record on every exit path of
  handleStreamingResponse so a client disconnect mid-stream still
  finalizes aggregating plugins and records the SessionResponse event.
- Capture and log the final RunResponseFrame Reject (headers already on
  the wire, but the policy violation now surfaces).
- streamFallbackBuffered: honor RunResponseFrame Reject (headers not
  yet sent, so a 502/403 is still possible).
- Drop hard-coded 30s ResponseHeaderTimeout. Streamable HTTP servers
  hold headers open until tool completion even for application/json,
  so a fixed time-to-headers ceiling reproduces #477 on the JSON shape.
  Request context + per-Read idle timer remain the bounds.
- idleReadCloser: sync.Once around close so a stray late timer cannot
  fire after a successful Read; document the close-to-unblock contract.
- SSE reader: accept lone CR as a line terminator and bound non-data
  line growth at maxSize so pathological comment / unknown-field lines
  cannot grow memory past the per-frame cap.
- mcp-parser: cap per-frame Observe at 50 to bound noisy session
  timelines on long tools/call streams; emit a single _truncated row
  past the cap so operators see truncation explicitly.
- a2a-parser: cap accumulated artifact text at 64 KiB with a single
  truncation marker, mirroring the per-stream bounds elsewhere.
- Fix multi-line data round-trip: re-split frame on LF at emit time so
  each original line gets its own data prefix instead of embedding LFs
  inside one event.
- Replace custom indexByteASCII / trimASCIISpace / equalASCIIFold with
  stdlib strings.IndexByte / TrimSpace / EqualFold in both listeners.
- Update StreamingResponder contract comment to reflect the actual
  proxy ordering (reverseproxy emits frame bytes before
  OnResponseFrame; forwardproxy emits after) and the new RunResponse
  skip behavior.

Regression tests: assert OnResponse is NOT called on the buffered
application/json path for a StreamingResponder plugin in both
listeners. All existing tests continue to pass.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
@kellyaa

kellyaa commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Pushed 2724eda addressing all review feedback.

Must-fix (huang195):

  • ✅ Buffered application/json double-dispatch (forwardproxy + reverseproxy): Pipeline.RunResponse now skips plugins implementing StreamingResponder so the framework picks one path per the contract. Regression tests added in both listeners.
  • ✅ Inference fallback clobber: streamFallbackBuffered re-parses the buffered SSE body frame-by-frame so the streaming branch sees properly-shaped chunks (also resolved by the RunResponse skip on the production-flusher path).

Suggestions (huang195):

  • ✅ Defer-based finalize on every exit path of handleStreamingResponse (client-write disconnect now finalizes and records).
  • ✅ Idle-timer race: sync.Once around close, Close() routes through it; doc note on the close-to-unblock contract.
  • ✅ MCP per-frame Observe capped at 50; truncation row at last=true so operators see it.
  • ✅ Dropped hard-coded 30s ResponseHeaderTimeout (was reproducing AuthBridge forward-proxy buffers entire response body, breaking streaming MCP tool calls #477 for application/json).
  • ✅ Multi-line data: round-trip: re-split frame on LF at emit, faithful wire shape in both listeners.

CodeRabbit:

  • ✅ Final RunResponseFrame Reject captured + logged.
  • streamFallbackBuffered honors Reject (headers not yet sent).
  • ✅ SSE reader handles CR-only terminators + bounds non-data line growth via maxSize.
  • ✅ Renamed indexByteASCIICaseInsensitiveindexByteASCII and then deleted in favor of stdlib strings.IndexByte / TrimSpace / EqualFold.
  • ✅ A2A artifact text capped at 64 KiB with truncation marker.
  • StreamingResponder contract comment updated to reflect actual proxy ordering and the new RunResponse skip behavior.

All tests pass; go vet clean. Inline thread replies posted on each comment with the specifics.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (commit 2724eda)

Thanks for the thorough follow-up — all eight points from the previous round are addressed, and well:

  • #1/#2 double-dispatch (must-fix)RunResponse now skips StreamingResponder plugins, so the buffered application/json path single-dispatches. ✔
  • #3 inference clobberstreamFallbackBuffered re-parses the buffered SSE body frame-by-frame instead of dumping the whole blob. ✔
  • #4 client-write finalize → moved to a defer covering every exit path; the old explicit tail calls are removed, so it's still exactly one finalize. ✔
  • #5 idle racesync.Once (closeIdempotent) makes close safe; residual deadline-boundary race documented. ✔
  • #6 mcp per-frame ObservemaxStreamObserves=50 cap + _truncated marker. ✔
  • #7 30s ResponseHeaderTimeout → removed; relies on the idle timer + inbound request context. ✔
  • #8 multi-line data:writeSSEFrame re-splits per original line. ✔

One new must-fix that the #1 fix introduced — details inline on pipeline.go. It's a consequence of the skip landing framework-wide while only the proxy listeners dispatch frames; the envoy-sidecar (extproc) path now drops streaming-parser response handling entirely. Once that's resolved this is good to go.

CI: green.

if policy == ErrorPolicyOff {
continue
}
if _, ok := p.plugins[i].(StreamingResponder); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this skip regresses the envoy-sidecar (extproc) path. Skipping StreamingResponder plugins here is correct for forwardproxy/reverseproxy because they finalize those plugins via RunResponseFrame. But extproc calls only RunResponse (extproc/server.go:557 and :594) and never RunResponseFrame — so for the mcp/inference/a2a parsers (all of which now implement StreamingResponder), their response-phase logic runs nowhere under envoy-sidecar: skipped here, never reached as frames.

Two concrete regressions in envoy mode:

  1. Lost response observability — MCP results, inference completion/usage, and A2A artifacts/final-status are no longer recorded on the response side.
  2. Broken A2A contextId rekeyextproc/server.go:~599 rekeys the session from DefaultSessionID to the server-assigned contextId, which depends on a2a-parser populating pctx.Extensions.A2A.SessionID during RunResponse. With a2a-parser now skipped, that field isn't set on the response and inbound A2A sessions fragment.

Envoy-sidecar is a supported, tested deployment path, so this needs to be closed before merge. Two options:

  • (a) Have extproc call RunResponseFrame(pctx.ResponseBody, last=true) after RunResponse on its buffered body (envoy delivers the response BUFFERED already — see handleResponseHeaders ModeOverride), mirroring what the proxy listeners do for application/json. For an SSE body buffered by envoy, re-parse via sseframe like streamFallbackBuffered does.
  • (b) Make the skip listener-aware (e.g. a flag/variant so RunResponse only skips when the caller will also dispatch frames), so extproc's plain RunResponse keeps running OnResponse for these plugins.

(a) is the more consistent fix — it gives extproc the same single-dispatch contract as the proxies.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2fd8c0f via option (a). After RunResponse, extproc now dispatches the buffered body through RunResponseFrame whenever the pipeline has any StreamingResponder — application/json as one last=true frame, text/event-stream re-parsed via sseframe (one non-last frame per event + a final last=true). Header-only path also gets a single empty last=true so plugins finalize. The A2A contextId rekey now sees pctx.Extensions.A2A.SessionID populated, restoring single-session behavior. Regression tests added: TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder and TestExtProc_BufferedSSEResponse_DispatchesPerEvent.

…in extproc

Fixes a regression introduced in 2724eda: pipeline.RunResponse now
skips StreamingResponder plugins so the proxy listeners single-dispatch
on the buffered application/json path, but extproc only calls
RunResponse — never RunResponseFrame — so under envoy-sidecar mode
mcp/inference/a2a parsers lost response-phase dispatch entirely. That
broke MCP / inference / A2A response observability and the inbound A2A
contextId rekey (which depends on a2a-parser populating
pctx.Extensions.A2A.SessionID during response handling).

extproc now mirrors the proxy listeners' single-dispatch contract:
after RunResponse, when any plugin in the pipeline implements
StreamingResponder, dispatch the buffered body via RunResponseFrame.
For application/json the entire body is one last=true frame; for
text/event-stream we re-parse with sseframe so each event arrives as
its own non-last frame followed by a final last=true — same shape the
proxy wire-streaming path produces. The header-only response path
also delivers a single empty last=true frame so plugins finalize.

Regression tests:
- TestExtProc_BufferedJSONResponse_DispatchesToStreamingResponder:
  buffered JSON delivers exactly one OnResponseFrame(last=true) and
  OnResponse is not called for the StreamingResponder plugin.
- TestExtProc_BufferedSSEResponse_DispatchesPerEvent: a 3-event SSE
  body produces 3 non-last frames + 1 final last=true.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (commit 2fd8c0f) — LGTM

The envoy-sidecar regression from the previous round is correctly fixed. Verified:

  • handleResponseBody calls dispatchBufferedFrames after RunResponse and before the A2A contextId rekey, so a2a-parser's OnResponseFrame populates pctx.Extensions.A2A.SessionID in time — the rekey works again.
  • Single dispatch is preserved: RunResponse still skips StreamingResponder plugins, and dispatchBufferedFrames is the only place they're dispatched (no return of the original double-dispatch).
  • Both shapes covered: application/json → one last=true frame; text/event-stream → re-parsed per-event via sseframe + final last=true; header-only path → empty last=true so plugins finalize.
  • Two regression tests added (buffered JSON single last=true + OnResponse not called; 3-event SSE → 3 frames + final).

All eight points from the first round remain resolved. Two non-blocking notes inline. Nice work across the revisions.

CI: green.

// sseframe so each event arrives as its own non-last frame followed
// by a final last=true — matches the per-message dispatch shape
// streaming-aware plugins expect.
func dispatchBufferedFrames(ctx context.Context, p *pipeline.Holder, pctx *pipeline.Context) pipeline.Action {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking, scope note). Because envoy ext_proc delivers the response ResponseBodyMode_BUFFERED, this re-parses an already-fully-buffered SSE body. So #477's actual streaming benefit — not holding a slow/large SSE tools/call until complete — applies to proxy-sidecar only; in envoy-sidecar mode the whole stream is still buffered (bounded by maxBodySize = 1 MiB), so the original slow-tool/large-response risk persists there. This is a pre-existing extproc limitation and the right behavior for plugin dispatch — not something to fix in this PR — but worth a doc line or a tracked follow-up so operators know envoy mode isn't streamed end-to-end (it would need ext_proc STREAMED body mode).

// isEventStream reports whether a Content-Type header value names the
// SSE media type. Tolerates parameters and ASCII case differences.
// Mirrors the helpers in forwardproxy/reverseproxy.
func isEventStream(contentType string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit. isEventStream now exists in three listeners (forwardproxy, reverseproxy, extproc) with the same logic. Consider hoisting to a shared internal helper (e.g. next to sseframe) so the three copies can't drift.

@huang195
huang195 merged commit c856452 into main Jun 5, 2026
19 checks passed
@github-project-automation github-project-automation Bot moved this from New /:ToDo to Done in Kagenti Issue Prioritization Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

AuthBridge forward-proxy buffers entire response body, breaking streaming MCP tool calls

3 participants