Skip to content

Overlapping in-flight exchanges drop the first response from captured history #32

Description

@graysoncooper

What happens

WebsocketCapture keeps a single pending exchange in current_state, so it can only track one unfinished exchange at a time:

cxdb/cxtx/src/proxy.rs

Lines 853 to 859 in 0a59939

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:

cxdb/cxtx/src/proxy.rs

Lines 914 to 923 in 0a59939

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:

cxdb/cxtx/src/proxy.rs

Lines 931 to 948 in 0a59939

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

  1. Client sends request A; the proxy starts capturing exchange A.
  2. Before A completes, the client sends request B (overlapping or pipelined on the same connection).
  3. observe_downstream_text for B calls finalize_pending (finalizing A early on partial data) and sets current_state = Some(B).
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions