Skip to content

fix(claude): accept data-only Responses SSE (#700) - #711

Merged
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/claude-data-only-responses-sse
Jul 29, 2026
Merged

fix(claude): accept data-only Responses SSE (#700)#711
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/claude-data-only-responses-sse

Conversation

@snowyukitty

@snowyukitty snowyukitty commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes #700.

Summary

  • accept Responses-compatible data-only SSE records in the Claude Messages bridge by falling back
    to the parsed JSON payload's type when the optional SSE event: field is absent;
  • preserve explicit event-name precedence and strict terminal-frame validation;
  • add streaming, non-streaming, mixed-framing, precedence, ignored-frame, and truncation
    regressions.

Problem

The Claude Messages bridge currently requires every upstream Responses SSE record to carry both an
event: line and a data: line before it parses the JSON:

if (!eventName || !dataLine) continue;

The provider in #700 emits valid data-only records whose Responses event type is in the JSON
payload:

data: {"type":"response.output_text.delta",...}

data: {"type":"response.completed",...}

data: [DONE]

The HTML SSE standard permits data-only messages; event: is not required and the default event
type is message. Because the bridge drops these records before parsing them, it never observes
response.completed. Both Claude streaming and non-streaming requests consequently end as:

upstream stream ended before a terminal frame (truncated response)

The two Claude response modes share this converter, so one parser gate causes both failures.

Fix

Parse any record with a non-empty data: payload, then resolve its Responses event name as:

  1. the explicit SSE event: value, when present; otherwise
  2. the parsed JSON payload's string type.

The scope remains narrow:

  • explicit event: values still take precedence over a conflicting payload type;
  • untyped data-only JSON objects remain ignored;
  • non-JSON sentinels such as [DONE] remain ignored;
  • response.completed, response.incomplete, or response.failed is still required before EOF;
  • cancellation, keepalive, terminal, error-taxonomy, image, and tool-call paths are unchanged.

This accepts the gateway's framing without weakening the existing fail-closed truncation contract.

Tests

New regressions cover:

  • a fully data-only Responses stream through Anthropic SSE output;
  • data-only frames through non-streaming aggregation;
  • explicit and data-only records interleaved in one stream;
  • explicit event-name precedence over a conflicting payload type;
  • ignoring untyped data-only records;
  • [DONE] without a Responses terminal frame still producing a truncation error rather than
    message_stop.

Activation was verified before the source change: the streaming and non-streaming data-only tests
failed against the old gate, while the explicit-precedence control passed.

Final verification on commit fcd3298f:

  • bun test tests/claude-outbound.test.ts30 passed, 0 failed
  • bun test tests/claude-messages-endpoint.test.ts26 passed, 0 failed
  • bun run typecheck — passed
  • bun run privacy:scan — passed
  • git diff --check — passed
  • full isolated suite with Bun 1.3.14:
    bun scripts/test.ts --max-concurrency 4 ./tests/
    5,977 passed, 1 Windows-only skip, 0 failed across 429 files

The bounded concurrency is a local WSL scheduling accommodation, not a test exclusion. At Bun's
default concurrency, two pre-existing deadline-sensitive tests intermittently starved under load;
the same tests passed 10/10 and 5/5 in isolated repetitions, and the complete lower-concurrency run
was clean.

Notes

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (No user-facing configuration or API
    documentation changes are required.)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • Bug Fixes
    • Improved streaming response handling for gateways that omit SSE event names.
    • Preserved text updates and message aggregation across mixed and data-only streams.
    • Added safer handling for untyped or incomplete terminal messages to prevent incorrect stream completion.

@github-actions github-actions Bot added the bug Something isn't working label Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1c42fd5e-1f2b-45b8-ab75-c482cd08570d

📥 Commits

Reviewing files that changed from the base of the PR and between c0ad57a and fcd3298.

📒 Files selected for processing (2)
  • src/claude/outbound.ts
  • tests/claude-outbound.test.ts

📝 Walkthrough

Walkthrough

The Claude outbound SSE converter now accepts frames without event: by inferring event names from JSON type values. Tests cover mixed framing, aggregation, precedence, ignored untyped frames, and truncated data-only streams.

Changes

Responses SSE compatibility

Layer / File(s) Summary
Data-only SSE frame parsing
src/claude/outbound.ts
Frames now require only a data payload, validate parsed JSON objects, infer missing event names from data.type, and continue enforcing resolvable events and terminal-state handling.
Data-only stream validation
tests/claude-outbound.test.ts
Tests cover data-only event sequences, mixed explicit and inferred events, non-streaming aggregation, explicit-event precedence, untyped-frame ignoring, and fail-closed [DONE] handling.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • lidge-jun/opencodex#302: Updates fail-closed handling for response.completed and response.incomplete in the same outbound translation path.
  • lidge-jun/opencodex#316: Adjusts SSE event-name and terminal framing behavior for optional event: fields.

Suggested reviewers: wibias, ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: accepting data-only Responses SSE in Claude.
Linked Issues check ✅ Passed The code and tests match #700 by inferring event types from data-only payloads, preserving explicit event precedence, and keeping truncation checks.
Out of Scope Changes check ✅ Passed The changes stay focused on SSE parsing and regression tests, with no evident unrelated or extraneous modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@snowyukitty

Copy link
Copy Markdown
Contributor Author

CI note: the Windows job reached the workflow's 12-minute cap after the root test suite, GUI tests, privacy scan, release-helper syntax check, and GUI lint had all passed. It was cancelled during GUI build; Ubuntu, macOS, all npm-global jobs, React Doctor, target enforcement, and CodeRabbit passed. The contributor account cannot rerun an upstream Actions job, so a maintainer rerun should be sufficient. I have not changed the reviewed commit just to retrigger CI.

@lidge-jun

Copy link
Copy Markdown
Owner

Heads-up on a policy change that affects the port note in this PR's description: the dev2-go line is retired, so no Go counterpart is expected for this change and nothing further is owed after it lands on dev.

dev (Bun-native TypeScript) is the single integration line again. Keeping two integration lines in sync cost more than the Go port returned, so the carry/port obligation and the needs-go-port label are both gone. The dev2-go history is archived at lidge-jun/opencodex-go-archive with its final tip tagged archive/dev2-go here.

Nothing is required from you — this PR is unaffected other than that the port line in the description no longer applies. Sorry for the mixed signals while the policy was in flux.

@lidge-jun
lidge-jun merged commit d24c523 into lidge-jun:dev Jul 29, 2026
17 of 18 checks passed
lidge-jun added a commit that referenced this pull request Jul 30, 2026
The `test` job ran with `timeout-minutes: 12` while a green Windows run
takes 11.8 minutes, leaving about 12 seconds of margin. Runner variance,
not the code under review, decided the outcome: PR #711's rerun finished
at 11.8min and passed, PR #653's was killed at 12.0min with `Test`
cancelled and every later step skipped.

That flakiness is expensive to review around, because `gh pr checks`
renders a cancelled job as `fail`. A red Windows check could mean a real
failure, a `cancel-in-progress` concurrency cancel, or this timeout, and
telling them apart requires reading `conclusion` from the check-runs API
plus per-job timings.

Measured on run 30459554635 (same suite, same commit):

  ubuntu-latest    4.6 min
  macos-latest     5.6 min
  windows-latest  11.8 min

20 minutes keeps a green Windows run green with margin. It does not
address why Windows needs ~2.5x Linux on an identical suite; the comment
says so explicitly, because the ceiling has already been raised once
(8 -> 12) for this same reason and chasing it again is not a fix. The
`npm-global-smoke` job is untouched at 8 minutes: it completes in 1-2
minutes and was never near its limit.

Refs #717
lidge-jun added a commit that referenced this pull request Aug 1, 2026
The windows leg dominates this matrix at 11.8min against ubuntu's 4.6min,
and #711/#653 were decided by runner variance rather than by the code under
review. A maintainer's own Windows box removes that, but it is a personal
machine on a home network: whatever runs on it runs as a local user with
that user's files and LAN in reach.

So the routing decision is made from the event, not from the workflow body.
`push` on dev/main/preview requires the push permission, which only the
MAINTAINERS.md set holds, and `workflow_dispatch` requires write access.
Those two go to the home box. `pull_request` never does.

An author check inside this file would not be equivalent. `ci.yml` runs from
the PR head on `pull_request`, so any `if: author_association == ...` guard
is deletable by the same patch it is meant to stop, as are the tsconfig,
lockfile and postinstall hooks the run would otherwise honour. Only a
decision made before a runner is chosen survives a hostile head.

Two supporting details. `matrix.include` carries a fixed `name` because a
label-array runner renders into the check name, so deriving it would rename
the required check depending on which machine served it. And the self-hosted
leg wipes its workspace first: a persistent working directory keeps files
that the commit under test deleted, which passes a suite against a tree that
no longer exists in git.

Routing is gated on the `OCX_SELF_HOSTED_WINDOWS` repository variable, which
is unset today, so every leg still resolves to windows-latest until the
runner is registered and the variable is flipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants