diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bc3b274 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.scratch +logs +target +worktree + +**/node_modules +**/.next +**/out + +gateway/bin diff --git a/Cargo.toml b/Cargo.toml index e533ddb..22d1be1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,4 +3,5 @@ resolver = "2" members = [ "server", "clients/rust", + "cxtx", ] diff --git a/Dockerfile b/Dockerfile index a9dfe0b..8829eb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,17 +36,21 @@ WORKDIR /app COPY Cargo.toml Cargo.lock* ./ COPY server/Cargo.toml ./server/ COPY clients/rust/Cargo.toml ./clients/rust/ +COPY cxtx/Cargo.toml ./cxtx/ # Create dummy sources to build dependencies -RUN mkdir -p server/src clients/rust/src && \ +RUN mkdir -p server/src clients/rust/src cxtx/src && \ echo "fn main() {}" > server/src/main.rs && \ echo "pub fn dummy() {}" > clients/rust/src/lib.rs && \ + echo "pub fn dummy() {}" > cxtx/src/lib.rs && \ + echo "fn main() {}" > cxtx/src/main.rs && \ cargo build --release --manifest-path server/Cargo.toml && \ - rm -rf server/src clients/rust/src + rm -rf server/src clients/rust/src cxtx/src # Copy actual source and build COPY server/ ./server/ COPY clients/ ./clients/ +COPY cxtx/ ./cxtx/ RUN touch server/src/main.rs && \ cargo build --release --manifest-path server/Cargo.toml diff --git a/README.md b/README.md index 507b01f..acdb693 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Built on a Turn DAG + Blob CAS architecture, CXDB gives you: - **Content deduplication**: Identical payloads stored once via BLAKE3 hashing - **Type-safe projections**: Msgpack storage with typed JSON views for UIs - **Built-in UI**: React frontend with turn visualization and custom renderers +- **First-party CLI capture**: `cxtx` wraps `codex` and `claude` sessions and stores them as canonical conversation turns ## Quick Start @@ -36,6 +37,13 @@ curl -X POST http://localhost:9010/v1/contexts/1/append \ open http://localhost:9010 ``` +Capture a local `codex` or `claude` session into CXDB with the first-party wrapper: + +```bash +cargo run -p cxtx -- --help +cargo run -p cxtx -- codex -- --help +``` + ## Installation ### From Source @@ -54,7 +62,7 @@ cd cxdb cargo build --release # Run the server -./target/release/ai-cxdb-store +./target/release/cxdb-server # Build the gateway (optional - for OAuth and frontend serving) cd gateway @@ -201,6 +209,7 @@ CXDB is a three-tier system: - **HTTP API**: [docs/http-api.md](docs/http-api.md) - **Type Registry**: [docs/type-registry.md](docs/type-registry.md) - **Renderers**: [docs/renderers.md](docs/renderers.md) +- **CLI Wrapper (`cxtx`)**: [cxtx/README.md](cxtx/README.md) - **Deployment**: [docs/deployment.md](docs/deployment.md) - **Troubleshooting**: [docs/troubleshooting.md](docs/troubleshooting.md) - **Development**: [docs/development.md](docs/development.md) diff --git a/cxtx/Cargo.toml b/cxtx/Cargo.toml new file mode 100644 index 0000000..135ae65 --- /dev/null +++ b/cxtx/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cxtx" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "CLI wrapper that captures claude/codex provider traffic and uploads canonical conversation context into CXDB" + +[dependencies] +anyhow = "1.0" +async-stream = "0.3" +axum = { version = "0.7", features = ["macros"] } +base64 = "0.22" +bytes = "1.10" +chrono = { version = "0.4", features = ["clock", "serde"] } +clap = { version = "4.5", features = ["derive", "env"] } +cxdb = { path = "../clients/rust" } +futures-util = "0.3" +http = "1.3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +url = "2.5" +uuid = { version = "1.18", features = ["v4"] } + +[dev-dependencies] +assert_cmd = "2.0" +cxdb-server = { path = "../server" } +predicates = "3.1" +rmpv = "1.3" +tempfile = "3.10" diff --git a/cxtx/README.md b/cxtx/README.md new file mode 100644 index 0000000..57de6bb --- /dev/null +++ b/cxtx/README.md @@ -0,0 +1,55 @@ +# `cxtx` + +`cxtx` wraps `codex` or `claude`, routes provider traffic through a local reverse proxy, uploads canonical `cxdb.ConversationItem` turns into CXDB, and keeps raw provider evidence under `.scratch/cxtx/sessions/`. + +## Prerequisites + +- A running CXDB HTTP endpoint, typically `http://127.0.0.1:9010` +- Either the `codex` or `claude` CLI installed and discoverable on `PATH` +- Existing provider credentials for the child CLI in your environment + +## Build + +```bash +cargo build --release -p cxtx +``` + +## Usage + +```bash +# Wrap Codex and send captured turns to the local CXDB HTTP endpoint +./target/release/cxtx codex -- --model gpt-5 + +# Wrap Claude against a specific CXDB server +./target/release/cxtx --url http://127.0.0.1:9010 claude -- --print stream +``` + +`cxtx` preserves child stdin, stdout, stderr, and exit status. On successful execution it does not write wrapper-authored stdout. If CXDB is unavailable, it still launches the child, enters queued-delivery mode, and records delivery state in the local ledger until delivery recovers or shutdown drain completes. + +## Resulting Artifacts + +- CXDB receives canonical `system`, `user_input`, `assistant_turn`, and tool-related items for the wrapped session. +- The first uploaded turn carries `ContextMetadata` and `Provenance`, so the context is queryable in CXDB listings. +- `cxtx` publishes the bundled canonical `cxdb.ConversationItem` registry descriptor automatically before the first append when the server does not already have it. +- Local evidence is written under `.scratch/cxtx/sessions//`: + - `ledger.json` + - `exchanges//request.json` + - `exchanges//response.json` + - `exchanges//stream.ndjson` + +## Troubleshooting + +- `failed to launch codex` or `failed to launch claude`: + - The child binary is missing from `PATH` or is not executable. +- `cxtx: CXDB ingest unavailable, entering queued-delivery mode`: + - The wrapper could not reach the configured `--url`. Check the CXDB server, but the child session is still running and the ledger will show queue state. +- No captured turns appear in CXDB: + - Confirm the child CLI honors the injected provider base URL variables. `cxtx` depends on those environment overrides for transparent capture. + +## Verification + +```bash +cargo run -p cxtx -- --help +cargo test -p cxtx +cargo test -p cxtx --test integration +``` diff --git a/cxtx/src/cli.rs b/cxtx/src/cli.rs new file mode 100644 index 0000000..b6189a4 --- /dev/null +++ b/cxtx/src/cli.rs @@ -0,0 +1,63 @@ +use clap::{Parser, Subcommand}; + +use crate::provider::ProviderKind; + +#[derive(Debug, Clone, Parser)] +#[command( + name = "cxtx", + about = "Wrap claude or codex, capture provider traffic, and upload canonical conversation context to CXDB", + after_help = "Examples:\n cxtx codex -- --model gpt-5\n cxtx --url http://127.0.0.1:9010 claude -- --print stream" +)] +pub struct Cli { + #[arg( + long, + default_value = "http://127.0.0.1:9010", + help = "CXDB HTTP base URL used for registry publication, context creation, and turn append" + )] + pub url: String, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum Command { + /// Launch the `claude` CLI through a local Anthropic-aware capture proxy. + Claude { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Launch the `codex` CLI through a local OpenAI-aware capture proxy. + Codex { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +impl Command { + pub fn provider(&self) -> ProviderKind { + match self { + Self::Claude { .. } => ProviderKind::Claude, + Self::Codex { .. } => ProviderKind::Codex, + } + } + + pub fn args(&self) -> &[String] { + match self { + Self::Claude { args } | Self::Codex { args } => args, + } + } +} + +impl Cli { + pub fn for_tests(provider: ProviderKind, args: Vec, url: &str) -> Self { + let command = match provider { + ProviderKind::Claude => Command::Claude { args }, + ProviderKind::Codex => Command::Codex { args }, + }; + Self { + url: url.to_string(), + command, + } + } +} diff --git a/cxtx/src/conversation_registry_bundle.json b/cxtx/src/conversation_registry_bundle.json new file mode 100644 index 0000000..0c94543 --- /dev/null +++ b/cxtx/src/conversation_registry_bundle.json @@ -0,0 +1,222 @@ +{ + "registry_version": 1, + "bundle_id": "cxdb.conversation-item.v3", + "types": { + "cxdb.ConversationItem": { + "versions": { + "3": { + "fields": { + "1": { "name": "item_type", "type": "string" }, + "2": { "name": "status", "type": "string", "optional": true }, + "3": { "name": "timestamp", "type": "timestamp_ms", "optional": true }, + "4": { "name": "id", "type": "string", "optional": true }, + "10": { "name": "user_input", "type": "ref", "ref": "cxdb.UserInput", "optional": true }, + "11": { "name": "turn", "type": "ref", "ref": "cxdb.AssistantTurn", "optional": true }, + "12": { "name": "system", "type": "ref", "ref": "cxdb.SystemMessage", "optional": true }, + "13": { "name": "handoff", "type": "ref", "ref": "cxdb.HandoffInfo", "optional": true }, + "20": { "name": "assistant", "type": "ref", "ref": "cxdb.Assistant", "optional": true }, + "21": { "name": "tool_call", "type": "ref", "ref": "cxdb.ToolCall", "optional": true }, + "22": { "name": "tool_result", "type": "ref", "ref": "cxdb.ToolResult", "optional": true }, + "30": { "name": "context_metadata", "type": "ref", "ref": "cxdb.ContextMetadata", "optional": true } + }, + "renderer": { + "esm_url": "builtin:ConversationRenderer" + } + } + } + }, + "cxdb.UserInput": { + "versions": { + "1": { + "fields": { + "1": { "name": "text", "type": "string" }, + "2": { "name": "files", "type": "array", "items": "string", "optional": true } + } + } + } + }, + "cxdb.AssistantTurn": { + "versions": { + "1": { + "fields": { + "1": { "name": "text", "type": "string" }, + "2": { "name": "tool_calls", "type": "array", "items": { "ref": "cxdb.ToolCallItem" }, "optional": true }, + "3": { "name": "reasoning", "type": "string", "optional": true }, + "4": { "name": "metrics", "type": "ref", "ref": "cxdb.TurnMetrics", "optional": true }, + "5": { "name": "agent", "type": "string", "optional": true }, + "6": { "name": "turn_number", "type": "int64", "optional": true }, + "7": { "name": "max_turns", "type": "int64", "optional": true }, + "8": { "name": "finish_reason", "type": "string", "optional": true } + } + } + } + }, + "cxdb.ToolCallItem": { + "versions": { + "1": { + "fields": { + "1": { "name": "id", "type": "string" }, + "2": { "name": "name", "type": "string" }, + "3": { "name": "args", "type": "string" }, + "4": { "name": "status", "type": "string" }, + "5": { "name": "description", "type": "string", "optional": true }, + "6": { "name": "streaming_output", "type": "string", "optional": true }, + "7": { "name": "streaming_output_truncated", "type": "bool", "optional": true }, + "8": { "name": "result", "type": "ref", "ref": "cxdb.ToolCallResult", "optional": true }, + "9": { "name": "error", "type": "ref", "ref": "cxdb.ToolCallError", "optional": true }, + "10": { "name": "duration_ms", "type": "int64", "optional": true } + } + } + } + }, + "cxdb.ToolCallResult": { + "versions": { + "1": { + "fields": { + "1": { "name": "content", "type": "string" }, + "2": { "name": "content_truncated", "type": "bool", "optional": true }, + "3": { "name": "success", "type": "bool" }, + "4": { "name": "exit_code", "type": "int64", "optional": true } + } + } + } + }, + "cxdb.ToolCallError": { + "versions": { + "1": { + "fields": { + "1": { "name": "code", "type": "string", "optional": true }, + "2": { "name": "message", "type": "string" }, + "3": { "name": "exit_code", "type": "int64", "optional": true } + } + } + } + }, + "cxdb.TurnMetrics": { + "versions": { + "1": { + "fields": { + "1": { "name": "input_tokens", "type": "int64" }, + "2": { "name": "output_tokens", "type": "int64" }, + "3": { "name": "total_tokens", "type": "int64" }, + "4": { "name": "cached_tokens", "type": "int64", "optional": true }, + "5": { "name": "reasoning_tokens", "type": "int64", "optional": true }, + "6": { "name": "duration_ms", "type": "int64", "optional": true }, + "7": { "name": "model", "type": "string", "optional": true } + } + } + } + }, + "cxdb.SystemMessage": { + "versions": { + "1": { + "fields": { + "1": { "name": "kind", "type": "string" }, + "2": { "name": "title", "type": "string", "optional": true }, + "3": { "name": "content", "type": "string" } + } + } + } + }, + "cxdb.HandoffInfo": { + "versions": { + "1": { + "fields": { + "1": { "name": "from_agent", "type": "string" }, + "2": { "name": "to_agent", "type": "string" }, + "3": { "name": "tool_name", "type": "string", "optional": true }, + "4": { "name": "input", "type": "string", "optional": true }, + "5": { "name": "reason", "type": "string", "optional": true } + } + } + } + }, + "cxdb.Assistant": { + "versions": { + "1": { + "fields": { + "1": { "name": "text", "type": "string" }, + "2": { "name": "reasoning", "type": "string", "optional": true }, + "3": { "name": "model", "type": "string", "optional": true }, + "4": { "name": "input_tokens", "type": "int64", "optional": true }, + "5": { "name": "output_tokens", "type": "int64", "optional": true }, + "6": { "name": "stop_reason", "type": "string", "optional": true } + } + } + } + }, + "cxdb.ToolCall": { + "versions": { + "1": { + "fields": { + "1": { "name": "call_id", "type": "string" }, + "2": { "name": "name", "type": "string" }, + "3": { "name": "args", "type": "string" }, + "4": { "name": "description", "type": "string", "optional": true } + } + } + } + }, + "cxdb.ToolResult": { + "versions": { + "1": { + "fields": { + "1": { "name": "call_id", "type": "string" }, + "2": { "name": "content", "type": "string" }, + "3": { "name": "is_error", "type": "bool" }, + "4": { "name": "exit_code", "type": "int64", "optional": true }, + "5": { "name": "streaming_output", "type": "string", "optional": true }, + "6": { "name": "output_truncated", "type": "bool", "optional": true }, + "7": { "name": "duration_ms", "type": "int64", "optional": true } + } + } + } + }, + "cxdb.ContextMetadata": { + "versions": { + "1": { + "fields": { + "1": { "name": "client_tag", "type": "string", "optional": true }, + "2": { "name": "title", "type": "string", "optional": true }, + "3": { "name": "labels", "type": "array", "items": "string", "optional": true }, + "4": { "name": "custom", "type": "map", "optional": true }, + "10": { "name": "provenance", "type": "ref", "ref": "cxdb.Provenance", "optional": true } + } + } + } + }, + "cxdb.Provenance": { + "versions": { + "1": { + "fields": { + "1": { "name": "parent_context_id", "type": "u64", "optional": true }, + "2": { "name": "spawn_reason", "type": "string", "optional": true }, + "3": { "name": "root_context_id", "type": "u64", "optional": true }, + "10": { "name": "trace_id", "type": "string", "optional": true }, + "11": { "name": "span_id", "type": "string", "optional": true }, + "12": { "name": "correlation_id", "type": "string", "optional": true }, + "20": { "name": "on_behalf_of", "type": "string", "optional": true }, + "21": { "name": "on_behalf_of_source", "type": "string", "optional": true }, + "22": { "name": "on_behalf_of_email", "type": "string", "optional": true }, + "30": { "name": "writer_method", "type": "string", "optional": true }, + "31": { "name": "writer_subject", "type": "string", "optional": true }, + "32": { "name": "writer_issuer", "type": "string", "optional": true }, + "40": { "name": "service_name", "type": "string", "optional": true }, + "41": { "name": "service_version", "type": "string", "optional": true }, + "42": { "name": "service_instance_id", "type": "string", "optional": true }, + "43": { "name": "process_pid", "type": "int64", "optional": true }, + "44": { "name": "process_owner", "type": "string", "optional": true }, + "45": { "name": "host_name", "type": "string", "optional": true }, + "46": { "name": "host_arch", "type": "string", "optional": true }, + "50": { "name": "client_address", "type": "string", "optional": true }, + "51": { "name": "client_port", "type": "int64", "optional": true }, + "60": { "name": "env_vars", "type": "map", "optional": true }, + "70": { "name": "sdk_name", "type": "string", "optional": true }, + "71": { "name": "sdk_version", "type": "string", "optional": true }, + "80": { "name": "captured_at", "type": "timestamp_ms", "optional": true } + } + } + } + } + } +} diff --git a/cxtx/src/cxdb_http.rs b/cxtx/src/cxdb_http.rs new file mode 100644 index 0000000..6206284 --- /dev/null +++ b/cxtx/src/cxdb_http.rs @@ -0,0 +1,716 @@ +use anyhow::{anyhow, Context, Result}; +use cxdb::types::{ + Assistant, AssistantTurn, ContextMetadata, ConversationItem, HandoffInfo, Provenance, + SystemMessage, ToolCall, ToolCallError, ToolCallItem, ToolCallResult, ToolResult, TurnMetrics, + TypeIDConversationItem, TypeVersionConversationItem, UserInput, +}; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use url::Url; + +const CONVERSATION_REGISTRY_BUNDLE: &str = include_str!("conversation_registry_bundle.json"); + +#[derive(Debug, Clone)] +pub struct CxdbHttpClient { + base_url: Url, + client: Client, + client_tag: String, + registry_ready: Arc, +} + +#[derive(Debug, Clone)] +pub enum CxdbError { + Retriable(String), + Permanent(String), +} + +#[derive(Debug, Deserialize)] +struct CreateContextResponse { + context_id: FlexibleId, +} + +#[derive(Debug, Deserialize)] +pub struct AppendResponse { + #[allow(dead_code)] + turn_id: FlexibleId, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum FlexibleId { + String(String), + Number(u64), +} + +impl FlexibleId { + fn into_u64(self) -> Result { + match self { + Self::String(value) => value + .parse::() + .with_context(|| format!("failed to parse identifier {value}")), + Self::Number(value) => Ok(value), + } + } +} + +impl CxdbHttpClient { + pub fn new(base_url: Url, client_tag: String) -> Result { + let client = Client::builder() + .build() + .context("failed to construct reqwest client")?; + Ok(Self { + base_url, + client, + client_tag, + registry_ready: Arc::new(AtomicBool::new(false)), + }) + } + + pub async fn create_context(&self) -> std::result::Result { + let url = self + .base_url + .join("/v1/contexts/create") + .map_err(|err| CxdbError::Permanent(err.to_string()))?; + let response = self + .client + .post(url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .json(&json!({ "base_turn_id": "0" })) + .send() + .await + .map_err(classify_reqwest_error)?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(classify_status(status, body)); + } + + let created = response + .json::() + .await + .map_err(|err| CxdbError::Permanent(err.to_string()))?; + created + .context_id + .into_u64() + .map_err(|err| CxdbError::Permanent(err.to_string())) + } + + pub async fn append_turn( + &self, + context_id: u64, + item: &ConversationItem, + ) -> std::result::Result { + self.ensure_conversation_type_registered().await?; + let url = self + .base_url + .join(&format!("/v1/contexts/{context_id}/append")) + .map_err(|err| CxdbError::Permanent(err.to_string()))?; + let response = self + .client + .post(url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .json(&json!({ + "type_id": TypeIDConversationItem, + "type_version": TypeVersionConversationItem, + "data": conversation_item_payload(item), + })) + .send() + .await + .map_err(classify_reqwest_error)?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(classify_status(status, body)); + } + + response + .json::() + .await + .map_err(|err| CxdbError::Permanent(err.to_string())) + } + + pub async fn list_contexts(&self) -> Result { + let url = self + .base_url + .join("/v1/contexts?include_provenance=1") + .context("failed to build contexts URL")?; + let response = self + .client + .get(url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .send() + .await + .context("request to list contexts failed")?; + if !response.status().is_success() { + return Err(anyhow!("list contexts failed: {}", response.status())); + } + response + .json() + .await + .context("failed to decode list contexts response") + } + + pub async fn get_provenance(&self, context_id: u64) -> Result { + let url = self + .base_url + .join(&format!("/v1/contexts/{context_id}/provenance")) + .context("failed to build provenance URL")?; + let response = self + .client + .get(url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .send() + .await + .context("request to get provenance failed")?; + if !response.status().is_success() { + return Err(anyhow!("provenance lookup failed: {}", response.status())); + } + response + .json() + .await + .context("failed to decode provenance response") + } + + async fn ensure_conversation_type_registered(&self) -> std::result::Result<(), CxdbError> { + if self.registry_ready.load(Ordering::Acquire) { + return Ok(()); + } + + let descriptor_url = self + .base_url + .join(&format!( + "/v1/registry/types/{TypeIDConversationItem}/versions/{TypeVersionConversationItem}" + )) + .map_err(|err| CxdbError::Permanent(err.to_string()))?; + let response = self + .client + .get(descriptor_url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .send() + .await + .map_err(classify_reqwest_error)?; + match response.status() { + status if status.is_success() => { + self.registry_ready.store(true, Ordering::Release); + return Ok(()); + } + StatusCode::NOT_FOUND => {} + status => { + let body = response.text().await.unwrap_or_default(); + return Err(classify_status(status, body)); + } + } + + let bundle: Value = serde_json::from_str(CONVERSATION_REGISTRY_BUNDLE) + .map_err(|err| CxdbError::Permanent(format!("invalid bundled registry json: {err}")))?; + let bundle_id = bundle + .get("bundle_id") + .and_then(Value::as_str) + .ok_or_else(|| { + CxdbError::Permanent("bundled registry missing bundle_id".to_string()) + })?; + let bundle_url = self + .base_url + .join(&format!("/v1/registry/bundles/{bundle_id}")) + .map_err(|err| CxdbError::Permanent(err.to_string()))?; + let response = self + .client + .put(bundle_url) + .header("X-CXDB-Client-Tag", &self.client_tag) + .json(&bundle) + .send() + .await + .map_err(classify_reqwest_error)?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(classify_status(status, body)); + } + + self.registry_ready.store(true, Ordering::Release); + Ok(()) + } +} + +fn classify_reqwest_error(err: reqwest::Error) -> CxdbError { + if err.is_connect() || err.is_timeout() || err.is_request() { + CxdbError::Retriable(err.to_string()) + } else { + CxdbError::Permanent(err.to_string()) + } +} + +fn classify_status(status: StatusCode, body: String) -> CxdbError { + if status.is_server_error() { + CxdbError::Retriable(format!("{status}: {body}")) + } else { + CxdbError::Permanent(format!("{status}: {body}")) + } +} + +fn conversation_item_payload(item: &ConversationItem) -> Value { + let mut obj = Map::new(); + obj.insert( + "item_type".to_string(), + Value::String(item.item_type.clone()), + ); + if !item.status.is_empty() { + obj.insert("status".to_string(), Value::String(item.status.clone())); + } + if item.timestamp != 0 { + obj.insert("timestamp".to_string(), Value::from(item.timestamp)); + } + if !item.id.is_empty() { + obj.insert("id".to_string(), Value::String(item.id.clone())); + } + if let Some(value) = item.user_input.as_ref() { + obj.insert("user_input".to_string(), user_input_payload(value)); + } + if let Some(value) = item.turn.as_ref() { + obj.insert("turn".to_string(), assistant_turn_payload(value)); + } + if let Some(value) = item.system.as_ref() { + obj.insert("system".to_string(), system_message_payload(value)); + } + if let Some(value) = item.handoff.as_ref() { + obj.insert("handoff".to_string(), handoff_payload(value)); + } + if let Some(value) = item.assistant.as_ref() { + obj.insert("assistant".to_string(), assistant_payload(value)); + } + if let Some(value) = item.tool_call.as_ref() { + obj.insert("tool_call".to_string(), tool_call_payload(value)); + } + if let Some(value) = item.tool_result.as_ref() { + obj.insert("tool_result".to_string(), tool_result_payload(value)); + } + if let Some(value) = item.context_metadata.as_ref() { + obj.insert( + "context_metadata".to_string(), + context_metadata_payload(value), + ); + } + Value::Object(obj) +} + +fn user_input_payload(value: &UserInput) -> Value { + let mut obj = Map::new(); + obj.insert("text".to_string(), Value::String(value.text.clone())); + if !value.files.is_empty() { + obj.insert( + "files".to_string(), + Value::Array( + value + .files + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ); + } + Value::Object(obj) +} + +fn assistant_turn_payload(value: &AssistantTurn) -> Value { + let mut obj = Map::new(); + obj.insert("text".to_string(), Value::String(value.text.clone())); + if !value.tool_calls.is_empty() { + obj.insert( + "tool_calls".to_string(), + Value::Array( + value + .tool_calls + .iter() + .map(tool_call_item_payload) + .collect::>(), + ), + ); + } + if !value.reasoning.is_empty() { + obj.insert( + "reasoning".to_string(), + Value::String(value.reasoning.clone()), + ); + } + if let Some(metrics) = value.metrics.as_ref() { + obj.insert("metrics".to_string(), turn_metrics_payload(metrics)); + } + if !value.agent.is_empty() { + obj.insert("agent".to_string(), Value::String(value.agent.clone())); + } + if value.turn_number != 0 { + obj.insert("turn_number".to_string(), Value::from(value.turn_number)); + } + if value.max_turns != 0 { + obj.insert("max_turns".to_string(), Value::from(value.max_turns)); + } + if !value.finish_reason.is_empty() { + obj.insert( + "finish_reason".to_string(), + Value::String(value.finish_reason.clone()), + ); + } + Value::Object(obj) +} + +fn tool_call_item_payload(value: &ToolCallItem) -> Value { + let mut obj = Map::new(); + obj.insert("id".to_string(), Value::String(value.id.clone())); + obj.insert("name".to_string(), Value::String(value.name.clone())); + obj.insert("args".to_string(), Value::String(value.args.clone())); + obj.insert("status".to_string(), Value::String(value.status.clone())); + if !value.description.is_empty() { + obj.insert( + "description".to_string(), + Value::String(value.description.clone()), + ); + } + if !value.streaming_output.is_empty() { + obj.insert( + "streaming_output".to_string(), + Value::String(value.streaming_output.clone()), + ); + } + if value.streaming_output_truncated { + obj.insert("streaming_output_truncated".to_string(), Value::Bool(true)); + } + if let Some(result) = value.result.as_ref() { + obj.insert("result".to_string(), tool_call_result_payload(result)); + } + if let Some(error) = value.error.as_ref() { + obj.insert("error".to_string(), tool_call_error_payload(error)); + } + if value.duration_ms != 0 { + obj.insert("duration_ms".to_string(), Value::from(value.duration_ms)); + } + Value::Object(obj) +} + +fn tool_call_result_payload(value: &ToolCallResult) -> Value { + let mut obj = Map::new(); + obj.insert("content".to_string(), Value::String(value.content.clone())); + if value.content_truncated { + obj.insert("content_truncated".to_string(), Value::Bool(true)); + } + obj.insert("success".to_string(), Value::Bool(value.success)); + if let Some(exit_code) = value.exit_code { + obj.insert("exit_code".to_string(), Value::from(exit_code)); + } + Value::Object(obj) +} + +fn tool_call_error_payload(value: &ToolCallError) -> Value { + let mut obj = Map::new(); + if !value.code.is_empty() { + obj.insert("code".to_string(), Value::String(value.code.clone())); + } + obj.insert("message".to_string(), Value::String(value.message.clone())); + if let Some(exit_code) = value.exit_code { + obj.insert("exit_code".to_string(), Value::from(exit_code)); + } + Value::Object(obj) +} + +fn turn_metrics_payload(value: &TurnMetrics) -> Value { + let mut obj = Map::new(); + obj.insert("input_tokens".to_string(), Value::from(value.input_tokens)); + obj.insert( + "output_tokens".to_string(), + Value::from(value.output_tokens), + ); + obj.insert("total_tokens".to_string(), Value::from(value.total_tokens)); + if let Some(cached_tokens) = value.cached_tokens { + obj.insert("cached_tokens".to_string(), Value::from(cached_tokens)); + } + if let Some(reasoning_tokens) = value.reasoning_tokens { + obj.insert( + "reasoning_tokens".to_string(), + Value::from(reasoning_tokens), + ); + } + if let Some(duration_ms) = value.duration_ms { + obj.insert("duration_ms".to_string(), Value::from(duration_ms)); + } + if !value.model.is_empty() { + obj.insert("model".to_string(), Value::String(value.model.clone())); + } + Value::Object(obj) +} + +fn system_message_payload(value: &SystemMessage) -> Value { + let mut obj = Map::new(); + obj.insert("kind".to_string(), Value::String(value.kind.clone())); + if !value.title.is_empty() { + obj.insert("title".to_string(), Value::String(value.title.clone())); + } + obj.insert("content".to_string(), Value::String(value.content.clone())); + Value::Object(obj) +} + +fn handoff_payload(value: &HandoffInfo) -> Value { + let mut obj = Map::new(); + obj.insert( + "from_agent".to_string(), + Value::String(value.from_agent.clone()), + ); + obj.insert( + "to_agent".to_string(), + Value::String(value.to_agent.clone()), + ); + if !value.tool_name.is_empty() { + obj.insert( + "tool_name".to_string(), + Value::String(value.tool_name.clone()), + ); + } + if !value.input.is_empty() { + obj.insert("input".to_string(), Value::String(value.input.clone())); + } + if !value.reason.is_empty() { + obj.insert("reason".to_string(), Value::String(value.reason.clone())); + } + Value::Object(obj) +} + +fn assistant_payload(value: &Assistant) -> Value { + let mut obj = Map::new(); + obj.insert("text".to_string(), Value::String(value.text.clone())); + if !value.reasoning.is_empty() { + obj.insert( + "reasoning".to_string(), + Value::String(value.reasoning.clone()), + ); + } + if !value.model.is_empty() { + obj.insert("model".to_string(), Value::String(value.model.clone())); + } + if value.input_tokens != 0 { + obj.insert("input_tokens".to_string(), Value::from(value.input_tokens)); + } + if value.output_tokens != 0 { + obj.insert( + "output_tokens".to_string(), + Value::from(value.output_tokens), + ); + } + if !value.stop_reason.is_empty() { + obj.insert( + "stop_reason".to_string(), + Value::String(value.stop_reason.clone()), + ); + } + Value::Object(obj) +} + +fn tool_call_payload(value: &ToolCall) -> Value { + let mut obj = Map::new(); + obj.insert("call_id".to_string(), Value::String(value.call_id.clone())); + obj.insert("name".to_string(), Value::String(value.name.clone())); + obj.insert("args".to_string(), Value::String(value.args.clone())); + if !value.description.is_empty() { + obj.insert( + "description".to_string(), + Value::String(value.description.clone()), + ); + } + Value::Object(obj) +} + +fn tool_result_payload(value: &ToolResult) -> Value { + let mut obj = Map::new(); + obj.insert("call_id".to_string(), Value::String(value.call_id.clone())); + obj.insert("content".to_string(), Value::String(value.content.clone())); + obj.insert("is_error".to_string(), Value::Bool(value.is_error)); + if let Some(exit_code) = value.exit_code { + obj.insert("exit_code".to_string(), Value::from(exit_code)); + } + if !value.streaming_output.is_empty() { + obj.insert( + "streaming_output".to_string(), + Value::String(value.streaming_output.clone()), + ); + } + if value.output_truncated { + obj.insert("output_truncated".to_string(), Value::Bool(true)); + } + if value.duration_ms != 0 { + obj.insert("duration_ms".to_string(), Value::from(value.duration_ms)); + } + Value::Object(obj) +} + +fn context_metadata_payload(value: &ContextMetadata) -> Value { + let mut obj = Map::new(); + if !value.client_tag.is_empty() { + obj.insert( + "client_tag".to_string(), + Value::String(value.client_tag.clone()), + ); + } + if !value.title.is_empty() { + obj.insert("title".to_string(), Value::String(value.title.clone())); + } + if !value.labels.is_empty() { + obj.insert( + "labels".to_string(), + Value::Array( + value + .labels + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ); + } + if !value.custom.is_empty() { + obj.insert("custom".to_string(), json!(value.custom)); + } + if let Some(provenance) = value.provenance.as_ref() { + obj.insert("provenance".to_string(), provenance_payload(provenance)); + } + Value::Object(obj) +} + +fn provenance_payload(value: &Provenance) -> Value { + let mut obj = Map::new(); + if let Some(parent_context_id) = value.parent_context_id { + obj.insert( + "parent_context_id".to_string(), + Value::from(parent_context_id), + ); + } + if !value.spawn_reason.is_empty() { + obj.insert( + "spawn_reason".to_string(), + Value::String(value.spawn_reason.clone()), + ); + } + if let Some(root_context_id) = value.root_context_id { + obj.insert("root_context_id".to_string(), Value::from(root_context_id)); + } + if !value.trace_id.is_empty() { + obj.insert( + "trace_id".to_string(), + Value::String(value.trace_id.clone()), + ); + } + if !value.span_id.is_empty() { + obj.insert("span_id".to_string(), Value::String(value.span_id.clone())); + } + if !value.correlation_id.is_empty() { + obj.insert( + "correlation_id".to_string(), + Value::String(value.correlation_id.clone()), + ); + } + if !value.on_behalf_of.is_empty() { + obj.insert( + "on_behalf_of".to_string(), + Value::String(value.on_behalf_of.clone()), + ); + } + if !value.on_behalf_of_source.is_empty() { + obj.insert( + "on_behalf_of_source".to_string(), + Value::String(value.on_behalf_of_source.clone()), + ); + } + if !value.on_behalf_of_email.is_empty() { + obj.insert( + "on_behalf_of_email".to_string(), + Value::String(value.on_behalf_of_email.clone()), + ); + } + if !value.writer_method.is_empty() { + obj.insert( + "writer_method".to_string(), + Value::String(value.writer_method.clone()), + ); + } + if !value.writer_subject.is_empty() { + obj.insert( + "writer_subject".to_string(), + Value::String(value.writer_subject.clone()), + ); + } + if !value.writer_issuer.is_empty() { + obj.insert( + "writer_issuer".to_string(), + Value::String(value.writer_issuer.clone()), + ); + } + if !value.service_name.is_empty() { + obj.insert( + "service_name".to_string(), + Value::String(value.service_name.clone()), + ); + } + if !value.service_version.is_empty() { + obj.insert( + "service_version".to_string(), + Value::String(value.service_version.clone()), + ); + } + if !value.service_instance_id.is_empty() { + obj.insert( + "service_instance_id".to_string(), + Value::String(value.service_instance_id.clone()), + ); + } + if value.process_pid != 0 { + obj.insert("process_pid".to_string(), Value::from(value.process_pid)); + } + if !value.process_owner.is_empty() { + obj.insert( + "process_owner".to_string(), + Value::String(value.process_owner.clone()), + ); + } + if !value.host_name.is_empty() { + obj.insert( + "host_name".to_string(), + Value::String(value.host_name.clone()), + ); + } + if !value.host_arch.is_empty() { + obj.insert( + "host_arch".to_string(), + Value::String(value.host_arch.clone()), + ); + } + if !value.client_address.is_empty() { + obj.insert( + "client_address".to_string(), + Value::String(value.client_address.clone()), + ); + } + if value.client_port != 0 { + obj.insert("client_port".to_string(), Value::from(value.client_port)); + } + if let Some(env_vars) = value.env_vars.as_ref() { + obj.insert("env_vars".to_string(), json!(env_vars)); + } + if !value.sdk_name.is_empty() { + obj.insert( + "sdk_name".to_string(), + Value::String(value.sdk_name.clone()), + ); + } + if !value.sdk_version.is_empty() { + obj.insert( + "sdk_version".to_string(), + Value::String(value.sdk_version.clone()), + ); + } + if value.captured_at != 0 { + obj.insert("captured_at".to_string(), Value::from(value.captured_at)); + } + Value::Object(obj) +} diff --git a/cxtx/src/delivery.rs b/cxtx/src/delivery.rs new file mode 100644 index 0000000..3803a8f --- /dev/null +++ b/cxtx/src/delivery.rs @@ -0,0 +1,270 @@ +use anyhow::{anyhow, Result}; +use std::collections::VecDeque; +use std::time::Duration; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::{sleep, Instant}; +use url::Url; + +use crate::cxdb_http::{CxdbError, CxdbHttpClient}; +use crate::ledger::SessionLedgerWriter; +use crate::session::SessionRuntime; +use crate::turns::TurnEnvelope; + +const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(250); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(5); +const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug)] +pub struct DeliveryHandle { + tx: mpsc::Sender, +} + +#[derive(Debug)] +enum WorkerMessage { + Enqueue(QueueItem), + Shutdown(oneshot::Sender<()>), +} + +#[derive(Debug, Clone)] +enum QueueItem { + CreateContext, + Append(TurnEnvelope), +} + +impl DeliveryHandle { + pub async fn start( + base_url: Url, + session: SessionRuntime, + ledger: SessionLedgerWriter, + client_tag: String, + ) -> Result { + let client = CxdbHttpClient::new(base_url, client_tag)?; + let (tx, rx) = mpsc::channel(1024); + let worker = DeliveryWorker::new(client, session, ledger, rx); + tokio::spawn(worker.run()); + Ok(Self { tx }) + } + + pub async fn enqueue_create_context(&self) -> Result<()> { + self.tx + .send(WorkerMessage::Enqueue(QueueItem::CreateContext)) + .await + .map_err(|_| anyhow!("delivery worker is no longer running")) + } + + pub async fn enqueue_turn(&self, turn: TurnEnvelope) -> Result<()> { + self.tx + .send(WorkerMessage::Enqueue(QueueItem::Append(turn))) + .await + .map_err(|_| anyhow!("delivery worker is no longer running")) + } + + pub async fn shutdown(&self) -> Result<()> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(WorkerMessage::Shutdown(tx)) + .await + .map_err(|_| anyhow!("delivery worker is no longer running"))?; + rx.await + .map_err(|_| anyhow!("delivery worker shutdown acknowledgement dropped")) + } +} + +struct DeliveryWorker { + client: CxdbHttpClient, + session: SessionRuntime, + ledger: SessionLedgerWriter, + queue: VecDeque, + context_id: Option, + degraded: bool, + retry_delay: Duration, + rx: mpsc::Receiver, + shutdown: Option>, + shutdown_deadline: Option, + recovery_turn_enqueued: bool, +} + +impl DeliveryWorker { + fn new( + client: CxdbHttpClient, + session: SessionRuntime, + ledger: SessionLedgerWriter, + rx: mpsc::Receiver, + ) -> Self { + Self { + client, + session, + ledger, + queue: VecDeque::new(), + context_id: None, + degraded: false, + retry_delay: INITIAL_RETRY_DELAY, + rx, + shutdown: None, + shutdown_deadline: None, + recovery_turn_enqueued: false, + } + } + + async fn run(mut self) { + loop { + if self.maybe_finish_shutdown().await { + return; + } + + if self.queue.is_empty() { + match self.rx.recv().await { + Some(message) => self.handle_message(message).await, + None => return, + } + continue; + } + + while let Ok(message) = self.rx.try_recv() { + self.handle_message(message).await; + } + + let Some(item) = self.queue.front().cloned() else { + continue; + }; + + match self.process_item(item.clone()).await { + Ok(()) => { + self.queue.pop_front(); + self.retry_delay = INITIAL_RETRY_DELAY; + + if self.degraded && self.queue.is_empty() && !self.recovery_turn_enqueued { + self.recovery_turn_enqueued = true; + self.queue + .push_back(QueueItem::Append(self.session.ingest_recovered_turn(0))); + } else if self.degraded + && self.recovery_turn_enqueued + && matches!(item, QueueItem::Append(_)) + && self.queue.is_empty() + { + self.degraded = false; + self.recovery_turn_enqueued = false; + self.ledger + .note_delivery_state("healthy", 0, None) + .await + .ok(); + eprintln!("cxtx: CXDB ingest recovered; queued turns delivered"); + } + } + Err(err) => { + self.enter_degraded(&err).await; + let deadline = self + .shutdown_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())); + let sleep_for = deadline + .map(|remaining| remaining.min(self.retry_delay)) + .unwrap_or(self.retry_delay); + sleep(sleep_for).await; + self.retry_delay = (self.retry_delay * 2).min(MAX_RETRY_DELAY); + } + } + } + } + + async fn handle_message(&mut self, message: WorkerMessage) { + match message { + WorkerMessage::Enqueue(item) => { + self.queue.push_back(item); + self.ledger + .note_delivery_state( + if self.degraded { "degraded" } else { "healthy" }, + self.queue.len(), + None, + ) + .await + .ok(); + } + WorkerMessage::Shutdown(tx) => { + self.shutdown = Some(tx); + self.shutdown_deadline = Some(Instant::now() + SHUTDOWN_DRAIN_TIMEOUT); + } + } + } + + async fn process_item(&mut self, item: QueueItem) -> std::result::Result<(), String> { + match item { + QueueItem::CreateContext => match self.client.create_context().await { + Ok(context_id) => { + self.context_id = Some(context_id); + self.ledger.note_context_created(context_id).await.ok(); + Ok(()) + } + Err(err) => Err(error_string(err)), + }, + QueueItem::Append(turn) => { + let context_id = self + .context_id + .ok_or_else(|| "context creation has not completed".to_string())?; + match self.client.append_turn(context_id, &turn.item).await { + Ok(_) => { + self.ledger.note_append_sequence(turn.ordinal).await.ok(); + Ok(()) + } + Err(err) => Err(error_string(err)), + } + } + } + } + + async fn enter_degraded(&mut self, error: &str) { + self.ledger + .note_delivery_state("degraded", self.queue.len(), Some(error.to_string())) + .await + .ok(); + + if self.degraded { + return; + } + + self.degraded = true; + self.recovery_turn_enqueued = false; + self.queue.push_back(QueueItem::Append( + self.session.ingest_degraded_turn(self.queue.len(), error), + )); + eprintln!("cxtx: CXDB ingest unavailable, entering queued-delivery mode"); + } + + async fn maybe_finish_shutdown(&mut self) -> bool { + if self.shutdown.is_none() { + return false; + } + + if let Some(deadline) = self.shutdown_deadline { + if Instant::now() >= deadline { + self.ledger + .note_delivery_state( + if self.degraded { "degraded" } else { "healthy" }, + self.queue.len(), + Some("shutdown drain deadline reached".to_string()), + ) + .await + .ok(); + } + } + + if self.queue.is_empty() + || self + .shutdown_deadline + .map(|deadline| Instant::now() >= deadline) + .unwrap_or(false) + { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + return true; + } + + false + } +} + +fn error_string(err: CxdbError) -> String { + match err { + CxdbError::Retriable(err) | CxdbError::Permanent(err) => err, + } +} diff --git a/cxtx/src/ledger.rs b/cxtx/src/ledger.rs new file mode 100644 index 0000000..9e6842d --- /dev/null +++ b/cxtx/src/ledger.rs @@ -0,0 +1,319 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::fs::{self, OpenOptions}; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +use crate::session::SessionRuntime; + +#[derive(Debug, Serialize, Clone, Default)] +pub struct ExchangeArtifactRecord { + pub exchange_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_path: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct SessionLedger { + pub session_id: String, + pub provider_kind: String, + pub child_command: String, + pub child_args: Vec, + pub started_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub ended_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub child_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub child_exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cxdb_context_id: Option, + pub delivery_state: String, + pub queue_depth: usize, + pub appended_sequences: Vec, + pub provider_request_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_delivery_error: Option, + pub artifacts_root: String, + pub exchanges: Vec, +} + +#[derive(Clone, Debug)] +pub struct SessionLedgerWriter { + root: PathBuf, + path: PathBuf, + exchanges_dir: PathBuf, + state: Arc>, +} + +impl SessionLedgerWriter { + pub async fn create(session: &SessionRuntime) -> Result { + let root = Path::new(".scratch") + .join("cxtx") + .join("sessions") + .join(session.session_id()); + let exchanges_dir = root.join("exchanges"); + fs::create_dir_all(&exchanges_dir) + .await + .with_context(|| format!("failed to create {}", exchanges_dir.display()))?; + + let ledger = SessionLedger { + session_id: session.session_id().to_string(), + provider_kind: session.provider().provider_name().to_string(), + child_command: session.session().child_command.clone(), + child_args: session.session().child_args.clone(), + started_at: session.session().started_at, + ended_at: None, + child_pid: None, + child_exit_code: None, + cxdb_context_id: None, + delivery_state: "starting".to_string(), + queue_depth: 0, + appended_sequences: Vec::new(), + provider_request_ids: Vec::new(), + last_delivery_error: None, + artifacts_root: exchanges_dir.display().to_string(), + exchanges: Vec::new(), + }; + let path = root.join("ledger.json"); + let writer = Self { + root, + path, + exchanges_dir, + state: Arc::new(Mutex::new(ledger)), + }; + writer.persist().await?; + Ok(writer) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub async fn note_child_pid(&self, child_pid: Option) -> Result<()> { + let mut state = self.state.lock().await; + state.child_pid = child_pid; + drop(state); + self.persist().await + } + + pub async fn note_context_created(&self, context_id: u64) -> Result<()> { + let mut state = self.state.lock().await; + state.cxdb_context_id = Some(context_id); + drop(state); + self.persist().await + } + + pub async fn note_append_sequence(&self, sequence: u64) -> Result<()> { + let mut state = self.state.lock().await; + if !state.appended_sequences.contains(&sequence) { + state.appended_sequences.push(sequence); + } + drop(state); + self.persist().await + } + + pub async fn note_delivery_state( + &self, + delivery_state: impl Into, + queue_depth: usize, + error: Option, + ) -> Result<()> { + let mut state = self.state.lock().await; + state.delivery_state = delivery_state.into(); + state.queue_depth = queue_depth; + state.last_delivery_error = error; + drop(state); + self.persist().await + } + + pub async fn note_request_id(&self, request_id: String) -> Result<()> { + let mut state = self.state.lock().await; + if !state.provider_request_ids.contains(&request_id) { + state.provider_request_ids.push(request_id); + } + drop(state); + self.persist().await + } + + pub async fn note_child_exit(&self, exit_code: i32) -> Result<()> { + let mut state = self.state.lock().await; + state.child_exit_code = Some(exit_code); + state.ended_at = Some(Utc::now()); + drop(state); + self.persist().await + } + + pub async fn record_request( + &self, + exchange_id: &str, + endpoint: &str, + model: Option<&str>, + content_type: Option<&str>, + body: &[u8], + parsed_json: Option<&Value>, + ) -> Result { + let exchange_dir = self.exchange_dir(exchange_id).await?; + let path = + write_body_artifact(&exchange_dir, "request", content_type, body, parsed_json).await?; + self.update_exchange(exchange_id, |record| { + record.endpoint = Some(endpoint.to_string()); + record.model = model.map(|value| value.to_string()); + record.request_path = Some(path.display().to_string()); + }) + .await?; + Ok(path.display().to_string()) + } + + pub async fn record_response( + &self, + exchange_id: &str, + status_code: u16, + provider_request_id: Option<&str>, + content_type: Option<&str>, + body: &[u8], + parsed_json: Option<&Value>, + ) -> Result { + let exchange_dir = self.exchange_dir(exchange_id).await?; + let path = + write_body_artifact(&exchange_dir, "response", content_type, body, parsed_json).await?; + self.update_exchange(exchange_id, |record| { + record.status_code = Some(status_code); + record.provider_request_id = provider_request_id.map(|value| value.to_string()); + record.response_path = Some(path.display().to_string()); + }) + .await?; + if let Some(request_id) = provider_request_id { + self.note_request_id(request_id.to_string()).await?; + } + Ok(path.display().to_string()) + } + + pub async fn append_stream_frame(&self, exchange_id: &str, raw_frame: &str) -> Result { + let exchange_dir = self.exchange_dir(exchange_id).await?; + let path = exchange_dir.join("stream.ndjson"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await + .with_context(|| format!("failed to open {}", path.display()))?; + let payload = serde_json::to_vec(&serde_json::json!({ "frame": raw_frame })) + .context("failed to encode stream frame")?; + file.write_all(&payload) + .await + .with_context(|| format!("failed to write {}", path.display()))?; + file.write_all(b"\n") + .await + .with_context(|| format!("failed to write newline to {}", path.display()))?; + file.flush().await.ok(); + self.update_exchange(exchange_id, |record| { + record.stream_path = Some(path.display().to_string()); + }) + .await?; + Ok(path.display().to_string()) + } + + pub async fn finalize(&self) -> Result<()> { + let mut state = self.state.lock().await; + state.ended_at.get_or_insert_with(Utc::now); + drop(state); + self.persist().await + } + + async fn update_exchange( + &self, + exchange_id: &str, + mut update: impl FnMut(&mut ExchangeArtifactRecord), + ) -> Result<()> { + let mut state = self.state.lock().await; + let record = if let Some(record) = state + .exchanges + .iter_mut() + .find(|record| record.exchange_id == exchange_id) + { + record + } else { + state.exchanges.push(ExchangeArtifactRecord { + exchange_id: exchange_id.to_string(), + ..ExchangeArtifactRecord::default() + }); + state + .exchanges + .last_mut() + .expect("exchange record inserted") + }; + update(record); + drop(state); + self.persist().await + } + + async fn exchange_dir(&self, exchange_id: &str) -> Result { + let path = self.exchanges_dir.join(exchange_id); + fs::create_dir_all(&path) + .await + .with_context(|| format!("failed to create {}", path.display()))?; + Ok(path) + } + + async fn persist(&self) -> Result<()> { + let state = self.state.lock().await.clone(); + let payload = + serde_json::to_vec_pretty(&state).context("failed to serialize session ledger")?; + fs::create_dir_all(&self.root) + .await + .with_context(|| format!("failed to create {}", self.root.display()))?; + fs::write(&self.path, payload) + .await + .with_context(|| format!("failed to write {}", self.path.display())) + } +} + +async fn write_body_artifact( + exchange_dir: &Path, + stem: &str, + content_type: Option<&str>, + body: &[u8], + parsed_json: Option<&Value>, +) -> Result { + let path = if let Some(json) = parsed_json { + let path = exchange_dir.join(format!("{stem}.json")); + let payload = serde_json::to_vec_pretty(json).context("failed to encode json artifact")?; + fs::write(&path, payload) + .await + .with_context(|| format!("failed to write {}", path.display()))?; + path + } else if content_type + .map(|value| value.starts_with("text/") || value.contains("event-stream")) + .unwrap_or(false) + { + let path = exchange_dir.join(format!("{stem}.txt")); + fs::write(&path, body) + .await + .with_context(|| format!("failed to write {}", path.display()))?; + path + } else { + let path = exchange_dir.join(format!("{stem}.bin")); + fs::write(&path, body) + .await + .with_context(|| format!("failed to write {}", path.display()))?; + path + }; + Ok(path) +} diff --git a/cxtx/src/lib.rs b/cxtx/src/lib.rs new file mode 100644 index 0000000..16d54a4 --- /dev/null +++ b/cxtx/src/lib.rs @@ -0,0 +1,99 @@ +pub mod cli; +pub mod cxdb_http; +pub mod delivery; +pub mod ledger; +pub mod provider; +pub mod proxy; +pub mod session; +pub mod turns; + +use anyhow::{Context, Result}; +use cli::Cli; +use delivery::DeliveryHandle; +use ledger::SessionLedgerWriter; +use provider::ProviderKind; +use proxy::ProxyServer; +use session::SessionRuntime; +use std::process::Stdio; +use tokio::process::Command; + +pub async fn run(cli: Cli) -> Result { + let provider = cli.command.provider(); + let args = cli.command.args().to_vec(); + let cxdb_url = cli + .url + .parse() + .with_context(|| format!("invalid CXDB URL: {}", cli.url))?; + + let upstream = provider + .resolve_upstream_base() + .context("failed to resolve provider upstream base URL")?; + let allowlisted_env = provider.capture_env_allowlist(); + let session = SessionRuntime::new(provider, args.clone(), allowlisted_env)?; + let ledger = SessionLedgerWriter::create(&session).await?; + + let proxy = ProxyServer::start(provider, upstream, session.clone(), ledger.clone()) + .await + .context("failed to start local reverse proxy")?; + let delivery = DeliveryHandle::start( + cxdb_url, + session.clone(), + ledger.clone(), + provider.client_tag().to_string(), + ) + .await?; + proxy.set_delivery(delivery.clone()).await; + + let mut command = Command::new(provider.command_name()); + command.args(&args); + command.stdin(Stdio::inherit()); + command.stdout(Stdio::inherit()); + command.stderr(Stdio::inherit()); + command.envs(provider.injected_env(&proxy.proxy_base_url())); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => { + ledger + .note_delivery_state("child_launch_failed", 0, Some(err.to_string())) + .await + .ok(); + delivery.shutdown().await.ok(); + proxy.shutdown().await.ok(); + ledger.finalize().await.ok(); + return Err(err).with_context(|| { + format!( + "failed to launch {} using PATH resolution", + provider.command_name() + ) + }); + } + }; + session.set_child_pid(child.id()); + ledger.note_child_pid(child.id()).await?; + + delivery.enqueue_create_context().await?; + delivery.enqueue_turn(session.session_start_turn()).await?; + + let status = child.wait().await?; + let exit_code = status.code().unwrap_or(1); + + delivery + .enqueue_turn(session.session_end_turn(exit_code, status.success())) + .await?; + ledger.note_child_exit(exit_code).await?; + + proxy.shutdown().await?; + delivery.shutdown().await?; + ledger.finalize().await?; + + Ok(exit_code) +} + +pub async fn run_provider_command( + provider: ProviderKind, + args: Vec, + url: &str, +) -> Result { + run(Cli::for_tests(provider, args, url)).await +} diff --git a/cxtx/src/main.rs b/cxtx/src/main.rs new file mode 100644 index 0000000..e2f75ed --- /dev/null +++ b/cxtx/src/main.rs @@ -0,0 +1,13 @@ +use clap::Parser; + +#[tokio::main] +async fn main() { + let cli = cxtx::cli::Cli::parse(); + match cxtx::run(cli).await { + Ok(code) => std::process::exit(code), + Err(err) => { + eprintln!("cxtx: {err:#}"); + std::process::exit(1); + } + } +} diff --git a/cxtx/src/provider/anthropic.rs b/cxtx/src/provider/anthropic.rs new file mode 100644 index 0000000..af40525 --- /dev/null +++ b/cxtx/src/provider/anthropic.rs @@ -0,0 +1,586 @@ +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::provider::{ExchangeState, PreparedExchange}; +use crate::session::SessionRuntime; +use crate::turns::{tool_call_record, ArtifactRefs, HistoryItem, TurnEnvelope}; + +pub use crate::provider::openai::{parse_sse_buffer, SseFrame}; + +#[derive(Debug)] +pub struct AnthropicExchange { + pub exchange_id: String, + pub model: Option, + blocks: BTreeMap, + finish_reason: Option, + parse_errors: Vec, +} + +#[derive(Debug, Clone)] +enum PartialBlock { + Text(String), + ToolUse(PartialToolUse), +} + +#[derive(Debug, Clone, Default)] +struct PartialToolUse { + id: String, + name: String, + input_json: String, +} + +pub fn prepare_exchange( + session: &SessionRuntime, + exchange_id: String, + body: &[u8], + artifact_refs: &ArtifactRefs, +) -> PreparedExchange { + let payload = match serde_json::from_slice::(body) { + Ok(payload) => payload, + Err(err) => { + let turns = vec![session.provider_error_turn( + &exchange_id, + "request_parse_error", + &format!("failed to parse Anthropic request body: {err}"), + None, + artifact_refs, + )]; + return PreparedExchange { + exchange_id: exchange_id.clone(), + model: None, + request_turns: turns, + state: ExchangeState::Anthropic(AnthropicExchange::new(exchange_id, None)), + }; + } + }; + + let model = payload + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let request_turns = match parse_request_history(&payload) { + Ok(history) => session.observe_request_history(&exchange_id, history, artifact_refs), + Err(err) => vec![session.provider_error_turn( + &exchange_id, + "request_history_parse_error", + &err, + None, + artifact_refs, + )], + }; + + PreparedExchange { + exchange_id: exchange_id.clone(), + model: model.clone(), + request_turns, + state: ExchangeState::Anthropic(AnthropicExchange::new(exchange_id, model)), + } +} + +pub fn finalize_json( + session: &SessionRuntime, + exchange: AnthropicExchange, + status: u16, + request_id: Option, + body: &[u8], + artifact_refs: &ArtifactRefs, +) -> Vec { + if status >= 400 { + let body_excerpt = String::from_utf8_lossy(body); + return vec![session.provider_error_turn( + &exchange.exchange_id, + "provider_error_response", + &format!( + "Anthropic upstream returned HTTP {status}: {}", + body_excerpt.trim() + ), + request_id.as_deref(), + artifact_refs, + )]; + } + + let payload = match serde_json::from_slice::(body) { + Ok(payload) => payload, + Err(err) => { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "response_parse_error", + &format!("failed to parse Anthropic response body: {err}"), + request_id.as_deref(), + artifact_refs, + )]; + } + }; + + match parse_assistant_content( + payload.get("content").unwrap_or(&Value::Null), + payload + .get("model") + .and_then(Value::as_str) + .or(exchange.model.as_deref()), + payload.get("stop_reason").and_then(Value::as_str), + ) { + Ok(Some(item)) => vec![session.append_history_item(&exchange.exchange_id, item)], + Ok(None) => Vec::new(), + Err(err) => vec![session.provider_error_turn( + &exchange.exchange_id, + "response_extract_error", + &err, + request_id.as_deref(), + artifact_refs, + )], + } +} + +pub fn finalize_stream( + session: &SessionRuntime, + exchange: AnthropicExchange, + status: u16, + request_id: Option, + artifact_refs: &ArtifactRefs, + malformed_remainder: Option, +) -> Vec { + if let Some(remainder) = malformed_remainder.filter(|remainder| !remainder.trim().is_empty()) { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "malformed_sse_remainder", + &format!("Anthropic stream ended with leftover buffer: {remainder}"), + request_id.as_deref(), + artifact_refs, + )]; + } + + if !exchange.parse_errors.is_empty() { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "stream_parse_error", + &exchange.parse_errors.join("; "), + request_id.as_deref(), + artifact_refs, + )]; + } + + if status >= 400 { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "provider_error_stream", + &format!("Anthropic upstream returned HTTP {status} during stream"), + request_id.as_deref(), + artifact_refs, + )]; + } + + let mut blocks = exchange.blocks.into_iter().collect::>(); + blocks.sort_by_key(|(index, _)| *index); + if blocks.is_empty() { + return Vec::new(); + } + + let mut text = String::new(); + let mut tool_calls = Vec::new(); + for (_, block) in blocks { + match block { + PartialBlock::Text(value) => text.push_str(&value), + PartialBlock::ToolUse(tool) => { + tool_calls.push(tool_call_record(tool.id, tool.name, tool.input_json)) + } + } + } + + vec![session.append_history_item( + &exchange.exchange_id, + HistoryItem::AssistantTurn { + text, + tool_calls, + model: exchange.model, + finish_reason: exchange.finish_reason, + }, + )] +} + +impl AnthropicExchange { + fn new(exchange_id: String, model: Option) -> Self { + Self { + exchange_id, + model, + blocks: BTreeMap::new(), + finish_reason: None, + parse_errors: Vec::new(), + } + } + + pub fn absorb_sse_frame(&mut self, frame: &SseFrame) { + let payload = match serde_json::from_str::(&frame.data) { + Ok(payload) => payload, + Err(err) => { + self.parse_errors + .push(format!("failed to parse Anthropic stream frame: {err}")); + return; + } + }; + + match frame.event.as_deref() { + Some("message_start") => { + if self.model.is_none() { + self.model = payload + .get("message") + .and_then(|message| message.get("model")) + .and_then(Value::as_str) + .map(|value| value.to_string()); + } + } + Some("content_block_start") => { + let index = payload.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let block = payload.get("content_block").unwrap_or(&Value::Null); + match block.get("type").and_then(Value::as_str) { + Some("text") => { + self.blocks.insert( + index, + PartialBlock::Text( + block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ), + ); + } + Some("tool_use") => { + self.blocks.insert( + index, + PartialBlock::ToolUse(PartialToolUse { + id: block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + name: block + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + input_json: block + .get("input") + .map(jsonish_to_string) + .unwrap_or_default(), + }), + ); + } + _ => {} + } + } + Some("content_block_delta") => { + let index = payload.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + if let Some(delta) = payload.get("delta") { + match delta.get("type").and_then(Value::as_str) { + Some("text_delta") => { + let text = delta + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(); + let entry = self + .blocks + .entry(index) + .or_insert_with(|| PartialBlock::Text(String::new())); + if let PartialBlock::Text(value) = entry { + value.push_str(text); + } + } + Some("input_json_delta") => { + let partial = delta + .get("partial_json") + .and_then(Value::as_str) + .unwrap_or_default(); + let entry = self.blocks.entry(index).or_insert_with(|| { + PartialBlock::ToolUse(PartialToolUse::default()) + }); + if let PartialBlock::ToolUse(value) = entry { + value.input_json.push_str(partial); + } + } + _ => {} + } + } + } + Some("message_delta") => { + if self.finish_reason.is_none() { + self.finish_reason = payload + .get("delta") + .and_then(|delta| delta.get("stop_reason")) + .and_then(Value::as_str) + .map(|value| value.to_string()); + } + } + _ => {} + } + } +} + +fn parse_request_history(payload: &Value) -> Result, String> { + let messages = payload + .get("messages") + .and_then(Value::as_array) + .ok_or_else(|| "Anthropic request is missing messages array".to_string())?; + let model = payload + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + + let mut history = Vec::new(); + for message in messages { + let role = message + .get("role") + .and_then(Value::as_str) + .ok_or_else(|| "Anthropic request message missing role".to_string())?; + let content = message.get("content").unwrap_or(&Value::Null); + match role { + "user" => history.extend(parse_user_blocks(content)?), + "assistant" => { + if let Some(item) = parse_assistant_content(content, model.as_deref(), None)? { + history.push(item); + } + } + _ => {} + } + } + Ok(history) +} + +fn parse_user_blocks(content: &Value) -> Result, String> { + match content { + Value::String(value) => Ok(vec![HistoryItem::UserInput { + text: value.clone(), + files: Vec::new(), + }]), + Value::Array(blocks) => { + let mut history = Vec::new(); + let mut text_buffer = String::new(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + let text = block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(); + if !text_buffer.is_empty() { + text_buffer.push('\n'); + } + text_buffer.push_str(text); + } + Some("tool_result") => { + if !text_buffer.is_empty() { + history.push(HistoryItem::UserInput { + text: std::mem::take(&mut text_buffer), + files: Vec::new(), + }); + } + history.push(HistoryItem::ToolResult { + call_id: block + .get("tool_use_id") + .or_else(|| block.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content: block + .get("content") + .map(content_to_text) + .unwrap_or_default(), + is_error: block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + }); + } + _ => {} + } + } + if !text_buffer.is_empty() { + history.push(HistoryItem::UserInput { + text: text_buffer, + files: Vec::new(), + }); + } + Ok(history) + } + _ => Ok(Vec::new()), + } +} + +fn parse_assistant_content( + content: &Value, + model: Option<&str>, + finish_reason: Option<&str>, +) -> Result, String> { + match content { + Value::Null => Ok(None), + Value::String(value) => Ok(Some(HistoryItem::AssistantTurn { + text: value.clone(), + tool_calls: Vec::new(), + model: model.map(|value| value.to_string()), + finish_reason: finish_reason.map(|value| value.to_string()), + })), + Value::Array(blocks) => { + let mut text = String::new(); + let mut tool_calls = Vec::new(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => text.push_str( + block + .get("text") + .and_then(Value::as_str) + .unwrap_or_default(), + ), + Some("tool_use") => tool_calls.push(tool_call_record( + block + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + block + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + block + .get("input") + .map(jsonish_to_string) + .unwrap_or_default(), + )), + _ => {} + } + } + Ok(Some(HistoryItem::AssistantTurn { + text, + tool_calls, + model: model.map(|value| value.to_string()), + finish_reason: finish_reason.map(|value| value.to_string()), + })) + } + _ => Err("unsupported Anthropic content shape".to_string()), + } +} + +fn content_to_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + Value::Array(values) => values + .iter() + .map(content_to_text) + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"), + Value::Object(map) => map + .get("text") + .and_then(Value::as_str) + .map(|value| value.to_string()) + .or_else(|| map.get("content").map(content_to_text)) + .unwrap_or_else(|| jsonish_to_string(value)), + _ => jsonish_to_string(value), + } +} + +fn jsonish_to_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + _ => serde_json::to_string(value).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::{parse_request_history, parse_sse_buffer, AnthropicExchange, SseFrame}; + use crate::turns::HistoryItem; + use serde_json::json; + + #[test] + fn parses_anthropic_sse_frames() { + let mut buffer = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\"}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\"}\n\n" + ) + .to_string(); + let frames = parse_sse_buffer(&mut buffer); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].event.as_deref(), Some("message_start")); + assert_eq!(frames[1].event.as_deref(), Some("message_delta")); + assert!(buffer.is_empty()); + } + + #[test] + fn request_history_extracts_user_and_tool_result_blocks() { + struct Case { + name: &'static str, + payload: serde_json::Value, + expected_kinds: Vec<&'static str>, + } + + let cases = vec![ + Case { + name: "user blocks plus assistant tool use", + payload: json!({ + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hello"}, {"type": "tool_result", "tool_use_id": "call_1", "content": [{"type": "text", "text": "done"}]}]}, + {"role": "assistant", "content": [{"type": "text", "text": "working"}, {"type": "tool_use", "id": "call_1", "name": "lookup", "input": {"q": "hello"}}]} + ] + }), + expected_kinds: vec!["user_input", "tool_result", "assistant_turn"], + }, + Case { + name: "string user content stays a single user turn", + payload: json!({ + "model": "claude-sonnet", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]} + ] + }), + expected_kinds: vec!["user_input", "assistant_turn"], + }, + ]; + + for case in cases { + let history = parse_request_history(&case.payload).unwrap(); + let kinds = history + .iter() + .map(|item| match item { + HistoryItem::UserInput { .. } => "user_input", + HistoryItem::AssistantTurn { .. } => "assistant_turn", + HistoryItem::ToolResult { .. } => "tool_result", + }) + .collect::>(); + assert_eq!(kinds, case.expected_kinds, "case {}", case.name); + } + } + + #[test] + fn stream_accumulator_collects_text_and_tool_use_blocks() { + let mut exchange = + AnthropicExchange::new("exchange-0001".to_string(), Some("claude".to_string())); + exchange.absorb_sse_frame(&SseFrame { + event: Some("content_block_start".to_string()), + data: "{\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hel\"}}" + .to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: Some("content_block_delta".to_string()), + data: "{\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: Some("content_block_start".to_string()), + data: "{\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"lookup\"}}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: Some("content_block_delta".to_string()), + data: "{\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"q\\\":\\\"hello\\\"}\"}}".to_string(), + raw: String::new(), + }); + assert_eq!(exchange.blocks.len(), 2); + } +} diff --git a/cxtx/src/provider/mod.rs b/cxtx/src/provider/mod.rs new file mode 100644 index 0000000..1b86b7d --- /dev/null +++ b/cxtx/src/provider/mod.rs @@ -0,0 +1,374 @@ +pub mod anthropic; +pub mod openai; + +use anyhow::{anyhow, Context, Result}; +use http::Uri; +use serde_json::Value; +use std::collections::BTreeMap; +use std::env; +use url::Url; + +use crate::session::SessionRuntime; +use crate::turns::{ArtifactRefs, TurnEnvelope}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKind { + Codex, + Claude, +} + +#[derive(Debug)] +pub struct PreparedExchange { + pub exchange_id: String, + pub model: Option, + pub request_turns: Vec, + pub state: ExchangeState, +} + +#[derive(Debug)] +pub enum ExchangeState { + OpenAi(openai::OpenAiExchange), + Anthropic(anthropic::AnthropicExchange), +} + +impl ProviderKind { + pub fn command_name(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + } + } + + pub fn provider_name(self) -> &'static str { + match self { + Self::Codex => "openai", + Self::Claude => "anthropic", + } + } + + pub fn client_tag(self) -> &'static str { + match self { + Self::Codex => "cxtx/codex", + Self::Claude => "cxtx/claude", + } + } + + pub fn labels(self) -> Vec { + vec![ + "cxtx".to_string(), + match self { + Self::Codex => "codex", + Self::Claude => "claude", + } + .to_string(), + "interactive".to_string(), + ] + } + + pub fn resolve_upstream_base(self) -> Result { + let env_names = self.upstream_base_env_names(); + let value = env_names + .iter() + .find_map(|name| env::var(name).ok().filter(|v| !v.trim().is_empty())); + let default = match self { + Self::Codex => "https://api.openai.com/v1", + Self::Claude => "https://api.anthropic.com", + }; + let parsed = Url::parse(value.as_deref().unwrap_or(default)).with_context(|| { + format!( + "failed to parse upstream URL from {}", + value.unwrap_or_else(|| default.to_string()) + ) + })?; + Ok(parsed) + } + + pub fn capture_env_allowlist(self) -> BTreeMap { + let mut out = BTreeMap::new(); + for name in [ + "HOME", + "LOGNAME", + "PWD", + "SHELL", + "TERM", + "USER", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_URL", + "ANTHROPIC_API_BASE", + "CLAUDE_BASE_URL", + "CLAUDE_API_BASE", + "CLAUDE_CODE_BASE_URL", + ] { + if let Ok(value) = env::var(name) { + if !value.trim().is_empty() { + out.insert(name.to_string(), value); + } + } + } + out + } + + pub fn injected_env(self, proxy_base_url: &Url) -> Vec<(String, String)> { + let value = proxy_base_url.as_str().trim_end_matches('/').to_string(); + match self { + Self::Codex => vec![ + ("OPENAI_BASE_URL".to_string(), value.clone()), + ("OPENAI_API_BASE".to_string(), value), + ], + Self::Claude => { + let root = proxy_base_url.origin().unicode_serialization(); + vec![ + ("ANTHROPIC_BASE_URL".to_string(), root.clone()), + ("ANTHROPIC_API_URL".to_string(), root.clone()), + ("ANTHROPIC_API_BASE".to_string(), root.clone()), + ("CLAUDE_BASE_URL".to_string(), root.clone()), + ("CLAUDE_API_BASE".to_string(), root.clone()), + ("CLAUDE_CODE_BASE_URL".to_string(), root), + ] + } + } + } + + pub fn proxy_mount_path(self, upstream_base: &Url) -> String { + match self { + Self::Codex => normalize_path(upstream_base.path()), + Self::Claude => "/".to_string(), + } + } + + pub fn build_upstream_url(self, upstream_base: &Url, uri: &Uri) -> Result { + let path = uri.path(); + let mount_path = self.proxy_mount_path(upstream_base); + let relative_path = match self { + Self::Codex => strip_mount_path(path, &mount_path).ok_or_else(|| { + anyhow!("request path {path} does not match proxy mount {mount_path}") + })?, + Self::Claude => path.to_string(), + }; + + join_url(upstream_base, &relative_path, uri.query()) + } + + pub fn request_id_from_headers(self, headers: &reqwest::header::HeaderMap) -> Option { + let names: &[&str] = match self { + Self::Codex => &["x-request-id", "request-id"], + Self::Claude => &["request-id", "anthropic-request-id", "x-request-id"], + }; + names.iter().find_map(|name| { + headers + .get(*name) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()) + }) + } + + pub fn allowlisted_headers( + self, + headers: &reqwest::header::HeaderMap, + ) -> BTreeMap { + let names: &[&str] = match self { + Self::Codex => &[ + "accept", + "content-length", + "content-type", + "openai-processing-ms", + "request-id", + "user-agent", + "x-request-id", + ], + Self::Claude => &[ + "accept", + "anthropic-version", + "anthropic-request-id", + "content-length", + "content-type", + "request-id", + "user-agent", + "x-request-id", + ], + }; + + let mut out = BTreeMap::new(); + for name in names { + if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) { + out.insert((*name).to_string(), value.to_string()); + } + } + out + } + + pub fn model_from_payload(self, payload: &Value) -> Option { + find_model_field(payload) + } + + pub fn prepare_exchange( + self, + session: &SessionRuntime, + exchange_id: String, + body: &[u8], + artifact_refs: &ArtifactRefs, + ) -> PreparedExchange { + match self { + Self::Codex => openai::prepare_exchange(session, exchange_id, body, artifact_refs), + Self::Claude => anthropic::prepare_exchange(session, exchange_id, body, artifact_refs), + } + } + + fn upstream_base_env_names(self) -> &'static [&'static str] { + match self { + Self::Codex => &["OPENAI_BASE_URL", "OPENAI_API_BASE"], + Self::Claude => &[ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_URL", + "ANTHROPIC_API_BASE", + "CLAUDE_BASE_URL", + "CLAUDE_API_BASE", + "CLAUDE_CODE_BASE_URL", + ], + } + } +} + +impl ExchangeState { + pub fn finalize_json( + self, + session: &SessionRuntime, + status: u16, + request_id: Option, + body: &[u8], + artifact_refs: &ArtifactRefs, + ) -> Vec { + match self { + Self::OpenAi(state) => { + openai::finalize_json(session, state, status, request_id, body, artifact_refs) + } + Self::Anthropic(state) => { + anthropic::finalize_json(session, state, status, request_id, body, artifact_refs) + } + } + } + + pub fn absorb_sse_frame(&mut self, frame: &openai::SseFrame) { + match self { + Self::OpenAi(state) => state.absorb_sse_frame(frame), + Self::Anthropic(state) => state.absorb_sse_frame(frame), + } + } + + pub fn finalize_stream( + self, + session: &SessionRuntime, + status: u16, + request_id: Option, + artifact_refs: &ArtifactRefs, + malformed_remainder: Option, + ) -> Vec { + match self { + Self::OpenAi(state) => openai::finalize_stream( + session, + state, + status, + request_id, + artifact_refs, + malformed_remainder, + ), + Self::Anthropic(state) => anthropic::finalize_stream( + session, + state, + status, + request_id, + artifact_refs, + malformed_remainder, + ), + } + } +} + +fn normalize_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } +} + +fn strip_mount_path(path: &str, mount_path: &str) -> Option { + if mount_path == "/" { + return Some(path.to_string()); + } + let suffix = path.strip_prefix(mount_path)?; + if suffix.is_empty() { + Some("/".to_string()) + } else if suffix.starts_with('/') { + Some(suffix.to_string()) + } else { + Some(format!("/{suffix}")) + } +} + +fn join_url(base: &Url, path: &str, query: Option<&str>) -> Result { + let mut joined = base.clone(); + let base_path = base.path().trim_end_matches('/'); + let extra_path = path.trim_start_matches('/'); + let final_path = if base_path.is_empty() { + format!("/{}", extra_path) + } else if extra_path.is_empty() { + base_path.to_string() + } else { + format!("{base_path}/{extra_path}") + }; + joined.set_path(&final_path); + joined.set_query(query); + Ok(joined) +} + +fn find_model_field(value: &Value) -> Option { + match value { + Value::Object(map) => { + if let Some(model) = map.get("model").and_then(Value::as_str) { + return Some(model.to_string()); + } + map.values().find_map(find_model_field) + } + Value::Array(values) => values.iter().find_map(find_model_field), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::ProviderKind; + use http::Uri; + use url::Url; + + #[test] + fn codex_preserves_upstream_base_path() { + let upstream = Url::parse("https://example.test/custom/v1").unwrap(); + let uri: Uri = "/custom/v1/chat/completions?stream=true".parse().unwrap(); + let routed = ProviderKind::Codex + .build_upstream_url(&upstream, &uri) + .unwrap(); + assert_eq!( + routed.as_str(), + "https://example.test/custom/v1/chat/completions?stream=true" + ); + } + + #[test] + fn claude_routes_from_root_proxy_to_upstream_path() { + let upstream = Url::parse("https://example.test/anthropic").unwrap(); + let uri: Uri = "/v1/messages".parse().unwrap(); + let routed = ProviderKind::Claude + .build_upstream_url(&upstream, &uri) + .unwrap(); + assert_eq!( + routed.as_str(), + "https://example.test/anthropic/v1/messages" + ); + } +} diff --git a/cxtx/src/provider/openai.rs b/cxtx/src/provider/openai.rs new file mode 100644 index 0000000..4a4422b --- /dev/null +++ b/cxtx/src/provider/openai.rs @@ -0,0 +1,775 @@ +use serde_json::Value; + +use crate::provider::{ExchangeState, PreparedExchange}; +use crate::session::SessionRuntime; +use crate::turns::{tool_call_record, ArtifactRefs, HistoryItem, ToolCallRecord, TurnEnvelope}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SseFrame { + pub event: Option, + pub data: String, + pub raw: String, +} + +#[derive(Debug)] +pub struct OpenAiExchange { + pub exchange_id: String, + pub model: Option, + content: String, + tool_calls: Vec, + finish_reason: Option, + parse_errors: Vec, +} + +#[derive(Debug, Clone, Default)] +struct PartialToolCall { + call_id: String, + name: String, + args: String, +} + +pub fn prepare_exchange( + session: &SessionRuntime, + exchange_id: String, + body: &[u8], + artifact_refs: &ArtifactRefs, +) -> PreparedExchange { + let payload = match serde_json::from_slice::(body) { + Ok(payload) => payload, + Err(err) => { + let turns = vec![session.provider_error_turn( + &exchange_id, + "request_parse_error", + &format!("failed to parse OpenAI request body: {err}"), + None, + artifact_refs, + )]; + return PreparedExchange { + exchange_id: exchange_id.clone(), + model: None, + request_turns: turns, + state: ExchangeState::OpenAi(OpenAiExchange::new(exchange_id, None)), + }; + } + }; + + let model = payload + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let request_turns = match parse_request_history(&payload) { + Ok(history) => session.observe_request_history(&exchange_id, history, artifact_refs), + Err(err) => vec![session.provider_error_turn( + &exchange_id, + "request_history_parse_error", + &err, + None, + artifact_refs, + )], + }; + + PreparedExchange { + exchange_id: exchange_id.clone(), + model: model.clone(), + request_turns, + state: ExchangeState::OpenAi(OpenAiExchange::new(exchange_id, model)), + } +} + +pub fn finalize_json( + session: &SessionRuntime, + exchange: OpenAiExchange, + status: u16, + request_id: Option, + body: &[u8], + artifact_refs: &ArtifactRefs, +) -> Vec { + if status >= 400 { + let body_excerpt = String::from_utf8_lossy(body); + return vec![session.provider_error_turn( + &exchange.exchange_id, + "provider_error_response", + &format!( + "OpenAI upstream returned HTTP {status}: {}", + body_excerpt.trim() + ), + request_id.as_deref(), + artifact_refs, + )]; + } + + let payload = match serde_json::from_slice::(body) { + Ok(payload) => payload, + Err(err) => { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "response_parse_error", + &format!("failed to parse OpenAI response body: {err}"), + request_id.as_deref(), + artifact_refs, + )]; + } + }; + + match parse_assistant_payload(&payload, exchange.model.as_deref()) { + Ok(Some(item)) => vec![session.append_history_item(&exchange.exchange_id, item)], + Ok(None) => Vec::new(), + Err(err) => vec![session.provider_error_turn( + &exchange.exchange_id, + "response_extract_error", + &err, + request_id.as_deref(), + artifact_refs, + )], + } +} + +pub fn finalize_stream( + session: &SessionRuntime, + exchange: OpenAiExchange, + status: u16, + request_id: Option, + artifact_refs: &ArtifactRefs, + malformed_remainder: Option, +) -> Vec { + if let Some(remainder) = malformed_remainder.filter(|remainder| !remainder.trim().is_empty()) { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "malformed_sse_remainder", + &format!("OpenAI stream ended with leftover buffer: {remainder}"), + request_id.as_deref(), + artifact_refs, + )]; + } + + if !exchange.parse_errors.is_empty() { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "stream_parse_error", + &exchange.parse_errors.join("; "), + request_id.as_deref(), + artifact_refs, + )]; + } + + if status >= 400 { + return vec![session.provider_error_turn( + &exchange.exchange_id, + "provider_error_stream", + &format!("OpenAI upstream returned HTTP {status} during stream"), + request_id.as_deref(), + artifact_refs, + )]; + } + + if exchange.content.is_empty() && exchange.tool_calls.is_empty() { + return Vec::new(); + } + + let tool_calls = exchange + .tool_calls + .into_iter() + .map(|tool| tool_call_record(tool.call_id, tool.name, tool.args)) + .collect::>(); + vec![session.append_history_item( + &exchange.exchange_id, + HistoryItem::AssistantTurn { + text: exchange.content, + tool_calls, + model: exchange.model, + finish_reason: exchange.finish_reason, + }, + )] +} + +impl OpenAiExchange { + fn new(exchange_id: String, model: Option) -> Self { + Self { + exchange_id, + model, + content: String::new(), + tool_calls: Vec::new(), + finish_reason: None, + parse_errors: Vec::new(), + } + } + + pub fn absorb_sse_frame(&mut self, frame: &SseFrame) { + if frame.data.trim() == "[DONE]" { + return; + } + let payload = match serde_json::from_str::(&frame.data) { + Ok(payload) => payload, + Err(err) => { + self.parse_errors + .push(format!("failed to parse OpenAI stream frame: {err}")); + return; + } + }; + + if let Some(event_type) = payload.get("type").and_then(Value::as_str) { + self.absorb_responses_event(event_type, &payload); + return; + } + + if self.model.is_none() { + self.model = payload + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + } + + if let Some(choice) = payload + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + { + if let Some(delta) = choice.get("delta") { + if let Some(content) = delta.get("content") { + self.content.push_str(&content_to_text(content)); + } + if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { + for tool_call in tool_calls { + absorb_tool_call_delta(&mut self.tool_calls, tool_call); + } + } + } + if self.finish_reason.is_none() { + self.finish_reason = choice + .get("finish_reason") + .and_then(Value::as_str) + .map(|value| value.to_string()); + } + } + } + + fn absorb_responses_event(&mut self, event_type: &str, payload: &Value) { + match event_type { + "response.created" | "response.in_progress" | "response.completed" => { + if let Some(response) = payload.get("response") { + if self.model.is_none() { + self.model = response + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + } + if let Some(status) = response.get("status").and_then(Value::as_str) { + self.finish_reason = Some(status.to_string()); + } + absorb_responses_output(response, &mut self.content, &mut self.tool_calls); + } + } + "response.output_text.delta" => { + if let Some(delta) = payload.get("delta").and_then(Value::as_str) { + self.content.push_str(delta); + } + } + "response.output_item.added" | "response.output_item.done" => { + if let Some(item) = payload.get("item") { + absorb_responses_output_item(item, &mut self.content, &mut self.tool_calls); + } + } + _ => {} + } + } +} + +pub fn parse_sse_buffer(buffer: &mut String) -> Vec { + let normalized = buffer.replace("\r\n", "\n"); + let mut frames = Vec::new(); + let mut consumed = 0usize; + + for block in normalized.split_inclusive("\n\n") { + if !block.ends_with("\n\n") { + break; + } + consumed += block.len(); + if let Some(frame) = parse_block(block.trim_end_matches('\n')) { + frames.push(frame); + } + } + + let remaining = normalized[consumed..].to_string(); + *buffer = remaining; + frames +} + +fn parse_request_history(payload: &Value) -> Result, String> { + let model = payload + .get("model") + .and_then(Value::as_str) + .map(|value| value.to_string()); + + if let Some(messages) = payload.get("messages").and_then(Value::as_array) { + return parse_message_history(messages, model); + } + if let Some(input) = payload.get("input").and_then(Value::as_array) { + return parse_input_history(input, model); + } + + Err("OpenAI request is missing messages or input array".to_string()) +} + +fn parse_message_history( + messages: &[Value], + model: Option, +) -> Result, String> { + let mut history = Vec::new(); + for message in messages { + let role = message + .get("role") + .and_then(Value::as_str) + .ok_or_else(|| "OpenAI request message missing role".to_string())?; + match role { + "user" => history.push(HistoryItem::UserInput { + text: content_to_text(message.get("content").unwrap_or(&Value::Null)), + files: Vec::new(), + }), + "assistant" => { + history.push(HistoryItem::AssistantTurn { + text: content_to_text(message.get("content").unwrap_or(&Value::Null)), + tool_calls: parse_tool_calls(message.get("tool_calls")), + model: model.clone(), + finish_reason: None, + }); + } + "tool" => history.push(HistoryItem::ToolResult { + call_id: message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content: content_to_text(message.get("content").unwrap_or(&Value::Null)), + is_error: false, + }), + _ => {} + } + } + Ok(history) +} + +fn parse_input_history(input: &[Value], model: Option) -> Result, String> { + let mut history = Vec::new(); + for item in input { + if item + .get("type") + .and_then(Value::as_str) + .is_some_and(|item_type| item_type != "message") + { + continue; + } + + let role = item + .get("role") + .and_then(Value::as_str) + .ok_or_else(|| "OpenAI input item missing role".to_string())?; + match role { + "user" => history.push(HistoryItem::UserInput { + text: content_to_text(item.get("content").unwrap_or(&Value::Null)), + files: Vec::new(), + }), + "assistant" => history.push(HistoryItem::AssistantTurn { + text: content_to_text(item.get("content").unwrap_or(&Value::Null)), + tool_calls: Vec::new(), + model: model.clone(), + finish_reason: None, + }), + "tool" => history.push(HistoryItem::ToolResult { + call_id: item + .get("call_id") + .or_else(|| item.get("tool_call_id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content: content_to_text(item.get("content").unwrap_or(&Value::Null)), + is_error: false, + }), + _ => {} + } + } + Ok(history) +} + +fn parse_assistant_payload(payload: &Value, fallback_model: Option<&str>) -> Result, String> { + if let Some(message) = payload + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + { + return Ok(Some(HistoryItem::AssistantTurn { + text: content_to_text(message.get("content").unwrap_or(&Value::Null)), + tool_calls: parse_tool_calls(message.get("tool_calls")), + model: payload + .get("model") + .and_then(Value::as_str) + .or(fallback_model) + .map(|value| value.to_string()), + finish_reason: payload + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("finish_reason")) + .and_then(Value::as_str) + .map(|value| value.to_string()), + })); + } + + let response = payload.get("response").unwrap_or(payload); + let output = response + .get("output") + .and_then(Value::as_array) + .or_else(|| payload.get("output").and_then(Value::as_array)); + let Some(output) = output else { + return Ok(None); + }; + + let text = output + .iter() + .filter(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("assistant") + }) + .map(|item| content_to_text(item.get("content").unwrap_or(&Value::Null))) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + let tool_calls = parse_responses_tool_calls(output); + + if text.is_empty() && tool_calls.is_empty() { + return Ok(None); + } + + Ok(Some(HistoryItem::AssistantTurn { + text, + tool_calls, + model: response + .get("model") + .and_then(Value::as_str) + .or(fallback_model) + .map(|value| value.to_string()), + finish_reason: response + .get("status") + .and_then(Value::as_str) + .map(|value| value.to_string()), + })) +} + +fn parse_tool_calls(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|tool_calls| { + tool_calls + .iter() + .map(|tool_call| { + let args = tool_call + .get("function") + .and_then(|function| function.get("arguments")) + .map(jsonish_to_string) + .unwrap_or_default(); + tool_call_record( + tool_call + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + tool_call + .get("function") + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + args, + ) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn parse_responses_tool_calls(items: &[Value]) -> Vec { + items + .iter() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call")) + .map(|item| { + tool_call_record( + item.get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + item.get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + item.get("arguments") + .or_else(|| item.get("input")) + .map(jsonish_to_string) + .unwrap_or_default(), + ) + }) + .collect() +} + +fn absorb_tool_call_delta(slots: &mut Vec, delta: &Value) { + let index = delta + .get("index") + .and_then(Value::as_u64) + .unwrap_or(slots.len() as u64) as usize; + while slots.len() <= index { + slots.push(PartialToolCall::default()); + } + let slot = &mut slots[index]; + if let Some(id) = delta.get("id").and_then(Value::as_str) { + slot.call_id = id.to_string(); + } + if let Some(function) = delta.get("function") { + if let Some(name) = function.get("name").and_then(Value::as_str) { + slot.name = name.to_string(); + } + if let Some(arguments) = function.get("arguments") { + slot.args.push_str(&jsonish_to_string(arguments)); + } + } +} + +fn absorb_responses_output(response: &Value, content: &mut String, tool_calls: &mut Vec) { + let Some(items) = response.get("output").and_then(Value::as_array) else { + return; + }; + for item in items { + absorb_responses_output_item(item, content, tool_calls); + } +} + +fn absorb_responses_output_item( + item: &Value, + content: &mut String, + tool_calls: &mut Vec, +) { + match item.get("type").and_then(Value::as_str) { + Some("message") => { + if content.is_empty() { + content.push_str(&content_to_text(item.get("content").unwrap_or(&Value::Null))); + } + } + Some("function_call") => { + let call_id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or_default(); + if call_id.is_empty() { + return; + } + if let Some(existing) = tool_calls.iter_mut().find(|slot| slot.call_id == call_id) { + if existing.name.is_empty() { + existing.name = item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + } + if existing.args.is_empty() { + existing.args = item + .get("arguments") + .or_else(|| item.get("input")) + .map(jsonish_to_string) + .unwrap_or_default(); + } + } else { + tool_calls.push(PartialToolCall { + call_id: call_id.to_string(), + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + args: item + .get("arguments") + .or_else(|| item.get("input")) + .map(jsonish_to_string) + .unwrap_or_default(), + }); + } + } + _ => {} + } +} + +fn content_to_text(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(value) => value.clone(), + Value::Array(values) => values + .iter() + .map(content_to_text) + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"), + Value::Object(map) => map + .get("text") + .and_then(Value::as_str) + .map(|value| value.to_string()) + .or_else(|| map.get("content").map(content_to_text)) + .unwrap_or_else(|| jsonish_to_string(value)), + _ => jsonish_to_string(value), + } +} + +fn jsonish_to_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + _ => serde_json::to_string(value).unwrap_or_default(), + } +} + +fn parse_block(block: &str) -> Option { + let mut event = None; + let mut data_lines = Vec::new(); + + for line in block.lines() { + if let Some(value) = line.strip_prefix("event:") { + event = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("data:") { + data_lines.push(value.trim_start().to_string()); + } + } + + if data_lines.is_empty() { + return None; + } + + Some(SseFrame { + event, + data: data_lines.join("\n"), + raw: block.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::{parse_request_history, parse_sse_buffer, OpenAiExchange, SseFrame}; + use crate::turns::HistoryItem; + use serde_json::json; + + #[test] + fn parses_openai_sse_frames() { + let mut buffer = + "data: {\"id\":\"evt_1\",\"choices\":[]}\n\ndata: [DONE]\n\nremainder".to_string(); + let frames = parse_sse_buffer(&mut buffer); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].data, "{\"id\":\"evt_1\",\"choices\":[]}"); + assert_eq!(frames[1].data, "[DONE]"); + assert_eq!(buffer, "remainder"); + } + + #[test] + fn request_history_includes_user_assistant_and_tool_messages() { + struct Case { + name: &'static str, + payload: serde_json::Value, + expected_kinds: Vec<&'static str>, + } + + let cases = vec![ + Case { + name: "string user, assistant, and tool messages", + payload: json!({ + "model": "gpt-5", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "working", "tool_calls": [{"id": "call_1", "function": {"name": "lookup", "arguments": {"q": "hello"}}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "done"} + ] + }), + expected_kinds: vec!["user_input", "assistant_turn", "tool_result"], + }, + Case { + name: "array content is preserved as one user turn and one assistant turn", + payload: json!({ + "model": "gpt-5", + "messages": [ + {"role": "user", "content": [{"text": "alpha"}, {"text": "beta"}]}, + {"role": "assistant", "content": [{"text": "gamma"}]} + ] + }), + expected_kinds: vec!["user_input", "assistant_turn"], + }, + Case { + name: "responses api input captures only conversation roles", + payload: json!({ + "model": "gpt-5.4", + "input": [ + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "system instructions"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "hi"}]} + ] + }), + expected_kinds: vec!["user_input", "assistant_turn"], + }, + ]; + + for case in cases { + let history = parse_request_history(&case.payload).unwrap(); + let kinds = history + .iter() + .map(|item| match item { + HistoryItem::UserInput { .. } => "user_input", + HistoryItem::AssistantTurn { .. } => "assistant_turn", + HistoryItem::ToolResult { .. } => "tool_result", + }) + .collect::>(); + assert_eq!(kinds, case.expected_kinds, "case {}", case.name); + } + } + + #[test] + fn stream_accumulator_collects_text_and_tool_calls() { + let mut exchange = + OpenAiExchange::new("exchange-0001".to_string(), Some("gpt-5".to_string())); + exchange.absorb_sse_frame(&SseFrame { + event: None, + data: "{\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: None, + data: "{\"choices\":[{\"delta\":{\"content\":\"lo\",\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\"}}]}}]}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: None, + data: "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"hello\\\"}\"}}]}}]}".to_string(), + raw: String::new(), + }); + assert_eq!(exchange.content, "hello"); + assert_eq!(exchange.tool_calls.len(), 1); + assert_eq!(exchange.tool_calls[0].name, "lookup"); + } + + #[test] + fn stream_accumulator_collects_responses_output_text() { + let mut exchange = + OpenAiExchange::new("exchange-0001".to_string(), Some("gpt-5.4".to_string())); + exchange.absorb_sse_frame(&SseFrame { + event: Some("response.output_text.delta".to_string()), + data: "{\"type\":\"response.output_text.delta\",\"delta\":\"Hello\"}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: Some("response.output_text.delta".to_string()), + data: "{\"type\":\"response.output_text.delta\",\"delta\":\" world\"}".to_string(), + raw: String::new(), + }); + exchange.absorb_sse_frame(&SseFrame { + event: Some("response.completed".to_string()), + data: "{\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello world\"}]}]}}".to_string(), + raw: String::new(), + }); + + assert_eq!(exchange.content, "Hello world"); + assert_eq!(exchange.model.as_deref(), Some("gpt-5.4")); + assert_eq!(exchange.finish_reason.as_deref(), Some("completed")); + } +} diff --git a/cxtx/src/proxy.rs b/cxtx/src/proxy.rs new file mode 100644 index 0000000..ea251d1 --- /dev/null +++ b/cxtx/src/proxy.rs @@ -0,0 +1,493 @@ +use anyhow::{anyhow, Context, Result}; +use async_stream::stream; +use axum::body::{to_bytes, Body}; +use axum::extract::State; +use axum::http::{HeaderMap, Request, Response, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::Router; +use futures_util::StreamExt; +use reqwest::header::{HeaderName, HeaderValue}; +use serde_json::Value; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot, RwLock}; +use url::Url; + +use crate::delivery::DeliveryHandle; +use crate::ledger::SessionLedgerWriter; +use crate::provider::{anthropic, openai, PreparedExchange, ProviderKind}; +use crate::session::SessionRuntime; +use crate::turns::ArtifactRefs; + +#[derive(Clone)] +struct ProxyState { + provider: ProviderKind, + upstream_base: Url, + client: reqwest::Client, + session: SessionRuntime, + ledger: SessionLedgerWriter, + delivery: Arc>>, +} + +pub struct ProxyServer { + proxy_base_url: Url, + state: ProxyState, + shutdown: Option>, + join: tokio::task::JoinHandle<()>, +} + +impl ProxyServer { + pub async fn start( + provider: ProviderKind, + upstream_base: Url, + session: SessionRuntime, + ledger: SessionLedgerWriter, + ) -> Result { + let client = reqwest::Client::builder() + .build() + .context("failed to construct proxy reqwest client")?; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("failed to bind proxy listener")?; + let addr = listener + .local_addr() + .context("missing proxy listener address")?; + let proxy_base_url = proxy_base_url(provider, &upstream_base, addr)?; + + let state = ProxyState { + provider, + upstream_base, + client, + session, + ledger, + delivery: Arc::new(RwLock::new(None)), + }; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new() + .route("/*path", any(proxy_handler)) + .route("/", any(proxy_handler)) + .with_state(state.clone()); + let join = tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await; + }); + + Ok(Self { + proxy_base_url, + state, + shutdown: Some(shutdown_tx), + join, + }) + } + + pub fn proxy_base_url(&self) -> Url { + self.proxy_base_url.clone() + } + + pub async fn set_delivery(&self, delivery: DeliveryHandle) { + let mut slot = self.state.delivery.write().await; + *slot = Some(delivery); + } + + pub async fn shutdown(mut self) -> Result<()> { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + self.join + .await + .map_err(|err| anyhow!("proxy server task failed: {err}"))?; + Ok(()) + } +} + +async fn proxy_handler(State(state): State, request: Request) -> Response { + match handle_proxy_request(state, request).await { + Ok(response) => response, + Err(err) => ( + StatusCode::BAD_GATEWAY, + format!("cxtx proxy error: {err:#}"), + ) + .into_response(), + } +} + +async fn handle_proxy_request(state: ProxyState, request: Request) -> Result> { + let delivery = state + .delivery + .read() + .await + .clone() + .ok_or_else(|| anyhow!("delivery worker not attached"))?; + + let (parts, body) = request.into_parts(); + let body_bytes = to_bytes(body, usize::MAX) + .await + .context("failed to read downstream request body")?; + let upstream_url = state + .provider + .build_upstream_url(&state.upstream_base, &parts.uri) + .context("failed to derive upstream URL")?; + let request_headers = forwardable_headers(&parts.headers); + let request_content_type = header_value(&parts.headers, "content-type"); + let request_json = request_content_type + .as_deref() + .filter(|content_type| content_type.contains("json")) + .and_then(|_| serde_json::from_slice::(&body_bytes).ok()); + + let exchange_id = state.session.next_exchange_id(); + let request_artifact = state + .ledger + .record_request( + &exchange_id, + parts.uri.path(), + request_json + .as_ref() + .and_then(|payload| state.provider.model_from_payload(payload)) + .as_deref(), + request_content_type.as_deref(), + &body_bytes, + request_json.as_ref(), + ) + .await?; + let artifact_refs = ArtifactRefs::default().with_request_path(Some(request_artifact)); + let prepared = state.provider.prepare_exchange( + &state.session, + exchange_id.clone(), + &body_bytes, + &artifact_refs, + ); + enqueue_turns(&delivery, prepared.request_turns.clone()).await; + + let mut upstream_request = state.client.request(parts.method.clone(), upstream_url); + upstream_request = upstream_request.body(body_bytes.to_vec()); + for (name, value) in request_headers { + upstream_request = upstream_request.header(name, value); + } + + let upstream_response = match upstream_request.send().await { + Ok(response) => response, + Err(err) => { + delivery + .enqueue_turn(state.session.provider_error_turn( + &exchange_id, + "upstream_transport_error", + &format!("upstream request failed: {err}"), + None, + &artifact_refs, + )) + .await + .ok(); + return Ok(( + StatusCode::BAD_GATEWAY, + format!("upstream request failed: {err}"), + ) + .into_response()); + } + }; + + let status = StatusCode::from_u16(upstream_response.status().as_u16()) + .unwrap_or(StatusCode::BAD_GATEWAY); + let response_headers = upstream_response.headers().clone(); + let request_id = state.provider.request_id_from_headers(&response_headers); + + if header_value_reqwest(&response_headers, "content-type") + .as_deref() + .map(|value| value.contains("text/event-stream")) + .unwrap_or(false) + { + stream_response( + state, + delivery, + status, + response_headers, + upstream_response, + request_id, + prepared, + artifact_refs, + ) + .await + } else { + body_response( + state, + delivery, + status, + response_headers, + upstream_response, + request_id, + prepared, + artifact_refs, + ) + .await + } +} + +async fn body_response( + state: ProxyState, + delivery: DeliveryHandle, + status: StatusCode, + response_headers: reqwest::header::HeaderMap, + upstream_response: reqwest::Response, + request_id: Option, + prepared: PreparedExchange, + artifact_refs: ArtifactRefs, +) -> Result> { + let body = upstream_response + .bytes() + .await + .context("failed to read upstream response body")?; + let content_type = header_value_reqwest(&response_headers, "content-type"); + let response_json = content_type + .as_deref() + .filter(|content_type| content_type.contains("json")) + .and_then(|_| serde_json::from_slice::(&body).ok()); + let response_artifact = state + .ledger + .record_response( + &prepared.exchange_id, + status.as_u16(), + request_id.as_deref(), + content_type.as_deref(), + &body, + response_json.as_ref(), + ) + .await?; + let artifact_refs = artifact_refs.with_response_path(Some(response_artifact)); + let turns = prepared.state.finalize_json( + &state.session, + status.as_u16(), + request_id, + &body, + &artifact_refs, + ); + enqueue_turns(&delivery, turns).await; + + let mut response = Response::builder().status(status); + for (name, value) in copyable_response_headers(&response_headers) { + response = response.header(name, value); + } + response + .body(Body::from(body)) + .context("failed to build proxied response") +} + +async fn stream_response( + state: ProxyState, + delivery: DeliveryHandle, + status: StatusCode, + response_headers: reqwest::header::HeaderMap, + upstream_response: reqwest::Response, + request_id: Option, + prepared: PreparedExchange, + artifact_refs: ArtifactRefs, +) -> Result> { + let provider = state.provider; + let session = state.session.clone(); + let ledger = state.ledger.clone(); + let exchange_id = prepared.exchange_id.clone(); + let content_type = header_value_reqwest(&response_headers, "content-type"); + let mut exchange_state = prepared.state; + let mut stream_artifact_refs = artifact_refs.clone(); + let request_id_for_stream = request_id.clone(); + let (tx, rx) = mpsc::channel::>(32); + let stream = upstream_response.bytes_stream(); + tokio::spawn(async move { + let mut parse_buffer = String::new(); + let mut raw_stream = String::new(); + futures_util::pin_mut!(stream); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(chunk) => { + let text = String::from_utf8_lossy(&chunk); + raw_stream.push_str(&text); + parse_buffer.push_str(&text); + let frames = match provider { + ProviderKind::Codex => openai::parse_sse_buffer(&mut parse_buffer), + ProviderKind::Claude => anthropic::parse_sse_buffer(&mut parse_buffer), + }; + for frame in frames { + match ledger.append_stream_frame(&exchange_id, &frame.raw).await { + Ok(path) => { + stream_artifact_refs.stream_path = Some(path); + } + Err(err) => { + delivery.enqueue_turn(session.provider_error_turn( + &exchange_id, + "artifact_write_error", + &format!("failed to persist stream frame: {err}"), + request_id_for_stream.as_deref(), + &stream_artifact_refs, + )).await.ok(); + } + } + exchange_state.absorb_sse_frame(&frame); + } + if tx.send(Ok(chunk)).await.is_err() { + // Keep draining upstream so capture completes even if downstream disconnects. + } + } + Err(err) => { + delivery.enqueue_turn(session.provider_error_turn( + &exchange_id, + "stream_transport_error", + &format!("failed to read upstream stream: {err}"), + request_id_for_stream.as_deref(), + &stream_artifact_refs, + )).await.ok(); + let _ = tx + .send(Err(std::io::Error::other(err.to_string()))) + .await; + break; + } + } + } + + match ledger.record_response( + &exchange_id, + status.as_u16(), + request_id_for_stream.as_deref(), + content_type.as_deref(), + raw_stream.as_bytes(), + None, + ).await { + Ok(path) => { + stream_artifact_refs.response_path = Some(path); + } + Err(err) => { + delivery.enqueue_turn(session.provider_error_turn( + &exchange_id, + "artifact_write_error", + &format!("failed to persist streamed response transcript: {err}"), + request_id_for_stream.as_deref(), + &stream_artifact_refs, + )).await.ok(); + } + } + + let turns = exchange_state.finalize_stream( + &session, + status.as_u16(), + request_id_for_stream.clone(), + &stream_artifact_refs, + if parse_buffer.trim().is_empty() { + None + } else { + Some(parse_buffer) + }, + ); + enqueue_turns(&delivery, turns).await; + }); + + let stream = stream! { + let mut rx = rx; + while let Some(item) = rx.recv().await { + yield item; + } + }; + + let mut response = Response::builder().status(status); + for (name, value) in copyable_response_headers(&response_headers) { + response = response.header(name, value); + } + response + .body(Body::from_stream(stream)) + .context("failed to build streaming proxied response") +} + +async fn enqueue_turns(delivery: &DeliveryHandle, turns: Vec) { + for turn in turns { + delivery.enqueue_turn(turn).await.ok(); + } +} + +fn proxy_base_url(provider: ProviderKind, upstream_base: &Url, addr: SocketAddr) -> Result { + let mut url = Url::parse(&format!("http://{addr}")).context("failed to build proxy URL")?; + let mount_path = provider.proxy_mount_path(upstream_base); + url.set_path(&mount_path); + Ok(url) +} + +fn forwardable_headers(headers: &HeaderMap) -> Vec<(HeaderName, HeaderValue)> { + headers + .iter() + .filter_map(|(name, value)| { + if is_hop_by_hop(name.as_str()) || name.as_str().eq_ignore_ascii_case("host") { + None + } else { + Some((name.clone(), value.clone())) + } + }) + .collect() +} + +fn copyable_response_headers( + headers: &reqwest::header::HeaderMap, +) -> Vec<(HeaderName, HeaderValue)> { + headers + .iter() + .filter_map(|(name, value)| { + if is_hop_by_hop(name.as_str()) { + None + } else { + Some((name.clone(), value.clone())) + } + }) + .collect() +} + +fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()) +} + +fn header_value_reqwest(headers: &reqwest::header::HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()) +} + +fn is_hop_by_hop(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailers" + | "transfer-encoding" + | "upgrade" + ) +} + +#[cfg(test)] +mod tests { + use super::forwardable_headers; + use axum::http::{HeaderMap, HeaderValue}; + + #[test] + fn forwardable_headers_drop_host_but_keep_authorization() { + let mut headers = HeaderMap::new(); + headers.insert("host", HeaderValue::from_static("127.0.0.1:12345")); + headers.insert("authorization", HeaderValue::from_static("Bearer test")); + + let forwarded = forwardable_headers(&headers); + assert!( + !forwarded + .iter() + .any(|(name, _)| name.as_str().eq_ignore_ascii_case("host")) + ); + assert!( + forwarded + .iter() + .any(|(name, value)| name == "authorization" && value == "Bearer test") + ); + } +} diff --git a/cxtx/src/session.rs b/cxtx/src/session.rs new file mode 100644 index 0000000..c8e2e0c --- /dev/null +++ b/cxtx/src/session.rs @@ -0,0 +1,435 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use cxdb::types::ContextMetadata; +use serde::Serialize; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; + +use crate::provider::ProviderKind; +use crate::turns::{ + context_metadata, history_item_to_conversation_item, ingest_state_item, provider_error_item, + rewrite_item, session_end_item, session_start_item, ArtifactRefs, HistoryItem, TurnEnvelope, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct CapturedSession { + pub session_id: String, + pub provider_kind: String, + pub child_command: String, + pub child_args: Vec, + pub started_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct CapturedExchange { + pub exchange_id: String, + pub provider_request_id: Option, + pub model: Option, + pub endpoint: Option, + pub status_code: Option, +} + +#[derive(Debug)] +pub struct SessionRuntime { + inner: Arc, +} + +#[derive(Debug)] +struct SessionRuntimeInner { + session: CapturedSession, + provider: ProviderKind, + metadata: ContextMetadata, + child_pid: AtomicU32, + state: Mutex, +} + +#[derive(Debug, Default)] +struct MutableState { + next_turn_ordinal: u64, + next_exchange_ordinal: u64, + observed_history: Vec, +} + +impl Clone for SessionRuntime { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl SessionRuntime { + pub fn new( + provider: ProviderKind, + child_args: Vec, + allowlisted_env: BTreeMap, + ) -> Result { + let started_at = Utc::now(); + let session = CapturedSession { + session_id: Uuid::new_v4().to_string(), + provider_kind: provider.provider_name().to_string(), + child_command: provider.command_name().to_string(), + child_args, + started_at, + }; + let metadata = context_metadata(provider, &session, &allowlisted_env); + Ok(Self { + inner: Arc::new(SessionRuntimeInner { + session, + provider, + metadata, + child_pid: AtomicU32::new(0), + state: Mutex::new(MutableState::default()), + }), + }) + } + + pub fn session_id(&self) -> &str { + &self.inner.session.session_id + } + + pub fn provider(&self) -> ProviderKind { + self.inner.provider + } + + pub fn session(&self) -> &CapturedSession { + &self.inner.session + } + + pub fn metadata(&self) -> &ContextMetadata { + &self.inner.metadata + } + + pub fn set_child_pid(&self, pid: Option) { + self.inner + .child_pid + .store(pid.unwrap_or(0), Ordering::Relaxed); + } + + pub fn child_pid(&self) -> Option { + match self.inner.child_pid.load(Ordering::Relaxed) { + 0 => None, + value => Some(value), + } + } + + pub fn next_exchange_id(&self) -> String { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + state.next_exchange_ordinal += 1; + format!("exchange-{:04}", state.next_exchange_ordinal) + } + + pub fn session_start_turn(&self) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + TurnEnvelope { + ordinal, + item: session_start_item( + &self.inner.session, + self.inner.provider, + ordinal, + self.child_pid(), + &self.inner.metadata, + ), + } + } + + pub fn session_end_turn(&self, exit_code: i32, success: bool) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + TurnEnvelope { + ordinal, + item: session_end_item(&self.inner.session, ordinal, exit_code, success), + } + } + + pub fn ingest_degraded_turn(&self, queue_depth: usize, error: &str) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + TurnEnvelope { + ordinal, + item: ingest_state_item( + &self.inner.session, + ordinal, + "ingest_degraded", + cxdb::types::SystemKindWarning, + queue_depth, + Some(error), + ), + } + } + + pub fn ingest_recovered_turn(&self, queue_depth: usize) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + TurnEnvelope { + ordinal, + item: ingest_state_item( + &self.inner.session, + ordinal, + "ingest_recovered", + cxdb::types::SystemKindInfo, + queue_depth, + None, + ), + } + } + + pub fn observe_request_history( + &self, + exchange_id: &str, + history: Vec, + artifact_refs: &ArtifactRefs, + ) -> Vec { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let previous = state.observed_history.clone(); + let normalized_history = history + .iter() + .map(normalize_history_item) + .collect::>(); + let prefix_len = common_prefix_len(&previous, &normalized_history); + + let mut turns = Vec::new(); + if prefix_len < previous.len() { + let ordinal = next_turn_ordinal(&mut state); + turns.push(TurnEnvelope { + ordinal, + item: rewrite_item( + &self.inner.session, + ordinal, + exchange_id, + previous.len(), + history.len(), + artifact_refs, + ), + }); + } + + for item in history.iter().skip(prefix_len) { + let ordinal = next_turn_ordinal(&mut state); + turns.push(TurnEnvelope { + ordinal, + item: history_item_to_conversation_item( + &self.inner.session, + ordinal, + exchange_id, + item, + ), + }); + } + + state.observed_history = normalized_history; + turns + } + + pub fn append_history_item(&self, exchange_id: &str, item: HistoryItem) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + let turn = TurnEnvelope { + ordinal, + item: history_item_to_conversation_item( + &self.inner.session, + ordinal, + exchange_id, + &item, + ), + }; + state.observed_history.push(normalize_history_item(&item)); + turn + } + + pub fn provider_error_turn( + &self, + exchange_id: &str, + title: &str, + message: &str, + provider_request_id: Option<&str>, + artifact_refs: &ArtifactRefs, + ) -> TurnEnvelope { + let mut state = self + .inner + .state + .lock() + .expect("session state lock poisoned"); + let ordinal = next_turn_ordinal(&mut state); + TurnEnvelope { + ordinal, + item: provider_error_item( + &self.inner.session, + ordinal, + exchange_id, + title, + message, + provider_request_id, + artifact_refs, + ), + } + } +} + +fn next_turn_ordinal(state: &mut MutableState) -> u64 { + state.next_turn_ordinal += 1; + state.next_turn_ordinal +} + +fn common_prefix_len(left: &[HistoryItem], right: &[HistoryItem]) -> usize { + left.iter() + .zip(right.iter()) + .take_while(|(left, right)| left == right) + .count() +} + +fn normalize_history_item(item: &HistoryItem) -> HistoryItem { + match item { + HistoryItem::AssistantTurn { + text, tool_calls, .. + } => HistoryItem::AssistantTurn { + text: text.clone(), + tool_calls: tool_calls.clone(), + model: None, + finish_reason: None, + }, + _ => item.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::SessionRuntime; + use crate::provider::ProviderKind; + use crate::turns::{ArtifactRefs, HistoryItem, ToolCallRecord}; + use std::collections::BTreeMap; + + #[test] + fn first_turn_carries_context_metadata() { + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let turn = session.session_start_turn(); + assert_eq!( + turn.item.context_metadata.as_ref().unwrap().client_tag, + "cxtx/codex" + ); + } + + #[test] + fn rewrite_detection_emits_system_turn_before_new_suffix() { + let session = + SessionRuntime::new(ProviderKind::Codex, Vec::new(), BTreeMap::new()).unwrap(); + let first = session.observe_request_history( + "exchange-0001", + vec![HistoryItem::UserInput { + text: "hello".to_string(), + files: Vec::new(), + }], + &ArtifactRefs::default(), + ); + assert_eq!(first.len(), 1); + + let rewritten = session.observe_request_history( + "exchange-0002", + vec![HistoryItem::UserInput { + text: "rewritten".to_string(), + files: Vec::new(), + }], + &ArtifactRefs::default(), + ); + assert_eq!(rewritten.len(), 2); + assert_eq!( + rewritten[0].item.system.as_ref().unwrap().title, + "history_rewrite_detected" + ); + } + + #[test] + fn assistant_turn_dedup_ignores_model_and_finish_reason() { + let session = + SessionRuntime::new(ProviderKind::Claude, Vec::new(), BTreeMap::new()).unwrap(); + let first = session.observe_request_history( + "exchange-0001", + vec![HistoryItem::UserInput { + text: "use tool".to_string(), + files: Vec::new(), + }], + &ArtifactRefs::default(), + ); + assert_eq!(first.len(), 1); + + let appended = session.append_history_item( + "exchange-0001", + HistoryItem::AssistantTurn { + text: String::new(), + tool_calls: vec![ToolCallRecord { + call_id: "call_1".to_string(), + name: "lookup".to_string(), + args: "{\"q\":\"use tool\"}".to_string(), + }], + model: Some("claude-3-7-sonnet-20250219".to_string()), + finish_reason: Some("tool_use".to_string()), + }, + ); + assert_eq!(appended.item.item_type, "assistant_turn"); + + let replay = session.observe_request_history( + "exchange-0002", + vec![ + HistoryItem::UserInput { + text: "use tool".to_string(), + files: Vec::new(), + }, + HistoryItem::AssistantTurn { + text: String::new(), + tool_calls: vec![ToolCallRecord { + call_id: "call_1".to_string(), + name: "lookup".to_string(), + args: "{\"q\":\"use tool\"}".to_string(), + }], + model: None, + finish_reason: None, + }, + HistoryItem::ToolResult { + call_id: "call_1".to_string(), + content: "done".to_string(), + is_error: false, + }, + ], + &ArtifactRefs::default(), + ); + + assert_eq!(replay.len(), 1); + assert_eq!(replay[0].item.item_type, "tool_result"); + } +} diff --git a/cxtx/src/turns.rs b/cxtx/src/turns.rs new file mode 100644 index 0000000..360c052 --- /dev/null +++ b/cxtx/src/turns.rs @@ -0,0 +1,352 @@ +use chrono::{DateTime, Utc}; +use cxdb::types::{ + attach_provenance, build_assistant_turn, build_system, build_tool_call_item, build_tool_result, + capture_process_provenance, new_user_input, with_env_vars, with_on_behalf_of, with_sdk, + ContextMetadata, ConversationItem, SystemKindError, SystemKindInfo, SystemKindRewind, + ToolCallStatusPending, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; + +use crate::provider::ProviderKind; +use crate::session::CapturedSession; + +pub const WRAPPER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HistoryItem { + UserInput { + text: String, + files: Vec, + }, + AssistantTurn { + text: String, + tool_calls: Vec, + model: Option, + finish_reason: Option, + }, + ToolResult { + call_id: String, + content: String, + is_error: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolCallRecord { + pub call_id: String, + pub name: String, + pub args: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct ArtifactRefs { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub response_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_path: Option, +} + +impl ArtifactRefs { + pub fn with_request_path(mut self, path: Option) -> Self { + self.request_path = path; + self + } + + pub fn with_response_path(mut self, path: Option) -> Self { + self.response_path = path; + self + } + + pub fn with_stream_path(mut self, path: Option) -> Self { + self.stream_path = path; + self + } +} + +#[derive(Debug, Clone)] +pub struct TurnEnvelope { + pub ordinal: u64, + pub item: ConversationItem, +} + +pub fn context_metadata( + provider: ProviderKind, + session: &CapturedSession, + allowlisted_env: &BTreeMap, +) -> ContextMetadata { + let mut custom = HashMap::new(); + custom.insert("stable_session_id".to_string(), session.session_id.clone()); + custom.insert( + "provider_kind".to_string(), + provider.provider_name().to_string(), + ); + custom.insert("wrapper_command".to_string(), session.child_command.clone()); + custom.insert("wrapper_version".to_string(), WRAPPER_VERSION.to_string()); + + let env_keys = allowlisted_env.keys().cloned().collect::>(); + let owner = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "unknown".to_string()); + let provenance = capture_process_provenance( + "cxtx", + WRAPPER_VERSION, + vec![ + with_on_behalf_of(owner, "cli", ""), + with_env_vars(Some(env_keys)), + with_sdk("cxtx", WRAPPER_VERSION), + ], + ); + + let mut metadata = ContextMetadata { + client_tag: provider.client_tag().to_string(), + title: format!( + "{} {} {}", + provider.client_tag(), + session.child_command, + session.started_at.format("%Y-%m-%dT%H:%M:%SZ") + ), + labels: provider.labels(), + custom, + provenance: None, + }; + attach_provenance(&mut metadata, provenance); + metadata +} + +pub fn session_start_item( + session: &CapturedSession, + provider: ProviderKind, + ordinal: u64, + child_pid: Option, + metadata: &ContextMetadata, +) -> ConversationItem { + let payload = json!({ + "stable_session_id": session.session_id, + "provider_kind": provider.provider_name(), + "child_command": session.child_command, + "child_args": session.child_args, + "child_pid": child_pid, + "started_at": session.started_at, + }); + let mut item = system_item( + session, + ordinal, + "wrapper-session-start", + SystemKindInfo, + "session_start", + payload, + ); + item.with_context_metadata(metadata.clone()); + item +} + +pub fn session_end_item( + session: &CapturedSession, + ordinal: u64, + exit_code: i32, + success: bool, +) -> ConversationItem { + system_item( + session, + ordinal, + "wrapper-session-end", + if success { + SystemKindInfo + } else { + SystemKindError + }, + "session_end", + json!({ + "stable_session_id": session.session_id, + "child_exit_code": exit_code, + "success": success, + }), + ) +} + +pub fn ingest_state_item( + session: &CapturedSession, + ordinal: u64, + title: &str, + kind: &str, + queue_depth: usize, + error: Option<&str>, +) -> ConversationItem { + let exchange_id = if title == "ingest_degraded" { + "wrapper-ingest-degraded" + } else { + "wrapper-ingest-recovered" + }; + system_item( + session, + ordinal, + exchange_id, + kind, + title, + json!({ + "stable_session_id": session.session_id, + "queue_depth": queue_depth, + "error": error, + }), + ) +} + +pub fn rewrite_item( + session: &CapturedSession, + ordinal: u64, + exchange_id: &str, + previous_len: usize, + new_len: usize, + artifact_refs: &ArtifactRefs, +) -> ConversationItem { + system_item( + session, + ordinal, + exchange_id, + SystemKindRewind, + "history_rewrite_detected", + json!({ + "stable_session_id": session.session_id, + "previous_history_len": previous_len, + "new_history_len": new_len, + "artifacts": artifact_refs, + }), + ) +} + +pub fn provider_error_item( + session: &CapturedSession, + ordinal: u64, + exchange_id: &str, + title: &str, + message: &str, + provider_request_id: Option<&str>, + artifact_refs: &ArtifactRefs, +) -> ConversationItem { + system_item( + session, + ordinal, + exchange_id, + SystemKindError, + title, + json!({ + "stable_session_id": session.session_id, + "message": message, + "provider_request_id": provider_request_id, + "artifacts": artifact_refs, + }), + ) +} + +pub fn history_item_to_conversation_item( + session: &CapturedSession, + ordinal: u64, + exchange_id: &str, + item: &HistoryItem, +) -> ConversationItem { + let id = turn_id(&session.session_id, ordinal, exchange_id); + match item { + HistoryItem::UserInput { text, files } => { + let mut item = new_user_input(text.clone(), files.clone()); + item.id = id; + item + } + HistoryItem::AssistantTurn { + text, + tool_calls, + model, + finish_reason, + } => { + let mut builder = build_assistant_turn(text.clone()); + for tool_call in tool_calls { + let mut tool_builder = build_tool_call_item( + tool_call.call_id.clone(), + tool_call.name.clone(), + tool_call.args.clone(), + ); + tool_builder.with_status(ToolCallStatusPending); + let built = tool_builder.build(); + builder.with_tool_call(built); + } + if let Some(reason) = finish_reason.as_ref().filter(|reason| !reason.is_empty()) { + builder.with_finish_reason(reason.clone()); + } + if let Some(model) = model.as_ref().filter(|model| !model.is_empty()) { + builder.with_full_metrics(cxdb::types::TurnMetrics { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cached_tokens: None, + reasoning_tokens: None, + duration_ms: None, + model: model.clone(), + }); + } + builder.with_id(id); + builder.build() + } + HistoryItem::ToolResult { + call_id, + content, + is_error, + } => { + let mut builder = build_tool_result(call_id.clone(), content.clone()); + if *is_error { + builder.with_error(); + } + let mut item = builder.build(); + item.id = id; + item + } + } +} + +pub fn tool_call_record(call_id: String, name: String, args: String) -> ToolCallRecord { + ToolCallRecord { + call_id, + name, + args, + } +} + +pub fn preview_text(text: &str, limit: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= limit { + trimmed.to_string() + } else { + let mut out = trimmed.chars().take(limit).collect::(); + out.push_str("..."); + out + } +} + +pub fn turn_id(session_id: &str, ordinal: u64, exchange_id: &str) -> String { + format!("{session_id}:{ordinal}:{exchange_id}") +} + +pub fn timestamp_ms(at: DateTime) -> i64 { + at.timestamp_millis() +} + +fn system_item( + session: &CapturedSession, + ordinal: u64, + exchange_id: &str, + kind: &str, + title: &str, + payload: Value, +) -> ConversationItem { + let mut builder = build_system(kind.to_string(), pretty_json(payload)); + builder.with_title(title.to_string()); + builder.with_id(turn_id(&session.session_id, ordinal, exchange_id)); + builder.build() +} + +fn pretty_json(value: Value) -> String { + serde_json::to_string_pretty(&value) + .unwrap_or_else(|_| "{\"message\":\"failed to encode system payload\"}".to_string()) +} diff --git a/cxtx/tests/integration.rs b/cxtx/tests/integration.rs new file mode 100644 index 0000000..a0b9872 --- /dev/null +++ b/cxtx/tests/integration.rs @@ -0,0 +1,1947 @@ +use std::collections::BTreeMap; +use std::fs; +use std::net::TcpListener; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock}; +use std::time::Duration; + +use assert_cmd::prelude::*; +use axum::body::Body; +use axum::extract::Path as AxumPath; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post, put}; +use axum::Router; +use cxdb_server::events::EventBus; +use cxdb_server::http::start_http; +use cxdb_server::metrics::{Metrics, SessionTracker}; +use cxdb_server::registry::Registry; +use cxdb_server::store::Store; +use predicates::prelude::*; +use reqwest::Client; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::net::TcpListener as TokioTcpListener; +use tokio::sync::oneshot; + +use cxtx::cxdb_http::CxdbHttpClient; +use cxtx::delivery::DeliveryHandle; +use cxtx::ledger::SessionLedgerWriter; +use cxtx::provider::ProviderKind; +use cxtx::session::SessionRuntime; + +#[tokio::test(flavor = "multi_thread")] +async fn first_record_metadata_is_queryable_via_cxdb_http() { + let _scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let client = + CxdbHttpClient::new(cxdb.base_url.parse().unwrap(), "cxtx-tests".to_string()).unwrap(); + let context_id = client.create_context().await.unwrap(); + client + .append_turn(context_id, &session.session_start_turn().item) + .await + .unwrap(); + + let contexts = client.list_contexts().await.unwrap(); + let listed = contexts["contexts"] + .as_array() + .unwrap() + .iter() + .find(|context| json_u64(&context["context_id"]) == Some(context_id)) + .cloned() + .unwrap(); + assert_eq!(listed["client_tag"], "cxtx/codex"); + assert!(listed["title"] + .as_str() + .unwrap() + .contains("cxtx/codex codex")); + assert!(listed["labels"] + .as_array() + .unwrap() + .iter() + .any(|label| label == "interactive")); + + let provenance = client.get_provenance(context_id).await.unwrap(); + assert_eq!(provenance["provenance"]["service_name"], "cxtx"); + assert_eq!(provenance["provenance"]["on_behalf_of_source"], "cli"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn registry_bundle_is_published_before_first_append() { + let _scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let client = + CxdbHttpClient::new(cxdb.base_url.parse().unwrap(), "cxtx-tests".to_string()).unwrap(); + let context_id = client.create_context().await.unwrap(); + client + .append_turn(context_id, &session.session_start_turn().item) + .await + .unwrap(); + + let descriptor = cxdb + .registry_type("cxdb.ConversationItem", 3) + .await + .unwrap(); + assert_eq!(descriptor["fields"]["1"]["name"], "item_type"); + assert_eq!(descriptor["fields"]["30"]["name"], "context_metadata"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_wrapper_preserves_child_io_and_uploads_canonical_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAi::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + let fixture_dir = scratch.dir.path().join("fixtures-codex"); + fs::create_dir_all(&fixture_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$OPENAI_BASE_URL" > "{fixture}/openai_base_url.txt" +printf '%s\n' "$OPENAI_API_BASE" > "{fixture}/openai_api_base.txt" +printf '%s\n' "$@" > "{fixture}/args.txt" +python3 - <<'PY' +import json +import os +import sys +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({{ + "model": "gpt-5", + "messages": [{{"role": "user", "content": "hello"}}], + "stream": False + }}).encode(), + headers={{ + "Content-Type": "application/json", + "Authorization": "Bearer test-openai" + }}, +) +with urllib.request.urlopen(req) as resp: + sys.stdout.write(resp.read().decode()) +PY +printf 'codex-child-stderr\n' >&2 +"#, + fixture = fixture_dir.display(), + ), + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--") + .arg("--model") + .arg("gpt-5"); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("\"id\":\"chatcmpl_123\"")); + assert!(String::from_utf8_lossy(&output.stderr).contains("codex-child-stderr")); + assert!(!String::from_utf8_lossy(&output.stdout).contains("cxtx:")); + + assert!(fs::read_to_string(fixture_dir.join("openai_base_url.txt")) + .unwrap() + .contains("/v1")); + assert_eq!( + fs::read_to_string(fixture_dir.join("args.txt")).unwrap(), + "--model\ngpt-5\n" + ); + + let contexts = cxdb.list_contexts().await.unwrap(); + let context_id = first_context_id(&contexts); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec!["system", "user_input", "assistant_turn", "system"] + ); + assert_eq!(turns[1]["data"]["user_input"]["text"], "hello"); + assert_eq!(turns[2]["data"]["turn"]["text"], "hi"); + assert_eq!(turns[0]["data"]["system"]["title"], "session_start"); + assert_eq!(turns[3]["data"]["system"]["title"], "session_end"); + + let ledger = find_single_ledger(scratch.dir.path()); + assert_eq!(ledger["provider_kind"], "openai"); + assert!(ledger["exchanges"][0]["request_path"] + .as_str() + .unwrap() + .ends_with("request.json")); + assert!(ledger["exchanges"][0]["response_path"] + .as_str() + .unwrap() + .ends_with("response.json")); + + let recorded_requests = upstream.requests.lock().unwrap().clone(); + assert_eq!(recorded_requests.len(), 1); + assert_eq!(recorded_requests[0]["path"], "/v1/chat/completions"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_replay_history_suppresses_duplicate_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAi::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer test-openai", +} + +def post(messages): + req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": messages, + "stream": False + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + +first = post([{"role": "user", "content": "hello"}]) +assistant = first["choices"][0]["message"]["content"] +post([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": assistant}, + {"role": "user", "content": "tell me more"}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "user_input", + "assistant_turn", + "system" + ] + ); + assert_eq!(turns[1]["data"]["user_input"]["text"], "hello"); + assert_eq!(turns[3]["data"]["user_input"]["text"], "tell me more"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_history_rewrite_emits_system_turn_and_resets_suffix() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAi::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer test-openai", +} + +def post(messages): + req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": messages, + "stream": False + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + +post([{"role": "user", "content": "hello"}]) +post([ + {"role": "user", "content": "start over"}, + {"role": "user", "content": "new question"}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "system", + "user_input", + "user_input", + "assistant_turn", + "system" + ] + ); + assert_eq!( + turns[3]["data"]["system"]["title"], + "history_rewrite_detected" + ); + assert_eq!(turns[4]["data"]["user_input"]["text"], "start over"); + assert_eq!(turns[5]["data"]["user_input"]["text"], "new question"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_tool_result_history_uploads_tool_related_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAiTooling::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "Authorization": "Bearer test-openai", +} + +def post(messages): + req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": messages, + "stream": False + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + +first = post([{"role": "user", "content": "use tool"}]) +tool_call = first["choices"][0]["message"]["tool_calls"][0] +post([ + {"role": "user", "content": "use tool"}, + {"role": "assistant", "content": "", "tool_calls": [tool_call]}, + {"role": "tool", "tool_call_id": tool_call["id"], "content": "lookup done"}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "tool_result", + "assistant_turn", + "system" + ] + ); + assert_eq!(turns[2]["data"]["turn"]["tool_calls"][0]["name"], "lookup"); + assert_eq!(turns[3]["data"]["tool_result"]["content"], "lookup done"); + assert_eq!(turns[4]["data"]["turn"]["text"], "tool complete"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_wrapper_streams_to_child_and_uploads_canonical_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockClaude::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + let fixture_dir = scratch.dir.path().join("fixtures-claude"); + fs::create_dir_all(&fixture_dir).unwrap(); + write_executable( + &fake_bin_dir.join("claude"), + &format!( + r#"#!/bin/sh +set -eu +printf '%s\n' "$ANTHROPIC_BASE_URL" > "{fixture}/anthropic_base_url.txt" +printf '%s\n' "$CLAUDE_BASE_URL" > "{fixture}/claude_base_url.txt" +printf '%s\n' "$@" > "{fixture}/args.txt" +python3 - <<'PY' +import json +import os +import sys +import urllib.request + +base = os.environ["ANTHROPIC_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/v1/messages", + data=json.dumps({{ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16, + "stream": True, + "messages": [{{"role": "user", "content": "hello"}}] + }}).encode(), + headers={{ + "Content-Type": "application/json", + "x-api-key": "test-anthropic", + "anthropic-version": "2023-06-01", + "Accept": "text/event-stream" + }}, +) +with urllib.request.urlopen(req) as resp: + for raw in resp: + sys.stdout.write(raw.decode()) +PY +printf 'claude-child-stderr\n' >&2 +"#, + fixture = fixture_dir.display(), + ), + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("ANTHROPIC_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("claude") + .arg("--") + .arg("--print") + .arg("stream"); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("event: message_start")); + assert!(String::from_utf8_lossy(&output.stderr).contains("claude-child-stderr")); + + let anthropic_base = fs::read_to_string(fixture_dir.join("anthropic_base_url.txt")).unwrap(); + assert!(anthropic_base.starts_with("http://127.0.0.1:")); + assert!(!anthropic_base.contains("/v1")); + + let contexts = cxdb.list_contexts().await.unwrap(); + let context_id = first_context_id(&contexts); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec!["system", "user_input", "assistant_turn", "system"] + ); + assert_eq!(turns[2]["data"]["turn"]["text"], "hello from claude"); + + let ledger = find_single_ledger(scratch.dir.path()); + assert!(ledger["exchanges"][0]["stream_path"] + .as_str() + .unwrap() + .ends_with("stream.ndjson")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_replay_history_suppresses_duplicate_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockClaudeJson::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("claude"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["ANTHROPIC_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "x-api-key": "test-anthropic", + "anthropic-version": "2023-06-01", +} + +def post(messages): + req = urllib.request.Request( + base + "/v1/messages", + data=json.dumps({ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16, + "stream": False, + "messages": messages, + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + +first = post([{"role": "user", "content": "hello"}]) +assistant = first["content"][0]["text"] +post([ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": assistant}, + {"role": "user", "content": "tell me more"}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("ANTHROPIC_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("claude") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "user_input", + "assistant_turn", + "system" + ] + ); + assert_eq!(turns[1]["data"]["user_input"]["text"], "hello"); + assert_eq!(turns[2]["data"]["turn"]["text"], "hello from claude"); + assert_eq!(turns[3]["data"]["user_input"]["text"], "tell me more"); + assert_eq!(turns[4]["data"]["turn"]["text"], "more from claude"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_history_rewrite_emits_system_turn_and_resets_suffix() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockClaudeJson::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("claude"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["ANTHROPIC_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "x-api-key": "test-anthropic", + "anthropic-version": "2023-06-01", +} + +def post(messages): + req = urllib.request.Request( + base + "/v1/messages", + data=json.dumps({ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16, + "stream": False, + "messages": messages, + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read().decode()) + +post([{"role": "user", "content": "hello"}]) +post([ + {"role": "user", "content": "start over"}, + {"role": "user", "content": "new question"}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("ANTHROPIC_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("claude") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "system", + "user_input", + "user_input", + "assistant_turn", + "system" + ] + ); + assert_eq!( + turns[3]["data"]["system"]["title"], + "history_rewrite_detected" + ); + assert_eq!(turns[4]["data"]["user_input"]["text"], "start over"); + assert_eq!(turns[5]["data"]["user_input"]["text"], "new question"); + assert_eq!(turns[6]["data"]["turn"]["text"], "fresh answer"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_tool_result_history_uploads_tool_related_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockClaudeTooling::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("claude"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import urllib.request + +base = os.environ["ANTHROPIC_BASE_URL"].rstrip("/") +headers = { + "Content-Type": "application/json", + "x-api-key": "test-anthropic", + "anthropic-version": "2023-06-01", + "Accept": "text/event-stream", +} + +def post(messages): + req = urllib.request.Request( + base + "/v1/messages", + data=json.dumps({ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16, + "stream": True, + "messages": messages, + }).encode(), + headers=headers, + ) + with urllib.request.urlopen(req) as resp: + for _ in resp: + pass + +post([{"role": "user", "content": "use tool"}]) +post([ + {"role": "user", "content": "use tool"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "call_1", "name": "lookup", "input": {"q": "use tool"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": [{"type": "text", "text": "done"}]}]}, +]) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("ANTHROPIC_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("claude") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!( + item_types, + vec![ + "system", + "user_input", + "assistant_turn", + "tool_result", + "assistant_turn", + "system" + ] + ); + assert_eq!(turns[2]["data"]["turn"]["tool_calls"][0]["name"], "lookup"); + assert_eq!(turns[3]["data"]["tool_result"]["content"], "done"); + assert_eq!(turns[4]["data"]["turn"]["text"], "tool complete"); + + let ledger = find_single_ledger(scratch.dir.path()); + assert!(ledger["exchanges"][0]["stream_path"] + .as_str() + .unwrap() + .ends_with("stream.ndjson")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_upstream_error_response_emits_system_turn() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAiUpstreamError::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import sys +import urllib.error +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "hello"}], + "stream": False + }).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer test-openai" + }, +) +try: + with urllib.request.urlopen(req) as resp: + sys.stdout.write(resp.read().decode()) +except urllib.error.HTTPError as err: + sys.stdout.write(err.read().decode()) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + command.assert().success(); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!(item_types, vec!["system", "user_input", "system", "system"]); + assert_eq!( + turns[2]["data"]["system"]["title"], + "provider_error_response" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn codex_malformed_json_response_emits_system_error_turn() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockOpenAiMalformed::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import sys +import urllib.request + +base = os.environ["OPENAI_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "hello"}], + "stream": False + }).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer test-openai" + }, +) +with urllib.request.urlopen(req) as resp: + sys.stdout.write(resp.read().decode()) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "not-json"); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!(item_types, vec!["system", "user_input", "system", "system"]); + assert_eq!(turns[2]["data"]["system"]["title"], "response_parse_error"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn claude_malformed_stream_emits_system_error_turn() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let upstream = MockClaudeMalformed::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("claude"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import sys +import urllib.request + +base = os.environ["ANTHROPIC_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/v1/messages", + data=json.dumps({ + "model": "claude-3-7-sonnet-20250219", + "max_tokens": 16, + "stream": True, + "messages": [{"role": "user", "content": "hello"}] + }).encode(), + headers={ + "Content-Type": "application/json", + "x-api-key": "test-anthropic", + "anthropic-version": "2023-06-01", + "Accept": "text/event-stream" + }, +) +with urllib.request.urlopen(req) as resp: + for raw in resp: + sys.stdout.write(raw.decode()) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("ANTHROPIC_BASE_URL", upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("claude") + .arg("--"); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("event: content_block_start")); + + let context_id = first_context_id(&cxdb.list_contexts().await.unwrap()); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!(item_types, vec!["system", "user_input", "system", "system"]); + assert_eq!(turns[2]["data"]["system"]["title"], "stream_parse_error"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn missing_child_binary_does_not_create_cxdb_context() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", scratch.dir.path()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--") + .arg("--help"); + command + .assert() + .failure() + .stderr(predicate::str::contains("failed to launch codex")); + + let contexts = cxdb.list_contexts().await.unwrap(); + assert_eq!(contexts["count"], 0); + + let ledger = find_single_ledger(scratch.dir.path()); + assert_eq!(ledger["delivery_state"], "child_launch_failed"); + assert_eq!(ledger["cxdb_context_id"], Value::Null); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_cxdb_url_fails_before_session_bootstrap() { + let scratch = ScratchRoot::new().unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .arg("--url") + .arg("not-a-url") + .arg("codex") + .arg("--") + .arg("--help"); + command + .assert() + .failure() + .stderr(predicate::str::contains("invalid CXDB URL: not-a-url")); + + assert!(!scratch.dir.path().join(".scratch").exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn queued_delivery_recovers_when_cxdb_appears_later() { + let _scratch = ScratchRoot::new().unwrap(); + let port = free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let ledger = SessionLedgerWriter::create(&session).await.unwrap(); + let delivery = DeliveryHandle::start( + base_url.parse().unwrap(), + session.clone(), + ledger.clone(), + "cxtx/test".to_string(), + ) + .await + .unwrap(); + delivery.enqueue_create_context().await.unwrap(); + delivery + .enqueue_turn(session.session_start_turn()) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(400)).await; + let cxdb = TestCxdb::start_on_port(port).await.unwrap(); + delivery + .enqueue_turn(session.session_end_turn(0, true)) + .await + .unwrap(); + delivery.shutdown().await.unwrap(); + + let contexts = cxdb.list_contexts().await.unwrap(); + assert_eq!(contexts["count"], 1); + let ledger_json = serde_json::from_slice::(&fs::read(ledger.path()).unwrap()).unwrap(); + assert_eq!(ledger_json["delivery_state"], "healthy"); + assert!(ledger_json["cxdb_context_id"].as_u64().unwrap() > 0); + assert!(ledger_json["appended_sequences"] + .as_array() + .unwrap() + .iter() + .any(|sequence| sequence == 1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn queued_delivery_recovers_from_mid_session_append_failure_in_order() { + let _scratch = ScratchRoot::new().unwrap(); + let fake = FakeRecoveringCxdb::start().await.unwrap(); + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let ledger = SessionLedgerWriter::create(&session).await.unwrap(); + let delivery = DeliveryHandle::start( + fake.base_url.parse().unwrap(), + session.clone(), + ledger.clone(), + "cxtx/test".to_string(), + ) + .await + .unwrap(); + delivery.enqueue_create_context().await.unwrap(); + delivery + .enqueue_turn(session.session_start_turn()) + .await + .unwrap(); + delivery + .enqueue_turn(session.session_end_turn(0, true)) + .await + .unwrap(); + delivery.shutdown().await.unwrap(); + + let state = fake.state.lock().unwrap().clone(); + assert!(state.registry_ready); + assert_eq!(state.create_calls, 1); + assert_eq!( + state.stored_item_types, + vec!["system", "system", "system", "system"] + ); + assert_eq!( + state.stored_system_titles, + vec![ + "session_start", + "session_end", + "ingest_degraded", + "ingest_recovered" + ] + ); + + let ledger_json = serde_json::from_slice::(&fs::read(ledger.path()).unwrap()).unwrap(); + assert_eq!(ledger_json["delivery_state"], "healthy"); + assert_eq!(ledger_json["appended_sequences"], json!([1, 2, 3, 4])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn shutdown_drain_records_remaining_queue_state() { + let _scratch = ScratchRoot::new().unwrap(); + let port = free_port(); + let base_url = format!("http://127.0.0.1:{port}"); + let session = SessionRuntime::new( + ProviderKind::Codex, + vec!["--help".to_string()], + BTreeMap::new(), + ) + .unwrap(); + let ledger = SessionLedgerWriter::create(&session).await.unwrap(); + let delivery = DeliveryHandle::start( + base_url.parse().unwrap(), + session.clone(), + ledger.clone(), + "cxtx/test".to_string(), + ) + .await + .unwrap(); + delivery.enqueue_create_context().await.unwrap(); + delivery + .enqueue_turn(session.session_start_turn()) + .await + .unwrap(); + delivery.shutdown().await.unwrap(); + + let ledger_json = serde_json::from_slice::(&fs::read(ledger.path()).unwrap()).unwrap(); + assert_eq!(ledger_json["delivery_state"], "degraded"); + assert!(ledger_json["queue_depth"].as_u64().unwrap() > 0); + assert!(ledger_json["last_delivery_error"] + .as_str() + .unwrap() + .contains("shutdown drain deadline reached")); + assert_eq!(ledger_json["cxdb_context_id"], Value::Null); + assert!(ledger_json["appended_sequences"] + .as_array() + .unwrap() + .is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_bypassing_env_overrides_produces_only_lifecycle_turns() { + let scratch = ScratchRoot::new().unwrap(); + let cxdb = TestCxdb::start().await.unwrap(); + let direct_upstream = MockOpenAi::start().await.unwrap(); + let fake_bin_dir = scratch.dir.path().join("bin"); + fs::create_dir_all(&fake_bin_dir).unwrap(); + write_executable( + &fake_bin_dir.join("codex"), + r#"#!/bin/sh +set -eu +python3 - <<'PY' +import json +import os +import sys +import urllib.request + +base = os.environ["BYPASS_OPENAI_BASE_URL"].rstrip("/") +req = urllib.request.Request( + base + "/chat/completions", + data=json.dumps({ + "model": "gpt-5", + "messages": [{"role": "user", "content": "hello"}], + "stream": False + }).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer test-openai" + }, +) +with urllib.request.urlopen(req) as resp: + sys.stdout.write(resp.read().decode()) +PY +"#, + ) + .unwrap(); + + let mut command = std::process::Command::cargo_bin("cxtx").unwrap(); + command + .current_dir(scratch.dir.path()) + .env("PATH", prepend_path(&fake_bin_dir)) + .env("OPENAI_BASE_URL", "http://ignored-by-wrapper.invalid/v1") + .env("BYPASS_OPENAI_BASE_URL", direct_upstream.base_url.as_str()) + .arg("--url") + .arg(&cxdb.base_url) + .arg("codex") + .arg("--"); + let output = command.output().unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("\"id\":\"chatcmpl_123\"")); + + let contexts = cxdb.list_contexts().await.unwrap(); + let context_id = first_context_id(&contexts); + let turns = cxdb.turns(context_id).await.unwrap(); + let item_types = turn_item_types(&turns); + assert_eq!(item_types, vec!["system", "system"]); + + let recorded_requests = direct_upstream.requests.lock().unwrap().clone(); + assert_eq!(recorded_requests.len(), 1); + assert_eq!(recorded_requests[0]["path"], "/v1/chat/completions"); +} + +struct ScratchRoot { + dir: TempDir, + previous: PathBuf, + _guard: MutexGuard<'static, ()>, +} + +impl ScratchRoot { + fn new() -> anyhow::Result { + static CWD_LOCK: OnceLock> = OnceLock::new(); + let guard = CWD_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = tempfile::tempdir()?; + let previous = std::env::current_dir()?; + std::env::set_current_dir(dir.path())?; + Ok(Self { + dir, + previous, + _guard: guard, + }) + } +} + +impl Drop for ScratchRoot { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.previous); + } +} + +struct TestCxdb { + base_url: String, +} + +impl TestCxdb { + async fn start() -> anyhow::Result { + Self::start_on_port(free_port()).await + } + + async fn start_on_port(port: u16) -> anyhow::Result { + let tempdir = tempfile::tempdir()?; + let data_dir = tempdir.path().to_path_buf(); + std::mem::forget(tempdir); + let bind_addr = format!("127.0.0.1:{port}"); + let store = Arc::new(RwLock::new(Store::open(&data_dir)?)); + let registry = Arc::new(Mutex::new(Registry::open(&data_dir.join("registry"))?)); + let metrics = Arc::new(Metrics::new(data_dir.clone())); + let session_tracker = Arc::new(SessionTracker::new()); + let event_bus = Arc::new(EventBus::new()); + start_http( + bind_addr.clone(), + store, + registry, + metrics, + session_tracker, + event_bus, + )?; + wait_for_http(&format!("http://{bind_addr}/healthz")).await?; + Ok(Self { + base_url: format!("http://{bind_addr}"), + }) + } + + async fn list_contexts(&self) -> anyhow::Result { + Client::new() + .get(format!( + "{}/v1/contexts?include_provenance=1", + self.base_url + )) + .send() + .await? + .json() + .await + .map_err(Into::into) + } + + async fn turns(&self, context_id: u64) -> anyhow::Result> { + let response: Value = Client::new() + .get(format!( + "{}/v1/contexts/{context_id}/turns?view=typed&limit=64", + self.base_url + )) + .send() + .await? + .json() + .await?; + let mut turns = response["turns"] + .as_array() + .cloned() + .ok_or_else(|| anyhow::anyhow!("unexpected turns response: {response}"))?; + turns.sort_by_key(|turn| turn["depth"].as_u64().unwrap()); + Ok(turns) + } + + async fn registry_type(&self, type_id: &str, version: u64) -> anyhow::Result { + Client::new() + .get(format!( + "{}/v1/registry/types/{type_id}/versions/{version}", + self.base_url + )) + .send() + .await? + .error_for_status()? + .json() + .await + .map_err(Into::into) + } +} + +struct MockOpenAi { + base_url: String, + requests: Arc>>, + _shutdown: oneshot::Sender<()>, +} + +impl MockOpenAi { + async fn start() -> anyhow::Result { + let requests = Arc::new(Mutex::new(Vec::new())); + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new() + .route("/v1/chat/completions", post(mock_openai_handler)) + .with_state(requests.clone()); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}/v1"), + requests, + _shutdown: shutdown_tx, + }) + } +} + +struct MockOpenAiMalformed { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +struct MockOpenAiTooling { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockOpenAiTooling { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/chat/completions", post(mock_openai_tooling_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}/v1"), + _shutdown: shutdown_tx, + }) + } +} + +struct MockOpenAiUpstreamError { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockOpenAiUpstreamError { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route( + "/v1/chat/completions", + post(mock_openai_upstream_error_handler), + ); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}/v1"), + _shutdown: shutdown_tx, + }) + } +} + +impl MockOpenAiMalformed { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/chat/completions", post(mock_openai_malformed_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}/v1"), + _shutdown: shutdown_tx, + }) + } +} + +struct MockClaude { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockClaude { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/messages", post(mock_claude_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}"), + _shutdown: shutdown_tx, + }) + } +} + +struct MockClaudeJson { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockClaudeJson { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/messages", post(mock_claude_json_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}"), + _shutdown: shutdown_tx, + }) + } +} + +struct MockClaudeTooling { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockClaudeTooling { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/messages", post(mock_claude_tooling_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}"), + _shutdown: shutdown_tx, + }) + } +} + +struct MockClaudeMalformed { + base_url: String, + _shutdown: oneshot::Sender<()>, +} + +impl MockClaudeMalformed { + async fn start() -> anyhow::Result { + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new().route("/v1/messages", post(mock_claude_malformed_handler)); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}"), + _shutdown: shutdown_tx, + }) + } +} + +#[derive(Clone, Default)] +struct FakeRecoveringCxdbState { + registry_ready: bool, + create_calls: usize, + append_calls: usize, + stored_item_types: Vec, + stored_system_titles: Vec, +} + +struct FakeRecoveringCxdb { + base_url: String, + state: Arc>, + _shutdown: oneshot::Sender<()>, +} + +impl FakeRecoveringCxdb { + async fn start() -> anyhow::Result { + let state = Arc::new(Mutex::new(FakeRecoveringCxdbState::default())); + let listener = TokioTcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new() + .route( + "/v1/registry/types/cxdb.ConversationItem/versions/3", + get(fake_registry_type_handler), + ) + .route( + "/v1/registry/bundles/:bundle_id", + put(fake_registry_bundle_handler), + ) + .route("/v1/contexts/create", post(fake_create_context_handler)) + .route( + "/v1/contexts/:context_id/append", + post(fake_append_turn_handler), + ) + .with_state(state.clone()); + tokio::spawn(async move { + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await; + }); + Ok(Self { + base_url: format!("http://{addr}"), + state, + _shutdown: shutdown_tx, + }) + } +} + +async fn mock_openai_handler( + State(requests): State>>>, + request: axum::http::Request, +) -> impl IntoResponse { + let (parts, body) = request.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let json_body: Value = serde_json::from_slice(&body).unwrap(); + requests.lock().unwrap().push(json!({ + "path": parts.uri.path(), + "body": json_body.clone(), + })); + + let last_user = json_body["messages"] + .as_array() + .and_then(|messages| messages.last()) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .unwrap_or("hello"); + let response_text = if last_user == "tell me more" { + "more detail" + } else { + "hi" + }; + + ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("x-request-id", "req_openai_123"), + ], + Body::from( + json!({ + "id": "chatcmpl_123", + "model": "gpt-5", + "choices": [{"message": {"role": "assistant", "content": response_text}}] + }) + .to_string(), + ), + ) +} + +async fn mock_openai_malformed_handler() -> impl IntoResponse { + ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("x-request-id", "req_openai_malformed_123"), + ], + Body::from("not-json"), + ) +} + +async fn mock_openai_tooling_handler(request: axum::http::Request) -> impl IntoResponse { + let (_, body) = request.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let json_body: Value = serde_json::from_slice(&body).unwrap(); + let messages = json_body["messages"] + .as_array() + .cloned() + .unwrap_or_default(); + let has_tool_result = messages + .iter() + .any(|message| message["role"] == "tool" && message["content"] == "lookup done"); + + let response = if has_tool_result { + json!({ + "id": "chatcmpl_tool_2", + "model": "gpt-5", + "choices": [{ + "message": { + "role": "assistant", + "content": "tool complete" + } + }] + }) + } else { + json!({ + "id": "chatcmpl_tool_1", + "model": "gpt-5", + "choices": [{ + "message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "lookup", + "arguments": "{\"q\":\"use tool\"}" + } + }] + } + }] + }) + }; + + ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("x-request-id", "req_openai_tool_123"), + ], + Body::from(response.to_string()), + ) +} + +async fn mock_openai_upstream_error_handler() -> impl IntoResponse { + ( + StatusCode::BAD_GATEWAY, + [ + ("content-type", "application/json"), + ("x-request-id", "req_openai_error_123"), + ], + Body::from(json!({"error": "upstream failed"}).to_string()), + ) +} + +async fn mock_claude_handler() -> impl IntoResponse { + let body = Body::from_stream(async_stream::stream! { + for chunk in [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-3-7-sonnet-20250219\"}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hello \"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"from claude\"}}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ] { + yield Ok::<_, std::io::Error>(bytes::Bytes::from(chunk)); + } + }); + ( + StatusCode::OK, + [ + ("content-type", "text/event-stream"), + ("request-id", "req_claude_123"), + ], + body, + ) +} + +async fn mock_claude_json_handler(request: axum::http::Request) -> impl IntoResponse { + let (_, body) = request.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let json_body: Value = serde_json::from_slice(&body).unwrap(); + let last_user = anthropic_last_user_text(&json_body).unwrap_or("hello"); + let response_text = match last_user { + "tell me more" => "more from claude", + "new question" => "fresh answer", + _ => "hello from claude", + }; + + ( + StatusCode::OK, + [ + ("content-type", "application/json"), + ("request-id", "req_claude_json_123"), + ], + Body::from( + json!({ + "id": "msg_json_123", + "model": "claude-3-7-sonnet-20250219", + "content": [{"type": "text", "text": response_text}], + "stop_reason": "end_turn" + }) + .to_string(), + ), + ) +} + +async fn mock_claude_tooling_handler(request: axum::http::Request) -> impl IntoResponse { + let (_, body) = request.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let json_body: Value = serde_json::from_slice(&body).unwrap(); + let has_tool_result = json_body["messages"] + .as_array() + .into_iter() + .flatten() + .any(|message| { + message["role"] == "user" + && message["content"] + .as_array() + .into_iter() + .flatten() + .any(|block| block["type"] == "tool_result") + }); + + let chunks = if has_tool_result { + vec![ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_2\",\"model\":\"claude-3-7-sonnet-20250219\"}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"tool complete\"}}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ] + } else { + vec![ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_tool_1\",\"model\":\"claude-3-7-sonnet-20250219\"}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"lookup\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"q\\\":\\\"use tool\\\"}\"}}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ] + }; + let body = Body::from_stream(async_stream::stream! { + for chunk in chunks { + yield Ok::<_, std::io::Error>(bytes::Bytes::from(chunk)); + } + }); + + ( + StatusCode::OK, + [ + ("content-type", "text/event-stream"), + ("request-id", "req_claude_tool_123"), + ], + body, + ) +} + +async fn mock_claude_malformed_handler() -> impl IntoResponse { + let body = Body::from_stream(async_stream::stream! { + for chunk in [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-3-7-sonnet-20250219\"}}\n\n", + "event: content_block_start\ndata: {not-json}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ] { + yield Ok::<_, std::io::Error>(bytes::Bytes::from(chunk)); + } + }); + ( + StatusCode::OK, + [ + ("content-type", "text/event-stream"), + ("request-id", "req_claude_malformed_123"), + ], + body, + ) +} + +async fn fake_registry_type_handler( + State(state): State>>, +) -> impl IntoResponse { + let state = state.lock().unwrap(); + if state.registry_ready { + ( + StatusCode::OK, + Body::from(json!({"type_id": "cxdb.ConversationItem", "version": 3}).to_string()), + ) + .into_response() + } else { + StatusCode::NOT_FOUND.into_response() + } +} + +async fn fake_registry_bundle_handler( + State(state): State>>, + AxumPath(_bundle_id): AxumPath, +) -> impl IntoResponse { + state.lock().unwrap().registry_ready = true; + (StatusCode::OK, Body::from(json!({"ok": true}).to_string())) +} + +async fn fake_create_context_handler( + State(state): State>>, +) -> impl IntoResponse { + state.lock().unwrap().create_calls += 1; + ( + StatusCode::OK, + Body::from(json!({"context_id": "41", "head_turn_id": "0", "head_depth": 0}).to_string()), + ) +} + +async fn fake_append_turn_handler( + State(state): State>>, + AxumPath(_context_id): AxumPath, + request: axum::http::Request, +) -> impl IntoResponse { + let (_, body) = request.into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let json_body: Value = serde_json::from_slice(&body).unwrap(); + let mut state = state.lock().unwrap(); + state.append_calls += 1; + if state.append_calls == 1 { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Body::from("temporary append failure"), + ) + .into_response(); + } + state.stored_item_types.push( + json_body["data"]["item_type"] + .as_str() + .unwrap_or_default() + .to_string(), + ); + if let Some(title) = json_body["data"]["system"]["title"].as_str() { + state.stored_system_titles.push(title.to_string()); + } + ( + StatusCode::OK, + Body::from(json!({"turn_id": state.append_calls.to_string()}).to_string()), + ) + .into_response() +} + +fn turn_item_types(turns: &[Value]) -> Vec<&str> { + turns + .iter() + .map(|turn| { + turn["data"]["item_type"] + .as_str() + .unwrap_or_else(|| panic!("missing item_type in typed turn: {turn}")) + }) + .collect() +} + +fn first_context_id(contexts: &Value) -> u64 { + json_u64(&contexts["contexts"][0]["context_id"]).unwrap() +} + +fn json_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) +} + +fn anthropic_last_user_text(payload: &Value) -> Option<&str> { + payload["messages"] + .as_array()? + .iter() + .rev() + .find(|message| message["role"] == "user") + .and_then(|message| match &message["content"] { + Value::String(value) => Some(value.as_str()), + Value::Array(blocks) => blocks + .iter() + .rev() + .find(|block| block["type"] == "text") + .and_then(|block| block["text"].as_str()), + _ => None, + }) +} + +fn wait_for_http(url: &str) -> impl std::future::Future> + '_ { + async move { + for _ in 0..40 { + match Client::new().get(url).send().await { + Ok(response) if response.status().is_success() => return Ok(()), + _ => tokio::time::sleep(Duration::from_millis(50)).await, + } + } + anyhow::bail!("server at {url} did not become ready") + } +} + +fn write_executable(path: &Path, contents: &str) -> anyhow::Result<()> { + fs::write(path, contents)?; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + Ok(()) +} + +fn prepend_path(dir: &Path) -> String { + let existing = std::env::var("PATH").unwrap_or_default(); + format!("{}:{existing}", dir.display()) +} + +fn find_single_ledger(root: &Path) -> Value { + let sessions_dir = root.join(".scratch").join("cxtx").join("sessions"); + let entries = fs::read_dir(sessions_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(entries.len(), 1); + serde_json::from_slice(&fs::read(entries[0].join("ledger.json")).unwrap()).unwrap() +} + +fn free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} diff --git a/docs/ADR.md b/docs/ADR.md new file mode 100644 index 0000000..fd80497 --- /dev/null +++ b/docs/ADR.md @@ -0,0 +1,60 @@ +# Architecture Decision Record Log + +## ADR-001 - `cxtx` Uses Local Reverse Proxy Capture and CXDB HTTP Ingest + +- Status: Accepted +- Date: 2026-03-17 + +### Context + +CXDB already exposes HTTP context creation and append routes that are easy for tools to consume, while the adjacent `tc` project demonstrates a practical wrapper pattern for `claude` and `codex`: launch the child process, inject provider base URL environment variables, and observe OpenAI/Anthropic traffic through a localhost reverse proxy. + +The new `cxtx` CLI needs to preserve the child CLI experience while transmitting useful session context into CXDB with minimal operator friction. The implementation also needs to fit naturally into this repository's existing Rust workspace and developer workflow. + +### Decision + +Implement `cxtx` as a new Rust workspace member that: + +1. launches `claude` or `codex` as a child process while preserving stdin/stdout/stderr and exit status; +2. starts a localhost reverse proxy and injects provider base URL environment variables so provider traffic traverses `cxtx`; +3. captures provider requests, responses, stream frames, and wrapper lifecycle transitions, but uses that traffic only to extract newly observed turns within one stable wrapper session; +4. writes the captured session to CXDB through the existing HTTP create and append endpoints rather than introducing a second ingest path for this sprint; +5. uses a real-time delivery worker for CXDB ingest that attempts immediate transmission, but when the ingest endpoint is unavailable, queues unsent messages locally in memory and retries in order until delivery succeeds or the wrapper reaches its documented shutdown boundary. + +`cxtx` will create one CXDB context per CLI invocation, mint one stable session ID for that invocation, attach `ContextMetadata` and `Provenance` on the first appended turn, and append only newly extracted conversation turns for that stable session rather than uploading every replayed provider payload verbatim. + +The primary CXDB wire contract for this sprint is the existing canonical `cxdb.ConversationItem` type rather than a new `cxtx`-specific top-level turn type. `cxtx` will convert extracted user input, assistant output, tool activity, and lifecycle events into canonical conversation items so the server's metadata extraction and the frontend's built-in conversation renderer work without extra registry or renderer work. Session-specific correlation data such as stable session IDs, wrapper identity, and provider-exchange references will be attached through first-turn context metadata, per-item IDs, and system-message content. + +Raw provider request bodies, response bodies, and stream-frame evidence will remain local session-ledger artifacts under `.scratch/cxtx/`, keyed by exchange correlation IDs. CXDB remains the extracted conversation and lifecycle record; the ledger remains the debugging and verification evidence store. + +Provider-specific child handling is explicit for this sprint. `codex` receives `OPENAI_BASE_URL` and `OPENAI_API_BASE` pointed at the local proxy with the OpenAI-compatible upstream base path preserved. `claude` receives `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_URL`, `ANTHROPIC_API_BASE`, `CLAUDE_BASE_URL`, `CLAUDE_API_BASE`, and `CLAUDE_CODE_BASE_URL` pointed at the local proxy root. Arguments after the wrapper's `--` separator are forwarded verbatim to the child command in original order, and existing auth environment such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` is inherited unchanged. + +`cxtx` will not emit wrapper-originated stdout during successful execution. Child process stdout and stderr remain authoritative, and wrapper-authored terminal output is reserved for fatal preflight errors and explicitly documented failure conditions. + +This sprint does not introduce a separate "capture-only, no ingest" mode. If `--url` is omitted, `cxtx` still targets the documented local default, but a failed CXDB connection is handled as a degraded queued-delivery condition rather than a cryptic immediate abort. The child process still launches, and `cxtx` emits a clear stderr status message describing the degraded ingest state. + +### Consequences + +#### Positive + +- Reuses an already-proven wrapper technique instead of inventing a new interception model. +- Avoids requiring downstream users to understand the CXDB binary protocol for this workflow. +- Produces a CXDB session shape that more closely matches how operators expect to review a conversation. +- Uses a stable session ID plus provider correlation state to upload only newly observed turns even when providers replay full history on every request. +- Keeps the new feature inside the Rust workspace, where the proxy-oriented implementation fits naturally. +- Preserves the interactive feel of `claude` and `codex` because the wrapper avoids competing terminal chatter. +- Avoids losing early session events when CXDB is temporarily unavailable because delivery is retried from an in-memory queue. +- Reuses the repo's existing canonical conversation type, metadata extraction path, and frontend renderer instead of creating a second primary browsing experience. + +#### Negative + +- Turn extraction reintroduces provider-specific interpretation logic and replay-handling complexity that must be specified and tested carefully. +- HTTP ingest duplicates some client behavior that already exists in the binary SDKs. +- The wrapper only works for CLIs that honor provider base URL environment variables. +- In-memory queued delivery introduces queue-management and shutdown-drain complexity that the implementation must make explicit and test thoroughly. +- Operators who need raw provider payloads must correlate CXDB system messages with local session-ledger artifacts instead of reading raw payloads directly from CXDB turns. + +#### Follow-Up + +- If downstream consumers later need full raw-traffic preservation in addition to extracted conversation turns, a later ADR can add optional raw-capture persistence alongside the extracted-conversation path. +- If prolonged CXDB outages or very large sessions make memory-only buffering unsafe, a later ADR can add disk-backed spooling. That is out of scope for this sprint. diff --git a/docs/development.md b/docs/development.md index fdba2f2..a0e0175 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,6 +44,9 @@ cxdb/ │ │ └── cmd/ # Example programs │ └── rust/ # Rust client SDK │ └── cxdb/ +├── cxtx/ # Rust CLI wrapper for codex/claude session capture +│ ├── src/ +│ └── tests/ ├── gateway/ # Go OAuth proxy + static serving │ ├── cmd/server/ │ ├── internal/ @@ -76,8 +79,26 @@ cargo run ``` Binaries are output to: -- Debug: `target/debug/ai-cxdb-store` -- Release: `target/release/ai-cxdb-store` +- Debug: `target/debug/cxdb-server` +- Release: `target/release/cxdb-server` + +### `cxtx` Wrapper + +```bash +# Build only the wrapper +cargo build -p cxtx + +# Show CLI contract +cargo run -p cxtx -- --help + +# Run package tests +cargo test -p cxtx + +# Run the end-to-end integration suite +cargo test -p cxtx --test integration +``` + +`cxtx` is a Rust workspace member. It wraps `codex` or `claude`, publishes the canonical `cxdb.ConversationItem` registry bundle through the HTTP API, appends extracted turns to CXDB, preserves child stdio and exit status, and writes local evidence to `.scratch/cxtx/sessions/`. Transparent capture depends on the child honoring the injected provider base URL environment variables; if a CLI bypasses those overrides, `cxtx` can still record lifecycle turns but cannot observe provider traffic that never reaches the proxy. **Environment variables:** @@ -230,7 +251,10 @@ cargo test test_append_turn cargo test -- --nocapture # Run tests in specific module -cargo test --package ai-cxdb-store --lib blob_store +cargo test --package cxdb-server --lib blob_store + +# Run only the cxtx wrapper package +cargo test -p cxtx ``` ### Go Tests @@ -423,7 +447,7 @@ bundle := map[string]interface{}{ cargo build # Run with debugger -rust-lldb target/debug/ai-cxdb-store +rust-lldb target/debug/cxdb-server # Set breakpoint (lldb) b blob_store::mod::put @@ -447,7 +471,7 @@ rust-lldb target/debug/ai-cxdb-store "request": "launch", "name": "Debug CXDB", "cargo": { - "args": ["build", "--package=ai-cxdb-store"] + "args": ["build", "--package=cxdb-server"] }, "args": [], "cwd": "${workspaceFolder}", @@ -497,7 +521,7 @@ pnpm dev cargo install flamegraph # Profile -cargo flamegraph --bin ai-cxdb-store +cargo flamegraph --bin cxdb-server # Opens flamegraph.svg in browser ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 840964d..ba7de14 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -60,7 +60,7 @@ cd cxdb cargo build --release # Run the server -CXDB_DATA_DIR=./data ./target/release/ai-cxdb-store +CXDB_DATA_DIR=./data ./target/release/cxdb-server ``` The server will start on: @@ -180,6 +180,37 @@ Response: } ``` +## Capture a Local CLI Session with `cxtx` + +If you already use `codex` or `claude`, you can capture that session into CXDB without changing the child CLI workflow: + +```bash +# Build the wrapper once +cargo build --release -p cxtx + +# Wrap Codex with the default local CXDB HTTP endpoint +./target/release/cxtx codex -- --help + +# Or wrap Claude against an explicit endpoint +./target/release/cxtx --url http://127.0.0.1:9010 claude -- --help +``` + +After a wrapped run: + +- CXDB receives canonical `cxdb.ConversationItem` turns for the session. +- The first uploaded turn carries `ContextMetadata` and `Provenance`, so the context is searchable in `/v1/contexts`. +- `cxtx` publishes the bundled canonical `cxdb.ConversationItem` registry descriptor automatically before the first append when needed. +- Raw provider request, response, and stream evidence is written locally under `.scratch/cxtx/sessions//`. +- If CXDB is temporarily unavailable, `cxtx` still launches the child, enters queued-delivery mode, and records the degradation plus recovery state in the local ledger and stored lifecycle turns. +- Transparent capture depends on the child honoring the injected provider base URL environment variables. If a CLI bypasses those overrides, `cxtx` still records lifecycle turns, but it cannot capture provider traffic that never crosses the local proxy. + +Use these endpoints to inspect captured runs: + +```bash +curl 'http://localhost:9010/v1/contexts?tag=cxtx/codex&include_provenance=1' +curl 'http://localhost:9010/v1/contexts?tag=cxtx/claude&include_provenance=1' +``` + ## Using the Go Client SDK For production use, the Go client provides a more efficient binary protocol: diff --git a/examples/README.md b/examples/README.md index bfbc784..b336690 100644 --- a/examples/README.md +++ b/examples/README.md @@ -149,6 +149,27 @@ python agent.py --- +### 7. [../cxtx/README.md](../cxtx/README.md) - CLI Session Capture Wrapper + +**What it demonstrates**: Wrapping an existing `codex` or `claude` CLI so provider traffic is captured, normalized into canonical `cxdb.ConversationItem` turns, and uploaded through the CXDB HTTP API. + +**Operations**: +- Launch a child CLI through a local reverse proxy +- Preserve child stdin, stdout, stderr, and exit code +- Append searchable context metadata and provenance on the first turn +- Publish the bundled canonical `cxdb.ConversationItem` registry descriptor automatically when needed +- Persist raw provider request, response, and stream evidence under `.scratch/cxtx/sessions/` +- Depend on the child honoring injected provider base URL variables for transparent traffic capture + +**Run it**: +```bash +cargo run -p cxtx -- --help +``` + +**Use case**: Operator capture of interactive CLI sessions, local debugging, provenance-aware session ingest + +--- + ## Quick Start 1. **Start the CXDB server** (required for all examples): diff --git a/frontend/components/ContextList.tsx b/frontend/components/ContextList.tsx index cad8233..bb9c39c 100644 --- a/frontend/components/ContextList.tsx +++ b/frontend/components/ContextList.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState, useEffect, useCallback, useRef } from 'react'; import type { ContextEntry, StoreEvent } from '@/types'; -import { cn } from '@/lib/utils'; +import { cn, formatTimestamp } from '@/lib/utils'; import { Database, GitBranch, GitFork, ChevronRight, Folder, User, Tag } from './icons'; import { PresenceIndicator, LiveTimestamp } from './live'; import type { PresenceState } from './live'; @@ -22,6 +22,47 @@ function getTagColor(tag: string) { return TAG_COLORS[tag.toLowerCase()] || DEFAULT_TAG_COLOR; } +function getContextBadgeLabel(context: ContextEntry): string | null { + const provenance = context.provenance; + const username = + provenance?.on_behalf_of || + provenance?.process_owner || + provenance?.on_behalf_of_email || + null; + const hostname = provenance?.host_name || null; + + if (username && hostname) { + return `${username}@${hostname}`; + } + if (username) { + return username; + } + if (hostname) { + return hostname; + } + return context.client_tag || null; +} + +function basename(path?: string): string | null { + if (!path) return null; + const trimmed = path.replace(/\/+$/, ''); + const segments = trimmed.split('/').filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : null; +} + +function getContextTitleLabel(context: ContextEntry): string | null { + const provider = context.client_tag?.split('/').pop() || null; + const worktreeName = basename(context.provenance?.env?.PWD); + const timestampSource = context.provenance?.captured_at ?? context.created_at_unix_ms; + const timestamp = timestampSource ? formatTimestamp(timestampSource) : null; + + if (provider && worktreeName && timestamp) { + return `${provider}: ${worktreeName} ${timestamp}`; + } + + return context.title || null; +} + interface ContextListProps { contexts: ContextEntry[]; selectedId?: string; @@ -73,6 +114,8 @@ const ContextListItem = memo(function ContextListItem({ const hasParent = !!(provenance?.parent_context_id); const onBehalfOf = provenance?.on_behalf_of || provenance?.on_behalf_of_email; const sourceStyle = provenance?.on_behalf_of_source ? getSourceStyle(provenance.on_behalf_of_source) : null; + const badgeLabel = getContextBadgeLabel(context); + const titleLabel = getContextTitleLabel(context); return (