From 48492cb781162cf8c05132dbac1e6289ed89c801 Mon Sep 17 00:00:00 2001 From: cy Date: Tue, 2 Jun 2026 23:02:37 +0800 Subject: [PATCH] fix: reduce sidebar polling and replay relay history --- assets/inject/renderer-inject.js | 16 +- crates/codex-plus-core/src/launcher.rs | 8 + crates/codex-plus-core/src/protocol_proxy.rs | 165 +++++++++++++++++- crates/codex-plus-core/tests/cdp_bridge.rs | 10 ++ .../codex-plus-core/tests/protocol_proxy.rs | 72 +++++++- 5 files changed, 262 insertions(+), 9 deletions(-) diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index ed4269b..2a6b1e1 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -37,7 +37,7 @@ const projectMoveProjectionTtlMs = 24 * 60 * 60 * 1000; const projectMoveProjectionSettleMs = 5 * 60 * 1000; const projectMoveRefreshDelaysMs = [50, 250, 750, 1500]; - const chatsSortRefreshIntervalMs = 1500; + const chatsSortIdleDelayMs = 250; const chatsSortDbRefreshIntervalMs = 5000; const styleId = "codex-delete-style"; const codexDeleteStyleVersion = "12"; @@ -4529,18 +4529,22 @@ } } - function scheduleChatsSortCorrection(delay = chatsSortRefreshIntervalMs) { - if (!codexPlusSettings().projectMove || window.__codexProjectMoveChatsSortTimer) return; + function scheduleChatsSortCorrection(delay = chatsSortIdleDelayMs) { + if (!codexPlusSettings().projectMove) return; + const normalizedDelay = Math.max(0, Number(delay) || 0); + if (window.__codexProjectMoveChatsSortTimer) { + if (normalizedDelay > 0) return; + clearTimeout(window.__codexProjectMoveChatsSortTimer); + window.__codexProjectMoveChatsSortTimer = null; + } window.__codexProjectMoveChatsSortTimer = setTimeout(() => { if (window.__codexProjectMoveRuntimeId !== codexProjectMoveRuntimeId) return; window.__codexProjectMoveChatsSortTimer = null; applyChatsSortCorrection().catch((error) => { window.__codexProjectMoveSortFailures = window.__codexProjectMoveSortFailures || []; window.__codexProjectMoveSortFailures.push(String(error?.stack || error)); - }).finally(() => { - if (codexPlusSettings().projectMove) scheduleChatsSortCorrection(); }); - }, delay); + }, normalizedDelay); } async function setProjectlessThreadIds(ref, mode) { diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 3d652a6..170e717 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -820,6 +820,7 @@ async fn handle_protocol_proxy_connection( return Ok(()); } }; + let request_messages = upstream.request_messages.clone(); if !upstream.is_success() { let status = upstream.status(); @@ -879,6 +880,12 @@ async fn handle_protocol_proxy_connection( if !tail.is_empty() { stream.write_all(&tail).await?; } + if let Some(response) = converter.completed_response() { + crate::protocol_proxy::store_chat_history_from_response( + response, + request_messages, + ); + } } log_helper_response( "helper.protocol_proxy_stream_ok", @@ -898,6 +905,7 @@ async fn handle_protocol_proxy_connection( } else { crate::protocol_proxy::chat_completion_to_response(chat_json)? }; + crate::protocol_proxy::store_chat_history_from_response(&response_json, request_messages); let body = serde_json::to_vec(&response_json)?; write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body).await?; log_helper_response( diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 23510d0..822077d 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::collections::VecDeque; +use std::sync::{Mutex, OnceLock}; use serde_json::{Value, json}; @@ -28,6 +30,7 @@ const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ "user", ]; const ERROR_BODY_PREVIEW_LIMIT: usize = 1024; +const CHAT_HISTORY_MAX_ENTRIES: usize = 64; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChatReasoningStyle { @@ -48,6 +51,41 @@ struct CodexToolContext { has_namespace_tools: bool, } +#[derive(Default)] +struct ChatHistoryStore { + entries: BTreeMap>, + order: VecDeque, +} + +impl ChatHistoryStore { + fn get(&self, response_id: &str) -> Option> { + self.entries.get(response_id).cloned() + } + + fn insert(&mut self, response_id: String, messages: Vec) { + if response_id.is_empty() || messages.is_empty() { + return; + } + if self.entries.insert(response_id.clone(), messages).is_none() { + self.order.push_back(response_id.clone()); + } else { + self.order.retain(|id| id != &response_id); + self.order.push_back(response_id.clone()); + } + while self.order.len() > CHAT_HISTORY_MAX_ENTRIES { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + } + } + } +} + +static CHAT_HISTORY: OnceLock> = OnceLock::new(); + +fn chat_history_store() -> &'static Mutex { + CHAT_HISTORY.get_or_init(|| Mutex::new(ChatHistoryStore::default())) +} + #[derive(Debug, Clone)] struct CodexCustomToolSpec { openai_name: String, @@ -211,6 +249,60 @@ pub fn responses_to_chat_completions(body: Value) -> anyhow::Result { Ok(result) } +pub fn responses_to_chat_completions_with_history(body: Value) -> anyhow::Result { + let mut result = responses_to_chat_completions(body.clone())?; + let Some(previous_response_id) = body + .get("previous_response_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return Ok(result); + }; + let Some(history) = chat_history_store() + .lock() + .ok() + .and_then(|store| store.get(previous_response_id)) + else { + return Ok(result); + }; + let current = result + .get("messages") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + result["messages"] = json!(merge_chat_history_messages(history, current)); + Ok(result) +} + +fn merge_chat_history_messages(history: Vec, current: Vec) -> Vec { + let mut system_chunks = Vec::new(); + let mut messages = Vec::with_capacity(history.len() + current.len()); + + for message in history.into_iter().chain(current) { + if message.get("role").and_then(Value::as_str) == Some("system") { + if let Some(text) = message.get("content").and_then(Value::as_str) { + let text = text.trim(); + if !text.is_empty() && !system_chunks.iter().any(|chunk| chunk == text) { + system_chunks.push(text.to_string()); + } + } + continue; + } + messages.push(message); + } + + if system_chunks.is_empty() { + return messages; + } + let mut output = Vec::with_capacity(messages.len() + 1); + output.push(json!({ + "role": "system", + "content": system_chunks.join("\n\n") + })); + output.extend(messages); + output +} + pub fn chat_completion_to_response(body: Value) -> anyhow::Result { chat_completion_to_response_with_context(body, &CodexToolContext::default(), None) } @@ -270,6 +362,47 @@ fn chat_completion_to_response_with_context( Ok(response) } +pub fn store_chat_history_from_response(response: &Value, sent_messages: Vec) { + let Some(response_id) = response + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return; + }; + let mut messages = sent_messages; + messages.extend(response_output_to_chat_messages( + response.get("output").unwrap_or(&Value::Null), + )); + if let Ok(mut store) = chat_history_store().lock() { + store.insert(response_id.to_string(), messages); + } +} + +fn response_output_to_chat_messages(output: &Value) -> Vec { + let Some(items) = output.as_array() else { + return Vec::new(); + }; + let mut messages = Vec::new(); + let mut pending_tool_calls = Vec::new(); + let mut pending_reasoning = Vec::new(); + let mut seen_tool_call_ids = BTreeSet::new(); + + for item in items { + append_responses_item( + item, + &mut messages, + &mut pending_tool_calls, + &mut pending_reasoning, + &mut seen_tool_call_ids, + ); + } + flush_tool_calls(&mut messages, &mut pending_tool_calls, &mut pending_reasoning); + flush_reasoning(&mut messages, &mut pending_reasoning); + normalize_chat_messages(&mut messages); + messages +} + pub struct ProxyHttpResponse { pub status: String, pub content_type: String, @@ -280,6 +413,7 @@ pub struct UpstreamProxyResponse { pub status_code: u16, pub content_type: String, pub is_stream: bool, + pub request_messages: Vec, pub response: reqwest::Response, } @@ -355,6 +489,10 @@ impl ChatSseToResponsesConverter { output.into_bytes() } + pub fn completed_response(&self) -> Option<&Value> { + self.state.completed_response.as_ref() + } + fn handle_block(&mut self, block: &str, output: &mut String) { let mut event_name: Option = None; let mut data_parts = Vec::new(); @@ -441,7 +579,12 @@ pub async fn open_responses_proxy_request(body: &str) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result anyhow::Result, tool_context: CodexToolContext, original_request: Option, + completed_response: Option, } impl Default for ChatSseState { @@ -746,6 +905,7 @@ impl Default for ChatSseState { finish_reason: None, tool_context: CodexToolContext::default(), original_request: None, + completed_response: None, } } } @@ -1121,6 +1281,7 @@ impl ChatSseState { response["incomplete_details"] = json!({ "reason": "max_output_tokens" }); } copy_response_request_fields(&mut response, self.original_request.as_ref()); + self.completed_response = Some(response.clone()); push_sse( output, "response.completed", diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index e05e1da..08e3c90 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -192,6 +192,16 @@ fn injection_script_moves_export_and_project_move_into_more_menu() { assert!(!script.contains("group.appendChild(moveButton)")); } +#[test] +fn injection_script_keeps_chats_sort_event_driven() { + let script = assets::injection_script(57321).replace("\r\n", "\n"); + + assert!(script.contains("const chatsSortIdleDelayMs = 250;")); + assert!(script.contains("function scheduleChatsSortCorrection(delay = chatsSortIdleDelayMs)")); + assert!(!script.contains("const chatsSortRefreshIntervalMs")); + assert!(!script.contains("if (codexPlusSettings().projectMove) scheduleChatsSortCorrection();")); +} + #[test] fn injection_script_unlocks_custom_model_catalog() { let script = assets::injection_script(57321); diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 6300290..db44a7c 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -3,7 +3,8 @@ use codex_plus_core::protocol_proxy::{ chat_completion_to_response_with_request, chat_completions_url, chat_sse_to_responses_sse, chat_sse_to_responses_sse_with_request, is_chat_completions_proxy_path, is_models_proxy_path, is_responses_proxy_path, models_url, responses_error_from_upstream, - responses_to_chat_completions, + responses_to_chat_completions, responses_to_chat_completions_with_history, + store_chat_history_from_response, }; use serde_json::json; @@ -61,6 +62,75 @@ fn responses_request_converts_to_chat_completions() { ); } +#[test] +fn responses_request_with_previous_response_id_replays_chat_history() { + let first_messages = vec![json!({ "role": "user", "content": "hello" })]; + let first_response = chat_completion_to_response_with_request( + json!({ + "id": "chatcmpl_history_first", + "created": 1, + "model": "gpt-5-mini", + "choices": [{ + "message": { "role": "assistant", "content": "hi there" }, + "finish_reason": "stop" + }] + }), + &json!({ + "model": "gpt-5-mini", + "input": "hello" + }), + ) + .unwrap(); + store_chat_history_from_response(&first_response, first_messages); + + let converted = responses_to_chat_completions_with_history(json!({ + "model": "gpt-5-mini", + "previous_response_id": first_response["id"], + "input": "what did I say?" + })) + .unwrap(); + + assert_eq!(converted["messages"][0], json!({ "role": "user", "content": "hello" })); + assert_eq!(converted["messages"][1], json!({ "role": "assistant", "content": "hi there" })); + assert_eq!(converted["messages"][2], json!({ "role": "user", "content": "what did I say?" })); +} + +#[test] +fn streaming_response_can_seed_previous_response_history() { + let mut converter = ChatSseToResponsesConverter::with_request(&json!({ + "model": "gpt-5-mini", + "input": "stream hello" + })); + let mut output = converter.push_bytes( + br#"data: {"id":"chatcmpl_stream_history","model":"gpt-5-mini","choices":[{"delta":{"content":"stream hi"},"finish_reason":"stop"}]} + +data: [DONE] + +"#, + ); + output.extend(converter.finish()); + let response = converter + .completed_response() + .expect("stream should produce a completed response") + .clone(); + store_chat_history_from_response( + &response, + vec![json!({ "role": "user", "content": "stream hello" })], + ); + + let converted = responses_to_chat_completions_with_history(json!({ + "model": "gpt-5-mini", + "previous_response_id": response["id"], + "input": "continue" + })) + .unwrap(); + + assert_eq!(converted["messages"][0], json!({ "role": "user", "content": "stream hello" })); + assert_eq!(converted["messages"][1], json!({ "role": "assistant", "content": "stream hi" })); + assert_eq!(converted["messages"][2], json!({ "role": "user", "content": "continue" })); + assert!(String::from_utf8(output).unwrap().contains("response.completed")); +} + #[test] fn responses_request_matches_ccs_reasoning_and_tool_choice_edges() { let non_reasoning = responses_to_chat_completions(json!({