Files
felixxia-oaiandcopyberry de02016798 Persist explicit user goal edits in model history (#49598)
## Why

User goal edits and clears need to reach the model as user instructions, including when a thread is unloaded or history is being compacted. Automatic goal lifecycle updates must not supply user authorization.

## What changed

- Add optional `origin` values of `user` and `automatic` to `thread/goal/set` and `thread/goal/clear`, and mark TUI goal requests as user actions. Omitted origins do not supply user authorization.
- Persist explicit user objective, status, and clear instructions before changing goal state. Recording failures leave goal state unchanged.
- Coordinate goal edits with thread resume, reload stored history under writer ownership, and preserve edits accepted after the compaction input snapshot.
- Update generated protocol schemas and TypeScript and Python bindings.

## Testing

Add coverage for user edits and clears on loaded and unloaded threads, concurrent path-based resume, exclusion of automatic goals from user instructions, preservation of edits during compaction, and recording failures that leave goal state unchanged.

GitOrigin-RevId: d779d7c678a0c32a6ef76975dbe5b1400d1df245
2026-09-30 11:58:03 +00:00

132 lines
4.5 KiB
Rust

//! Goal API edits and clears enter model history across loaded and cold-resume paths.
use super::*;
use codex_app_server_protocol::ThreadGoalSetResponse;
use pretty_assertions::assert_eq;
use test_case::test_case;
#[test_case(false; "loaded")]
#[test_case(true; "unloaded with path resume")]
#[tokio::test]
async fn user_goal_updates_survive_resume_and_clear(unloaded: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("done")]),
)
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.with_provider_config("supports_websockets = false")
.enable_feature(Feature::Goals)
.disable_feature(Feature::EnableRequestCompression)
.write(codex_home.path())?;
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.build_initialized_with_timeout(TIMEOUT)
.await?;
let thread_id = if unloaded {
create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"",
Some("mock_provider"),
/*git_info*/ None,
)?
} else {
app.start_thread(ThreadStartParams::default())
.await?
.thread
.id
};
// Automatic lifecycle calls mutate goal state without creating user authorization.
let id = app
.send_raw_request(
"thread/goal/set",
Some(json!({
"threadId": thread_id, "objective": "Automatically generated goal.",
"status": "paused", "origin": "automatic",
})),
)
.await?;
let _: ThreadGoalSetResponse = app.read_response(id).await?;
let mut resume_request = None;
for objective in [
"Send the approved report.",
"Draft the report, but do not send it.",
] {
if unloaded && objective.starts_with("Draft") {
let path = codex_rollout::find_thread_path_by_id_str(
codex_home.path(),
&thread_id,
/*state_db_ctx*/ None,
)
.await?
.expect("unloaded rollout");
resume_request = Some(
app.send_thread_resume_request(ThreadResumeParams {
thread_id: String::new(),
path: Some(path),
..Default::default()
})
.await?,
);
}
let id = app
.send_raw_request(
"thread/goal/set",
Some(json!({
"threadId": thread_id, "objective": objective, "status": "paused", "origin": "user",
})),
)
.await?;
let _: ThreadGoalSetResponse = app.read_response(id).await?;
}
if let Some(id) = resume_request {
let _: ThreadResumeResponse = app.read_response(id).await?;
}
let id = app
.send_raw_request(
"thread/goal/clear",
Some(json!({"threadId": thread_id, "origin": "user"})),
)
.await?;
let _: codex_app_server_protocol::ThreadGoalClearResponse = app.read_response(id).await?;
let id = app
.send_turn_start_request(TurnStartParams {
thread_id,
input: vec![UserInput::Text {
text: "What is the status?".to_owned(),
text_elements: vec![],
}],
..Default::default()
})
.await?;
let _: TurnStartResponse = app.read_response(id).await?;
let completed: TurnCompletedNotification =
timeout(TIMEOUT, app.read_notification("turn/completed")).await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let history = mock.single_request().message_input_texts("user").join("\n");
assert!(
!history.contains("Automatically generated goal."),
"{history}"
);
let old = history
.find("User set the goal: \"Send the approved report.\"")
.expect("original user goal");
let new = history
.find("User set the goal: \"Draft the report, but do not send it.\"")
.expect("updated user goal");
assert!(old < new, "{history}");
assert!(
history.contains("User set goal status: \"paused\"."),
"{history}"
);
assert!(history.contains("User cleared the goal."), "{history}");
app.shutdown_gracefully().await?;
Ok(())
}