diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 11a2c709b..32e72cc5c 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -172,6 +172,7 @@ type BackendSettings = { codexAppImageOverlayOpacity: number; codexAppImageOverlayFitMode: ImageOverlayFitMode; codexGoalsEnabled: boolean; + codexAppGoalResumeGuard: boolean; launchMode: LaunchMode; relayBaseUrl: string; relayApiKey: string; @@ -694,6 +695,7 @@ const defaultSettings: BackendSettings = { codexAppImageOverlayOpacity: 35, codexAppImageOverlayFitMode: "fit", codexGoalsEnabled: false, + codexAppGoalResumeGuard: false, launchMode: "patch", relayBaseUrl: "", relayApiKey: "", @@ -3937,7 +3939,7 @@ function RelayProfileDetail({ - + {isAggregateRelayProfile(draft) ? null : ( void; onProfileChange: (value: RelayProfile) => void; onSwitch: () => void; actions: Actions; @@ -4116,6 +4120,17 @@ function RelayProfileEditor({ /> {t("启用目标功能")} + + onFormChange({ ...form, codexAppGoalResumeGuard: event.currentTarget.checked })} + type="checkbox" + /> + {t("启用目标续跑保护")} + + + {t("检测到目标上下文时,Codex++ 会在中转请求中追加续跑保护提示,减少压缩后重做旧任务。")} + = { "启用供应商配置切换": "Enable provider configuration switching", "启用此扩展项": "Enable this entry", "启用目标功能": "Enable goals feature", + "启用目标续跑保护": "Enable goal resume guard", + "检测到目标上下文时,Codex++ 会在中转请求中追加续跑保护提示,减少压缩后重做旧任务。": + "When goal context is detected, Codex++ appends a resume guard prompt to relay requests to reduce redoing old tasks after compaction.", "图片": "Image", "图片覆盖层": "Image overlay", "在会话列表悬停显示删除按钮,并支持撤销。": "Show a delete button on hover in the session list, with undo support.", diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 94178394a..a824c7ea1 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -18,6 +18,18 @@ const UPSTREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(30); const UPSTREAM_STREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(120); const THINK_OPEN_TAG: &str = ""; const THINK_CLOSE_TAG: &str = ""; +const GOAL_RESUME_GUARD_MARKER: &str = "Codex++ Goal Resume Guard"; +const GOAL_RESUME_GUARD_INSTRUCTION: &str = concat!( + "Codex++ Goal Resume Guard:\n", + "When continuing an active Codex goal after compaction or resume, do not replay older manual ", + "steer messages as a fresh task. Treat the newest active goal checkpoint as authoritative. ", + "If the workspace contains .codexpp-goal-state.md or GOAL_STATE.md, read it before acting ", + "and follow its objective, completed, current_next_step, blockers, do_not_redo, and ", + "last_completed_turn_id fields. For long-running goals, update the checkpoint file after ", + "each meaningful small batch or completed stage before continuing, so a later resume has ", + "the latest progress. If no checkpoint file exists, continue from the newest goal context ", + "and avoid redoing completed work unless the user explicitly asks." +); const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ "frequency_penalty", "logit_bias", @@ -497,7 +509,10 @@ async fn open_responses_proxy_request_with_settings_and_user_agent( settings: crate::settings::BackendSettings, original_user_agent: Option<&str>, ) -> anyhow::Result { - let request_json: Value = serde_json::from_str(body)?; + let mut request_json: Value = serde_json::from_str(body)?; + if settings.codex_app_goal_resume_guard { + request_json = apply_goal_resume_guard_to_request(request_json); + } let is_stream = request_json .get("stream") .and_then(Value::as_bool) @@ -789,6 +804,88 @@ fn conversation_id_from_responses_request(body: &Value) -> Option { None } +fn apply_goal_resume_guard_to_request(mut request_json: Value) -> Value { + if !request_has_goal_context(&request_json) + || value_contains_text(&request_json, GOAL_RESUME_GUARD_MARKER) + { + return request_json; + } + + let Some(object) = request_json.as_object_mut() else { + return request_json; + }; + append_goal_resume_guard_instruction(object); + request_json +} + +fn append_goal_resume_guard_instruction(object: &mut serde_json::Map) { + match object.get_mut("instructions") { + Some(Value::String(text)) => { + if !text.trim().is_empty() { + text.push_str("\n\n"); + } + text.push_str(GOAL_RESUME_GUARD_INSTRUCTION); + } + Some(Value::Array(parts)) => { + parts.push(json!({ + "type": "input_text", + "text": GOAL_RESUME_GUARD_INSTRUCTION + })); + } + Some(other) => { + let existing = instruction_text(other); + let next = if existing.trim().is_empty() { + GOAL_RESUME_GUARD_INSTRUCTION.to_string() + } else { + format!("{existing}\n\n{GOAL_RESUME_GUARD_INSTRUCTION}") + }; + object.insert("instructions".to_string(), Value::String(next)); + } + None => { + object.insert( + "instructions".to_string(), + Value::String(GOAL_RESUME_GUARD_INSTRUCTION.to_string()), + ); + } + } +} + +fn request_has_goal_context(value: &Value) -> bool { + match value { + Value::Object(object) => { + let source = object.get("source").and_then(Value::as_str).unwrap_or(""); + let kind = object.get("type").and_then(Value::as_str).unwrap_or(""); + if source.eq_ignore_ascii_case("goal") + || kind.eq_ignore_ascii_case("thread_goal") + || kind.eq_ignore_ascii_case("goal") + { + return true; + } + object.values().any(request_has_goal_context) + } + Value::Array(items) => items.iter().any(request_has_goal_context), + Value::String(text) => text_mentions_goal_context(text), + _ => false, + } +} + +fn text_mentions_goal_context(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + (lower.contains("codex_internal_context") && lower.contains("source") && lower.contains("goal")) + || lower.contains("thread_goal_updated") +} + +fn value_contains_text(value: &Value, needle: &str) -> bool { + match value { + Value::String(text) => text.contains(needle), + Value::Array(items) => items.iter().any(|item| value_contains_text(item, needle)), + Value::Object(object) => object + .values() + .any(|item| value_contains_text(item, needle)), + _ => false, + } +} + fn effective_user_agent(configured_user_agent: &str, original_user_agent: Option<&str>) -> String { let configured_user_agent = configured_user_agent.trim(); if !configured_user_agent.is_empty() { @@ -3953,3 +4050,71 @@ fn is_openai_o_series(model: &str) -> bool { .get(1) .is_some_and(|byte| byte.is_ascii_digit()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn goal_resume_guard_appends_to_goal_context_request() { + let guarded = apply_goal_resume_guard_to_request(json!({ + "model": "gpt-5.5", + "instructions": "Base instructions.", + "input": [{ + "type": "codex_internal_context", + "source": "goal", + "text": "Continue the active goal." + }] + })); + + let instructions = guarded["instructions"].as_str().unwrap(); + assert!(instructions.contains("Base instructions.")); + assert!(instructions.contains(GOAL_RESUME_GUARD_MARKER)); + assert!(instructions.contains(".codexpp-goal-state.md")); + assert!(instructions.contains("update the checkpoint file")); + + let chat = responses_to_chat_completions(guarded).unwrap(); + assert!( + chat["messages"][0]["content"] + .as_str() + .unwrap() + .contains(GOAL_RESUME_GUARD_MARKER) + ); + } + + #[test] + fn goal_resume_guard_leaves_regular_request_unchanged() { + let original = json!({ + "model": "gpt-5.5", + "input": "hello" + }); + let guarded = apply_goal_resume_guard_to_request(original.clone()); + + assert_eq!(guarded, original); + } + + #[test] + fn goal_resume_guard_does_not_duplicate_existing_marker() { + let guarded = apply_goal_resume_guard_to_request(json!({ + "model": "gpt-5.5", + "instructions": GOAL_RESUME_GUARD_INSTRUCTION, + "input": [{ + "type": "codex_internal_context", + "source": "goal", + "text": "Continue the active goal." + }] + })); + + assert_eq!( + guarded["instructions"].as_str().unwrap(), + GOAL_RESUME_GUARD_INSTRUCTION + ); + } + + #[test] + fn goal_resume_guard_instruction_mentions_incremental_checkpoint_updates() { + assert!(GOAL_RESUME_GUARD_INSTRUCTION.contains("long-running goals")); + assert!(GOAL_RESUME_GUARD_INSTRUCTION.contains("each meaningful small batch")); + assert!(GOAL_RESUME_GUARD_INSTRUCTION.contains("latest progress")); + } +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index e54611b61..74f88f75c 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -297,6 +297,8 @@ pub struct BackendSettings { pub codex_app_image_overlay_fit_mode: String, #[serde(rename = "codexGoalsEnabled", default)] pub codex_goals_enabled: bool, + #[serde(rename = "codexAppGoalResumeGuard", default)] + pub codex_app_goal_resume_guard: bool, #[serde(rename = "launchMode", default)] pub launch_mode: LaunchMode, #[serde(rename = "relayBaseUrl", default = "default_relay_base_url")] @@ -366,6 +368,7 @@ impl Default for BackendSettings { codex_app_image_overlay_opacity: default_image_overlay_opacity(), codex_app_image_overlay_fit_mode: default_image_overlay_fit_mode(), codex_goals_enabled: false, + codex_app_goal_resume_guard: false, launch_mode: LaunchMode::Patch, relay_base_url: default_relay_base_url(), relay_api_key: String::new(), @@ -906,6 +909,7 @@ fn merge_known_setting_fields(target: &mut Map, source: &Map
+ {t("检测到目标上下文时,Codex++ 会在中转请求中追加续跑保护提示,减少压缩后重做旧任务。")} +