What happens
WebsocketCapture keeps a single pending exchange in current_state, so it can only track one unfinished exchange at a time:
|
struct WebsocketCapture { |
|
provider: ProviderKind, |
|
exchange_id: String, |
|
request_id: Option<String>, |
|
artifact_refs: ArtifactRefs, |
|
current_state: Option<crate::provider::ExchangeState>, |
|
} |
When a second response.create arrives before the first response finishes, observe_downstream_text finalizes the pending exchange and then overwrites current_state with the new request:
|
let mut turns = self.finalize_pending(session); |
|
let prepared = self.provider.prepare_exchange( |
|
session, |
|
self.exchange_id.clone(), |
|
text.as_bytes(), |
|
&self.artifact_refs, |
|
); |
|
turns.extend(prepared.request_turns); |
|
self.current_state = Some(prepared.state); |
|
turns |
Upstream events are then applied only to that one current_state, and the first response.completed calls finalize_pending:
|
let Some(state) = self.current_state.as_mut() else { |
|
return Vec::new(); |
|
}; |
|
let Ok(payload) = serde_json::from_str::<Value>(text) else { |
|
return Vec::new(); |
|
}; |
|
let event_type = payload |
|
.get("type") |
|
.and_then(Value::as_str) |
|
.map(|value| value.to_string()); |
|
state.absorb_sse_frame(&openai::SseFrame { |
|
event: event_type.clone(), |
|
data: text.to_string(), |
|
raw: text.to_string(), |
|
}); |
|
if event_type.as_deref() == Some("response.completed") { |
|
return self.finalize_pending(session); |
|
} |
Why it's a problem
If two provider responses overlap (request A is still streaming when request B starts), starting B finalizes A prematurely and replaces its state. The first response.completed then finds B's state, not A's, so A's real completion is never finalized against its own request and drops out of captured history. The capture ends up with B recorded and A missing or truncated.
Failure scenario
- Client sends request A; the proxy starts capturing exchange A.
- Before A completes, the client sends request B (overlapping or pipelined on the same connection).
observe_downstream_text for B calls finalize_pending (finalizing A early on partial data) and sets current_state = Some(B).
- A's
response.completed arrives and is applied to B's state; A's real completion is lost.
Result: the recorded conversation history is missing A's response, or attributes A's completion to B. The existing test around proxy.rs:1173-1248 only covers sequential requests, so this ordering is not exercised.
Suggested fix
Track pending exchange states keyed by the provider response/request id (a small map) and finalize the matching state on each response.completed, rather than the single current_state slot. Keep the single-slot fast path only if the protocol guarantees requests are serialized on the connection.
Context on why we were in this repo: cxdb was one of the projects in DoltHub's recent "Top 5 agent-engineered open source projects" writeup (https://www.dolthub.com/blog/2026-08-17-top-5-agent-engineered-open-source-projects/), and we wanted to see how our code-review tool held up against real agent-written code, so we ran it against the recently merged PRs here. This finding came out of PR #26.
Found while testing Ito, an automated code-review tool, against recently-merged PRs. It's free for open source. Sharing this because it looked like a real bug worth fixing, not to sell anything: https://app.ito.ai/share/99b73ede-8625-4132-a6ae-a31dd0b93792?tab=details
What happens
WebsocketCapturekeeps a single pending exchange incurrent_state, so it can only track one unfinished exchange at a time:cxdb/cxtx/src/proxy.rs
Lines 853 to 859 in 0a59939
When a second
response.createarrives before the first response finishes,observe_downstream_textfinalizes the pending exchange and then overwritescurrent_statewith the new request:cxdb/cxtx/src/proxy.rs
Lines 914 to 923 in 0a59939
Upstream events are then applied only to that one
current_state, and the firstresponse.completedcallsfinalize_pending:cxdb/cxtx/src/proxy.rs
Lines 931 to 948 in 0a59939
Why it's a problem
If two provider responses overlap (request A is still streaming when request B starts), starting B finalizes A prematurely and replaces its state. The first
response.completedthen finds B's state, not A's, so A's real completion is never finalized against its own request and drops out of captured history. The capture ends up with B recorded and A missing or truncated.Failure scenario
observe_downstream_textfor B callsfinalize_pending(finalizing A early on partial data) and setscurrent_state = Some(B).response.completedarrives and is applied to B's state; A's real completion is lost.Result: the recorded conversation history is missing A's response, or attributes A's completion to B. The existing test around
proxy.rs:1173-1248only covers sequential requests, so this ordering is not exercised.Suggested fix
Track pending exchange states keyed by the provider response/request id (a small map) and finalize the matching state on each
response.completed, rather than the singlecurrent_stateslot. Keep the single-slot fast path only if the protocol guarantees requests are serialized on the connection.Context on why we were in this repo: cxdb was one of the projects in DoltHub's recent "Top 5 agent-engineered open source projects" writeup (https://www.dolthub.com/blog/2026-08-17-top-5-agent-engineered-open-source-projects/), and we wanted to see how our code-review tool held up against real agent-written code, so we ran it against the recently merged PRs here. This finding came out of PR #26.
Found while testing Ito, an automated code-review tool, against recently-merged PRs. It's free for open source. Sharing this because it looked like a real bug worth fixing, not to sell anything: https://app.ito.ai/share/99b73ede-8625-4132-a6ae-a31dd0b93792?tab=details