From 0d1944634c0c3eb8786f7b2045e0041eeda55183 Mon Sep 17 00:00:00 2001 From: mkoushni Date: Tue, 1 Sep 2026 14:14:14 +0300 Subject: [PATCH 1/5] fix(compact): keep rewritten current-turn messages after compaction (#661) File resolve and doc extract rewrite the messages tail in place and leave state.input as the original client payload. Rebuild that tail from messages instead of cloning input so resolved file_data and extracted text survive. Signed-off-by: mkoushni --- apis/src/openai/responses/compact/mod.rs | 32 ++++-- apis/src/openai/responses/compact/tests.rs | 107 +++++++++++++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/apis/src/openai/responses/compact/mod.rs b/apis/src/openai/responses/compact/mod.rs index 8189d4007a..16ceacaaf0 100644 --- a/apis/src/openai/responses/compact/mod.rs +++ b/apis/src/openai/responses/compact/mod.rs @@ -501,18 +501,32 @@ fn build_compaction_item(id: &str, summary: &str) -> Value { /// Replace conversation history with the compaction item. /// /// After replacement: -/// - `state.messages` = `[compaction_item, ...state.input]` -/// - `state.persisted_messages` = `[compaction_item, ...state.input]` +/// - `state.messages` = `[compaction_item, ...current_turn]` +/// - `state.persisted_messages` = `[compaction_item, ...current_turn]` /// /// The compaction item is `{"type": "compaction", "encrypted_content": ""}`. -/// `state.input` holds the current request's input items (unchanged -/// by rehydrate), so the current turn's messages are preserved. +/// The current turn is the tail of each message list whose length +/// matches `state.input`. File resolution and document extraction +/// rewrite that tail in place and leave `state.input` as the original +/// client payload, so compaction must not rebuild from `state.input`. fn replace_messages(state: &mut ResponsesState, compaction_item: Value) { - let mut new_messages = Vec::with_capacity(state.input.len() + 1); - new_messages.push(compaction_item); - new_messages.extend(state.input.iter().cloned()); - state.persisted_messages = new_messages.clone(); - state.messages = new_messages; + let input_len = state.input.len(); + let message_tail = split_current_turn(&mut state.messages, input_len); + let persisted_tail = split_current_turn(&mut state.persisted_messages, input_len); + + state.messages.clear(); + state.messages.push(compaction_item.clone()); + state.messages.extend(message_tail); + + state.persisted_messages.clear(); + state.persisted_messages.push(compaction_item); + state.persisted_messages.extend(persisted_tail); +} + +/// Move the current-turn tail off `items`, leaving history behind to drop. +fn split_current_turn(items: &mut Vec, input_len: usize) -> Vec { + let start = items.len().saturating_sub(input_len); + items.split_off(start) } /// Format a message array as readable text for the summarization prompt. diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index 761ae67be4..fcdf6b35ef 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -338,8 +338,115 @@ fn replace_messages_preserves_current_input() { assert_eq!(state.messages[0]["type"], "compaction"); assert_eq!(state.messages[0]["id"], "compact_test"); assert!(state.messages[0].get("encrypted_content").is_some()); + assert_eq!( + state.messages[1]["content"], "What's next?", + "current-turn tail from messages must be kept" + ); assert_eq!(state.persisted_messages.len(), 2); assert_eq!(state.persisted_messages[0]["type"], "compaction"); + assert_eq!( + state.persisted_messages[1]["content"], "What's next?", + "current-turn tail from persisted_messages must be kept" + ); +} + +/// Compaction must keep the rewritten current-turn tail from `messages`, +/// not rebuild it from immutable `state.input` (issue #661). +#[test] +fn compaction_preserves_resolved_file_data_instead_of_file_url() { + const FILE_URL: &str = "https://files.internal/secret.bin"; + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "context_management": [{"type": "compaction", "compact_threshold": 0}], + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_url": FILE_URL}] + }] + })); + state.history_rehydrated = true; + let history = json!({"role": "user", "content": "earlier turn long enough to compact"}); + state.messages.insert(0, history.clone()); + state.persisted_messages.insert(0, history); + + let resolved_item = json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_data": "SGVsbG8="}] + }); + // Same fields `sync_state_with_budget` updates — not `state.input`. + state.request_body["input"] = json!([resolved_item.clone()]); + let tail = state.messages.len() - state.input.len(); + state.messages[tail] = resolved_item.clone(); + state.persisted_messages[tail] = resolved_item; + + assert_eq!( + state.input[0]["content"][0]["file_url"], FILE_URL, + "state.input stays the original client payload" + ); + + replace_messages(&mut state, build_compaction_item("compact_1", "summary")); + + assert_eq!(state.messages[0]["type"], "compaction"); + let current = &state.messages[1]; + assert_eq!( + current["content"][0]["file_data"], "SGVsbG8=", + "resolved file_data must survive compaction" + ); + assert!( + current["content"][0].get("file_url").is_none(), + "original file_url must not be restored from state.input" + ); + assert_eq!( + state.persisted_messages[1]["content"][0]["file_data"], "SGVsbG8=", + "persisted current-turn tail must keep resolved file_data" + ); + assert_eq!( + state.input[0]["content"][0]["file_url"], FILE_URL, + "state.input remains the unmodified client payload" + ); +} + +#[test] +fn compaction_preserves_extracted_input_text_instead_of_input_file() { + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "context_management": [{"type": "compaction", "compact_threshold": 0}], + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "filename": "notes.txt", "file_data": "c2VjcmV0"}] + }] + })); + state.history_rehydrated = true; + let history = json!({"role": "user", "content": "earlier turn"}); + state.messages.insert(0, history.clone()); + state.persisted_messages.insert(0, history); + + let extracted_item = json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "secret"}] + }); + state.request_body["input"] = json!([extracted_item.clone()]); + let tail = state.messages.len() - state.input.len(); + state.messages[tail] = extracted_item.clone(); + state.persisted_messages[tail] = extracted_item; + + replace_messages(&mut state, build_compaction_item("compact_1", "summary")); + + let current = &state.messages[1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "doc_extract rewrite must survive compaction" + ); + assert_eq!(current["content"][0]["text"], "secret"); + assert_eq!( + state.input[0]["content"][0]["type"], "input_file", + "state.input stays the original input_file part" + ); } // ============================================================================= From 996ae3c7e309b7c1aaedba038f5b8d5c4f015365 Mon Sep 17 00:00:00 2001 From: mkoushni Date: Tue, 1 Sep 2026 14:33:46 +0300 Subject: [PATCH 2/5] test(compact): cover file_resolve and doc_extract composition (#661) Assert the compact example pipeline keeps resolved file_data and extracted input_text on the current turn, and that the serialized proxy body never restores file_url or file_id. Signed-off-by: mkoushni --- apis/src/openai/responses/compact/mod.rs | 5 +- apis/src/openai/responses/compact/tests.rs | 28 +++ .../responses/openai_responses_proxy/tests.rs | 46 ++++ examples/README.md | 2 +- .../configs/openai/responses/compact.yaml | 35 ++- .../tests/suite/examples/compact.rs | 208 +++++++++++++++++- 6 files changed, 319 insertions(+), 5 deletions(-) diff --git a/apis/src/openai/responses/compact/mod.rs b/apis/src/openai/responses/compact/mod.rs index 16ceacaaf0..e80f4920d6 100644 --- a/apis/src/openai/responses/compact/mod.rs +++ b/apis/src/openai/responses/compact/mod.rs @@ -8,7 +8,9 @@ //! this filter summarizes the conversation history via a sub-request //! to an inference backend, replacing it with a single compaction //! item. Runs after `rehydrate` (which populates messages and -//! previous usage) and after `openai_tool_parse`. +//! previous usage) and after `openai_tool_parse`. Place +//! `openai_file_resolve` and `openai_doc_extract` before compact so +//! rewritten current-turn content is what compaction preserves. //! //! # Scope //! @@ -28,6 +30,7 @@ pub(super) mod config; clippy::expect_used, clippy::indexing_slicing, clippy::panic, + clippy::too_many_lines, reason = "tests" )] mod tests; diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index fcdf6b35ef..5aff6174be 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -350,6 +350,34 @@ fn replace_messages_preserves_current_input() { ); } +#[test] +fn replace_messages_keeps_each_list_current_turn_independently() { + let mut state = ResponsesState::from_request_body(json!({ + "model": "gpt-4o", + "input": [{"type": "message", "role": "user", "content": "from-input"}] + })); + state.messages = vec![ + json!({"role": "user", "content": "hist-a"}), + json!({"role": "user", "content": "from-messages"}), + ]; + state.persisted_messages = vec![ + json!({"role": "user", "content": "hist-b1"}), + json!({"role": "user", "content": "hist-b2"}), + json!({"role": "user", "content": "from-persisted"}), + ]; + + replace_messages(&mut state, build_compaction_item("c1", "sum")); + + assert_eq!(state.messages.len(), 2); + assert_eq!(state.messages[1]["content"], "from-messages"); + assert_eq!(state.persisted_messages.len(), 2); + assert_eq!(state.persisted_messages[1]["content"], "from-persisted"); + assert_eq!( + state.input[0]["content"], "from-input", + "state.input must not be used to rebuild the current turn" + ); +} + /// Compaction must keep the rewritten current-turn tail from `messages`, /// not rebuild it from immutable `state.input` (issue #661). #[test] diff --git a/apis/src/openai/responses/openai_responses_proxy/tests.rs b/apis/src/openai/responses/openai_responses_proxy/tests.rs index 254688de8e..0f57d7fb50 100644 --- a/apis/src/openai/responses/openai_responses_proxy/tests.rs +++ b/apis/src/openai/responses/openai_responses_proxy/tests.rs @@ -562,6 +562,52 @@ fn messages_for_backend_mixed_items() { assert_eq!(result[1]["role"], "user"); } +#[tokio::test] +async fn compacted_outbound_serializes_resolved_file_data_not_file_url() { + const FILE_URL: &str = "https://files.internal/secret.bin"; + let filter = make_filter(); + let req = make_request(Method::POST, "/v1/responses"); + let mut ctx = make_filter_context(&req); + let request_body = json!({ + "model": "gpt-4o", + "previous_response_id": "resp_prev", + "input": [{ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_url": FILE_URL}] + }] + }); + let mut state = ResponsesState::from_request_body(request_body); + state.history_rehydrated = true; + let encoded = base64::engine::general_purpose::STANDARD.encode("summary"); + state.messages = vec![ + json!({"type": "compaction", "id": "c_1", "encrypted_content": encoded}), + json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_file", "file_data": "SGVsbG8="}] + }), + ]; + ctx.extensions.insert(state); + let mut body = Some(Bytes::from_static( + br#"{"model":"gpt-4o","input":[{"type":"message","role":"user","content":[{"type":"input_file","file_url":"https://files.internal/secret.bin"}]}],"previous_response_id":"resp_prev"}"#, + )); + + let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); + assert!(matches!(action, FilterAction::Continue)); + + let outbound: serde_json::Value = serde_json::from_slice(body.as_ref().unwrap()).unwrap(); + let outbound_text = outbound.to_string(); + assert!( + !outbound_text.contains(FILE_URL), + "proxy must not serialize the unresolved file_url after compaction" + ); + assert_eq!( + outbound["input"][1]["content"][0]["file_data"], "SGVsbG8=", + "proxy must serialize the resolved current-turn file_data" + ); +} + #[test] fn compaction_to_assistant_message_decodes_encrypted_content() { let encoded = base64::engine::general_purpose::STANDARD.encode("decoded summary"); diff --git a/examples/README.md b/examples/README.md index 6849626ef0..372fac8bbc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -71,7 +71,7 @@ before sending requests. | [prompts-routing.yaml](configs/openai/prompts/prompts-routing.yaml) | Routes OpenAI Prompts API requests to a dedicated Prompts API backend | | [agentic-loop-fixture.yaml](configs/openai/responses/agentic-loop-fixture.yaml) | Minimal agentic loop pipeline for inference fixture replay | | [agentic-loop.yaml](configs/openai/responses/agentic-loop.yaml) | Demonstrates the openai_agentic_loop filter with iterative_request_router for step-based model-tool-model looping in the Responses API | -| [compact.yaml](configs/openai/responses/compact.yaml) | Demonstrates the compaction flow: store a response, rehydrate it on the next turn, and count tokens to check if compaction is needed | +| [compact.yaml](configs/openai/responses/compact.yaml) | Demonstrates compaction after rehydrate, file resolve, and document extract so rewritten current-turn content survives history replacement | | [doc-extract.yaml](configs/openai/responses/doc-extract.yaml) | Converts `input_file` content parts to `input_text` for inference backends that do not natively support `input_file` (e.g. vLLM, llm-d) | | [file-resolve.yaml](configs/openai/responses/file-resolve.yaml) | Resolves `file_id` and `file_url` references in Responses API input by fetching file metadata and content, then inlining base64 content as `file_data` or `image_url` before forwarding | | [file-search-callout.yaml](configs/openai/responses/file-search-callout.yaml) | Demonstrates the `openai_file_search_callout` filter configuration | diff --git a/examples/configs/openai/responses/compact.yaml b/examples/configs/openai/responses/compact.yaml index ee8d512717..55d7a20606 100644 --- a/examples/configs/openai/responses/compact.yaml +++ b/examples/configs/openai/responses/compact.yaml @@ -1,7 +1,19 @@ # Compact Filter Example # -# Demonstrates the compaction flow: store a response, rehydrate it -# on the next turn, and count tokens to check if compaction is needed. +# Demonstrates compaction after rehydrate, file resolve, and document +# extract so rewritten current-turn content survives history +# replacement. Store a response, rehydrate it on the next turn, and +# summarize when the token count exceeds the compact threshold. +# +# Pipeline order is required: file_resolve and doc_extract rewrite the +# current-turn tail in place and leave `state.input` as the original +# client payload. Compact preserves that rewritten tail rather than +# restoring `file_url` or `input_file`. +# +# Security: StreamBuffer body callouts run before this listener's +# header-phase filters. Deploy this example behind an outer +# authentication and authorization boundary before enabling the +# required allow_pre_security_callout acknowledgement below. # # Usage: # cargo run -p praxis-ai-proxy -- -c examples/configs/openai/responses/compact.yaml @@ -19,6 +31,11 @@ # curl -s -X POST http://localhost:8080/v1/responses \ # -H "Content-Type: application/json" \ # -d "{\"model\":\"llama3.2:1b\",\"input\":\"Compare with QUIC\",\"previous_response_id\":\"$RESP_ID\",\"context_management\":[{\"type\":\"compaction\",\"compact_threshold\":200}],\"store\":false}" +# +# # Step 3 — compact a follow-up that includes a file_url (needs a +# # Files API on 127.0.0.1:9999 only for file_id; file_url is fetched +# # directly). The upstream body must contain resolved file_data or +# # extracted input_text, never the original file_url. listeners: - name: ai-gateway @@ -50,6 +67,20 @@ filter_chains: - filter: openai_responses_rehydrate + - filter: openai_file_resolve + files_api_url: "http://127.0.0.1:9999" + allow_private_files_api_url: true + allow_pre_security_callout: true + file_url: resolve + forward_headers: + - authorization + on_missing: reject + timeout_ms: 10000 + + - filter: openai_doc_extract + allow_pre_security_callout: true + on_unsupported: continue + - filter: openai_responses_compact inference_url: "http://localhost:11434/v1/chat/completions" default_model: llama3.2:1b diff --git a/tests/integration/tests/suite/examples/compact.rs b/tests/integration/tests/suite/examples/compact.rs index 8329a3aa6d..92d07518c1 100644 --- a/tests/integration/tests/suite/examples/compact.rs +++ b/tests/integration/tests/suite/examples/compact.rs @@ -19,6 +19,8 @@ use praxis_test_utils::{ patch_yaml, start_proxy, }; +use super::openai_file_resolve::{start_file_url_stub, start_files_api_stub}; + // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- @@ -40,9 +42,23 @@ const INFERENCE_RESPONSE: &str = r#"{"id":"resp_inf","created_at":2000,"model":" /// Load the compact example config, replacing the SQLite URL and /// patching listener/backend addresses. fn load_compact_config(yaml: &str, db_url: &str, proxy_port: u16, backend_port: u16) -> praxis_core::config::Config { - let replaced = yaml + load_compact_config_with_files(yaml, db_url, proxy_port, backend_port, None) +} + +/// Load the compact example, optionally pointing `files_api_url` at a stub. +fn load_compact_config_with_files( + yaml: &str, + db_url: &str, + proxy_port: u16, + backend_port: u16, + files_api_port: Option, +) -> praxis_core::config::Config { + let mut replaced = yaml .replace("sqlite://responses.db?mode=rwc", db_url) .replace("localhost:11434", &format!("127.0.0.1:{backend_port}")); + if let Some(files_port) = files_api_port { + replaced = replaced.replace("127.0.0.1:9999", &format!("127.0.0.1:{files_port}")); + } let patched = patch_yaml( &replaced, proxy_port, @@ -51,6 +67,31 @@ fn load_compact_config(yaml: &str, db_url: &str, proxy_port: u16, backend_port: praxis_core::config::Config::from_yaml(&patched).expect("patched config should parse") } +/// Drop `openai_doc_extract` so a test can assert the resolve-only shape. +fn without_doc_extract(yaml: &str) -> String { + yaml.replace( + " - filter: openai_doc_extract\n allow_pre_security_callout: true\n on_unsupported: continue\n\n", + "", + ) +} + +/// Allow the test file-url stub origin so loopback fetches are not SSRF-blocked. +fn with_allowed_file_url_origin(yaml: &str, origin: &str) -> String { + yaml.replace( + " file_url: resolve\n", + &format!(" file_url: resolve\n allowed_file_url_origins:\n - \"{origin}\"\n"), + ) +} + +/// True when any object in `value` still carries `key`. +fn json_contains_key(value: &serde_json::Value, key: &str) -> bool { + match value { + serde_json::Value::Object(map) => map.contains_key(key) || map.values().any(|v| json_contains_key(v, key)), + serde_json::Value::Array(items) => items.iter().any(|v| json_contains_key(v, key)), + _ => false, + } +} + /// Start a sequenced backend that: /// - Returns `first_response` for the first request (summarization callout) /// - Returns `second_response` for the second request (inference callout) @@ -275,3 +316,168 @@ async fn compact_verifies_summarization_call_and_compacted_state() { "second item should be the current user input" ); } + +/// Store a long first turn, then compact a follow-up whose current input +/// is a `file_url`. The inference body must keep the resolved `file_data` +/// and must not restore the original URL (issue #661). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_preserves_resolved_file_url_as_file_data() { + let file_url_port = start_file_url_stub(); + let origin = format!("http://127.0.0.1:{file_url_port}"); + let yaml = without_doc_extract(&with_allowed_file_url_origin( + &std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"), + &origin, + )); + let inference = + compact_follow_up_and_capture(&yaml, "compact_file_url", None, &file_url_follow_up_body(file_url_port)).await; + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_file", + "resolve-only pipeline should keep input_file after compaction" + ); + assert!( + current["content"][0] + .get("file_data") + .and_then(serde_json::Value::as_str) + .is_some(), + "resolved file_data must reach the inference backend: {current}" + ); +} + +/// Store a long first turn, then compact a follow-up whose current input +/// is a `file_id`. After resolve + extract, the inference body must be +/// `input_text` and must not restore `file_id` or `input_file`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_preserves_extracted_file_id_as_input_text() { + let files_api_port = start_files_api_stub(); + let yaml = std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"); + let inference = + compact_follow_up_and_capture(&yaml, "compact_file_id", Some(files_api_port), FILE_ID_FOLLOW_UP).await; + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "doc_extract rewrite must survive compaction: {current}" + ); + let text = current["content"][0]["text"] + .as_str() + .expect("extracted input_text should have a text field"); + assert!( + text.contains("Hello, world!"), + "extracted text should include file content: {text}" + ); +} + +/// Store a long first turn, then compact a follow-up whose current input +/// is a `file_url` through the example pipeline (resolve + extract). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_example_extracts_file_url_and_does_not_restore_it() { + let file_url_port = start_file_url_stub(); + let origin = format!("http://127.0.0.1:{file_url_port}"); + let yaml = with_allowed_file_url_origin( + &std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) + .expect("example config should exist"), + &origin, + ); + let inference = compact_follow_up_and_capture( + &yaml, + "compact_example_file_url", + None, + &file_url_follow_up_body(file_url_port), + ) + .await; + + assert_no_unresolved_file_fields(&inference); + let current = &inference["input"][1]; + assert_eq!( + current["content"][0]["type"], "input_text", + "example pipeline should extract the fetched file_url: {current}" + ); + let text = current["content"][0]["text"] + .as_str() + .expect("extracted input_text should have a text field"); + assert!( + text.contains("Hello, world!"), + "extracted text should include file content: {text}" + ); +} + +const FILE_ID_FOLLOW_UP: &str = r#"{"model":"gpt-4.1","previous_response_id":"resp_compact","context_management":[{"type":"compaction","compact_threshold":0}],"input":[{"type":"message","role":"user","content":[{"type":"input_file","file_id":"test-file-123"}]}]}"#; + +fn file_url_follow_up_body(file_url_port: u16) -> String { + format!( + r#"{{"model":"gpt-4.1","previous_response_id":"resp_compact","context_management":[{{"type":"compaction","compact_threshold":0}}],"input":[{{"type":"message","role":"user","content":[{{"type":"input_file","file_url":"http://127.0.0.1:{file_url_port}/document.txt"}}]}}]}}"# + ) +} + +fn assert_no_unresolved_file_fields(inference: &serde_json::Value) { + let input = inference["input"] + .as_array() + .expect("inference input should be an array"); + assert_eq!( + input.len(), + 2, + "compacted input should have exactly 2 items: summary + current input" + ); + assert_eq!( + input[0]["role"], "assistant", + "first item should be the compaction summary as an assistant message" + ); + assert!( + !json_contains_key(inference, "file_url"), + "compacted outbound body must not restore file_url: {inference}" + ); + assert!( + !json_contains_key(inference, "file_id"), + "compacted outbound body must not restore file_id: {inference}" + ); +} + +/// Store the first compact turn, then send `follow_up` against a sequenced +/// backend and return the captured inference request JSON. +async fn compact_follow_up_and_capture( + yaml: &str, + db_name: &str, + files_api_port: Option, + follow_up: &str, +) -> serde_json::Value { + let backend1 = Backend::fixed(FIRST_RESPONSE_JSON) + .header("content-type", "application/json") + .start_with_shutdown(); + let proxy_port = free_port(); + let db = TempSqlite::new(db_name); + + let config1 = load_compact_config_with_files(yaml, db.url(), proxy_port, backend1.port(), files_api_port); + let proxy1 = start_proxy(&config1); + let raw1 = http_send( + proxy1.addr(), + &json_post("/v1/responses", r#"{"model":"gpt-4.1","input":"Explain TCP vs UDP"}"#), + ); + assert_eq!(parse_status(&raw1), 200, "first request should succeed"); + drop(backend1); + drop(proxy1); + + let (backend_port, captured_inference_body) = + start_sequenced_backend(CHAT_COMPLETIONS_RESPONSE, INFERENCE_RESPONSE); + let config2 = load_compact_config_with_files(yaml, db.url(), proxy_port, backend_port, files_api_port); + let proxy2 = start_proxy(&config2); + let raw2 = http_send(proxy2.addr(), &json_post("/v1/responses", follow_up)); + assert_eq!( + parse_status(&raw2), + 200, + "compaction follow-up should succeed, body: {raw2}" + ); + drop(proxy2); + + let inference_body = captured_inference_body + .lock() + .unwrap() + .clone() + .expect("inference request body should have been captured"); + serde_json::from_str(&inference_body).expect("inference body should be valid JSON") +} From d1cadfc1c00ac74aa4528fc628e7fc63a84fc5cd Mon Sep 17 00:00:00 2001 From: mkoushni Date: Tue, 1 Sep 2026 14:38:11 +0300 Subject: [PATCH 3/5] test(compact): drop unused async from composition helper clippy::unused_async fails make lint because the helper never awaits. Signed-off-by: mkoushni --- tests/integration/tests/suite/examples/compact.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/integration/tests/suite/examples/compact.rs b/tests/integration/tests/suite/examples/compact.rs index 92d07518c1..e8f139ac06 100644 --- a/tests/integration/tests/suite/examples/compact.rs +++ b/tests/integration/tests/suite/examples/compact.rs @@ -330,7 +330,7 @@ async fn compact_preserves_resolved_file_url_as_file_data() { &origin, )); let inference = - compact_follow_up_and_capture(&yaml, "compact_file_url", None, &file_url_follow_up_body(file_url_port)).await; + compact_follow_up_and_capture(&yaml, "compact_file_url", None, &file_url_follow_up_body(file_url_port)); assert_no_unresolved_file_fields(&inference); let current = &inference["input"][1]; @@ -355,8 +355,7 @@ async fn compact_preserves_extracted_file_id_as_input_text() { let files_api_port = start_files_api_stub(); let yaml = std::fs::read_to_string(example_config_path("openai/responses/compact.yaml")) .expect("example config should exist"); - let inference = - compact_follow_up_and_capture(&yaml, "compact_file_id", Some(files_api_port), FILE_ID_FOLLOW_UP).await; + let inference = compact_follow_up_and_capture(&yaml, "compact_file_id", Some(files_api_port), FILE_ID_FOLLOW_UP); assert_no_unresolved_file_fields(&inference); let current = &inference["input"][1]; @@ -389,8 +388,7 @@ async fn compact_example_extracts_file_url_and_does_not_restore_it() { "compact_example_file_url", None, &file_url_follow_up_body(file_url_port), - ) - .await; + ); assert_no_unresolved_file_fields(&inference); let current = &inference["input"][1]; @@ -440,7 +438,7 @@ fn assert_no_unresolved_file_fields(inference: &serde_json::Value) { /// Store the first compact turn, then send `follow_up` against a sequenced /// backend and return the captured inference request JSON. -async fn compact_follow_up_and_capture( +fn compact_follow_up_and_capture( yaml: &str, db_name: &str, files_api_port: Option, From 70a821ee2216f5d691edab5f94001b86cce8d785 Mon Sep 17 00:00:00 2001 From: mkoushni Date: Tue, 1 Sep 2026 16:19:20 +0300 Subject: [PATCH 4/5] test(compact): drop test-function rustdoc and inline comments Function names and assertion messages already carry the intent; CONTRIBUTING forbids documenting tests with /// or body comments. Signed-off-by: mkoushni --- apis/src/openai/responses/compact/tests.rs | 3 --- tests/integration/tests/suite/examples/compact.rs | 8 -------- 2 files changed, 11 deletions(-) diff --git a/apis/src/openai/responses/compact/tests.rs b/apis/src/openai/responses/compact/tests.rs index 5aff6174be..5347d2293c 100644 --- a/apis/src/openai/responses/compact/tests.rs +++ b/apis/src/openai/responses/compact/tests.rs @@ -378,8 +378,6 @@ fn replace_messages_keeps_each_list_current_turn_independently() { ); } -/// Compaction must keep the rewritten current-turn tail from `messages`, -/// not rebuild it from immutable `state.input` (issue #661). #[test] fn compaction_preserves_resolved_file_data_instead_of_file_url() { const FILE_URL: &str = "https://files.internal/secret.bin"; @@ -403,7 +401,6 @@ fn compaction_preserves_resolved_file_data_instead_of_file_url() { "role": "user", "content": [{"type": "input_file", "file_data": "SGVsbG8="}] }); - // Same fields `sync_state_with_budget` updates — not `state.input`. state.request_body["input"] = json!([resolved_item.clone()]); let tail = state.messages.len() - state.input.len(); state.messages[tail] = resolved_item.clone(); diff --git a/tests/integration/tests/suite/examples/compact.rs b/tests/integration/tests/suite/examples/compact.rs index e8f139ac06..d8b0ba43ac 100644 --- a/tests/integration/tests/suite/examples/compact.rs +++ b/tests/integration/tests/suite/examples/compact.rs @@ -317,9 +317,6 @@ async fn compact_verifies_summarization_call_and_compacted_state() { ); } -/// Store a long first turn, then compact a follow-up whose current input -/// is a `file_url`. The inference body must keep the resolved `file_data` -/// and must not restore the original URL (issue #661). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn compact_preserves_resolved_file_url_as_file_data() { let file_url_port = start_file_url_stub(); @@ -347,9 +344,6 @@ async fn compact_preserves_resolved_file_url_as_file_data() { ); } -/// Store a long first turn, then compact a follow-up whose current input -/// is a `file_id`. After resolve + extract, the inference body must be -/// `input_text` and must not restore `file_id` or `input_file`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn compact_preserves_extracted_file_id_as_input_text() { let files_api_port = start_files_api_stub(); @@ -372,8 +366,6 @@ async fn compact_preserves_extracted_file_id_as_input_text() { ); } -/// Store a long first turn, then compact a follow-up whose current input -/// is a `file_url` through the example pipeline (resolve + extract). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn compact_example_extracts_file_url_and_does_not_restore_it() { let file_url_port = start_file_url_stub(); From 255c45260f92c7e880cfd43e3d45ad824231baf1 Mon Sep 17 00:00:00 2001 From: mkoushni Date: Thu, 3 Sep 2026 11:11:15 +0300 Subject: [PATCH 5/5] test(compact): add assertion message on proxy continue check The compacted outbound rewrite test asserted Continue without a failure message, which hid why the action must stay Continue. Signed-off-by: mkoushni --- apis/src/openai/responses/openai_responses_proxy/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apis/src/openai/responses/openai_responses_proxy/tests.rs b/apis/src/openai/responses/openai_responses_proxy/tests.rs index 431aa6a6a5..b01b2d4e1c 100644 --- a/apis/src/openai/responses/openai_responses_proxy/tests.rs +++ b/apis/src/openai/responses/openai_responses_proxy/tests.rs @@ -594,7 +594,10 @@ async fn compacted_outbound_serializes_resolved_file_data_not_file_url() { )); let action = filter.on_request_body(&mut ctx, &mut body, true).await.unwrap(); - assert!(matches!(action, FilterAction::Continue)); + assert!( + matches!(action, FilterAction::Continue), + "compaction rewrite should continue with the resolved current-turn body" + ); let outbound: serde_json::Value = serde_json::from_slice(body.as_ref().unwrap()).unwrap(); let outbound_text = outbound.to_string();