Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ type BackendSettings = {
codexAppImageOverlayOpacity: number;
codexAppImageOverlayFitMode: ImageOverlayFitMode;
codexGoalsEnabled: boolean;
codexAppGoalResumeGuard: boolean;
launchMode: LaunchMode;
relayBaseUrl: string;
relayApiKey: string;
Expand Down Expand Up @@ -694,6 +695,7 @@ const defaultSettings: BackendSettings = {
codexAppImageOverlayOpacity: 35,
codexAppImageOverlayFitMode: "fit",
codexGoalsEnabled: false,
codexAppGoalResumeGuard: false,
launchMode: "patch",
relayBaseUrl: "",
relayApiKey: "",
Expand Down Expand Up @@ -3937,7 +3939,7 @@ function RelayProfileDetail({
</Button>
</Toolbar>
</div>
<RelayProfileEditor profile={draft} form={form} isNew={isNew} onProfileChange={setDraft} onSwitch={switchDraft} actions={actions} modelWindowRows={modelWindowRows} setModelWindowRows={setModelWindowRows} />
<RelayProfileEditor profile={draft} form={form} isNew={isNew} onFormChange={onFormChange} onProfileChange={setDraft} onSwitch={switchDraft} actions={actions} modelWindowRows={modelWindowRows} setModelWindowRows={setModelWindowRows} />
{isAggregateRelayProfile(draft) ? null : (
<RelayFileEditors
contextProfile={profile}
Expand Down Expand Up @@ -3987,6 +3989,7 @@ function RelayProfileEditor({
profile,
form,
isNew = false,
onFormChange,
onProfileChange,
onSwitch,
actions,
Expand All @@ -3996,6 +3999,7 @@ function RelayProfileEditor({
profile: RelayProfile;
form: BackendSettings;
isNew?: boolean;
onFormChange: (value: BackendSettings) => void;
onProfileChange: (value: RelayProfile) => void;
onSwitch: () => void;
actions: Actions;
Expand Down Expand Up @@ -4116,6 +4120,17 @@ function RelayProfileEditor({
/>
<span>{t("启用目标功能")}</span>
</label>
<label className="inline-check">
<input
checked={form.codexAppGoalResumeGuard}
onChange={(event) => onFormChange({ ...form, codexAppGoalResumeGuard: event.currentTarget.checked })}
type="checkbox"
/>
<span>{t("启用目标续跑保护")}</span>
</label>
<p className="field-hint">
{t("检测到目标上下文时,Codex++ 会在中转请求中追加续跑保护提示,减少压缩后重做旧任务。")}
</p>
</Field>
<div className="relay-advanced-toggle">
<Button
Expand Down Expand Up @@ -5981,6 +5996,7 @@ function normalizeSettings(settings: BackendSettings): BackendSettings {
...settings,
relayProfilesEnabled: settings.relayProfilesEnabled !== false,
computerUseGuardEnabled: settings.computerUseGuardEnabled === true,
codexAppGoalResumeGuard: settings.codexAppGoalResumeGuard === true,
codexAppImageOverlayOpacity: clampNumber(settings.codexAppImageOverlayOpacity || 35, 1, 100),
codexAppImageOverlayFitMode: normalizeImageOverlayFitMode(settings.codexAppImageOverlayFitMode),
codexAppStepwiseMaxItems: clampNumber(settings.codexAppStepwiseMaxItems ?? 6, 0, 6),
Expand Down
3 changes: 3 additions & 0 deletions apps/codex-plus-manager/src/i18n-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ export const EN_PLAIN: Record<string, string> = {
"启用供应商配置切换": "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.",
Expand Down
167 changes: 166 additions & 1 deletion crates/codex-plus-core/src/protocol_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<think>";
const THINK_CLOSE_TAG: &str = "</think>";
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",
Expand Down Expand Up @@ -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<UpstreamProxyResponse> {
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)
Expand Down Expand Up @@ -789,6 +804,88 @@ fn conversation_id_from_responses_request(body: &Value) -> Option<String> {
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<String, Value>) {
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() {
Expand Down Expand Up @@ -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"));
}
}
7 changes: 7 additions & 0 deletions crates/codex-plus-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -906,6 +909,7 @@ fn merge_known_setting_fields(target: &mut Map<String, Value>, source: &Map<Stri
if let Some(value) = source.get("codexGoalsEnabled").and_then(Value::as_bool) {
target.insert("codexGoalsEnabled".to_string(), Value::Bool(value));
}
merge_bool_setting(target, source, "codexAppGoalResumeGuard");
if let Some(value) = source.get("launchMode").and_then(Value::as_str) {
if matches!(value, "patch" | "relay") {
target.insert("launchMode".to_string(), Value::String(value.to_string()));
Expand Down Expand Up @@ -1225,6 +1229,7 @@ mod tests {
assert!(!settings.codex_app_thread_id_badge);
assert!(settings.codex_app_force_chinese_locale);
assert!(!settings.codex_goals_enabled);
assert!(!settings.codex_app_goal_resume_guard);
assert!(settings.codex_app_path.is_empty());
assert!(settings.codex_extra_args.is_empty());
assert_eq!(
Expand Down Expand Up @@ -1775,6 +1780,7 @@ experimental_bearer_token = "sk-existing""#
"codexAppNativeMenuLocalization": false,
"codexAppServiceTierControls": true,
"codexGoalsEnabled": true,
"codexAppGoalResumeGuard": true,
"relayBaseUrl": "https://relay.example.test/v1",
"relayApiKey": "sk-relay",
"codexExtraArgs": ["--force_high_performance_gpu", "", " ", " --enable-gpu "],
Expand All @@ -1791,6 +1797,7 @@ experimental_bearer_token = "sk-existing""#
assert!(!updated.codex_app_native_menu_localization);
assert!(updated.codex_app_service_tier_controls);
assert!(updated.codex_goals_enabled);
assert!(updated.codex_app_goal_resume_guard);
assert_eq!(updated.relay_base_url, "https://relay.example.test/v1");
assert_eq!(updated.relay_api_key, "sk-relay");
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions tools/i18n-keys.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@
"启用供应商配置切换",
"启用此扩展项",
"启用目标功能",
"启用目标续跑保护",
"检测到目标上下文时,Codex++ 会在中转请求中追加续跑保护提示,减少压缩后重做旧任务。",
"图片",
"图片覆盖层",
"在会话列表悬停显示删除按钮,并支持撤销。",
Expand Down
Loading