feat: detached review (#7292)

This commit is contained in:
jif-oai
2025-11-28 11:34:57 +00:00
committed by GitHub
parent cbd7d0d543
commit aaec8abf58
15 changed files with 529 additions and 228 deletions
+24 -17
View File
@@ -65,7 +65,7 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits a `item/completed` notification with a `codeReview` item when results are ready.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
### 1) Start or resume a thread
@@ -190,49 +190,56 @@ Use `review/start` to run Codex’s reviewer on the currently checked-out projec
- `{"type":"baseBranch","branch":"main"}` — diff against the provided branch’s upstream (see prompt for the exact `git merge-base`/`git diff` instructions Codex will run).
- `{"type":"commit","sha":"abc1234","title":"Optional subject"}` — review a specific commit.
- `{"type":"custom","instructions":"Free-form reviewer instructions"}` — fallback prompt equivalent to the legacy manual review request.
- `appendToOriginalThread` (bool, default `false`) — when `true`, Codex also records a final assistant-style message with the review summary in the original thread. When `false`, only the `codeReview` item is emitted for the review run and no extra message is added to the original thread.
- `delivery` (`"inline"` or `"detached"`, default `"inline"`) — where the review runs:
- `"inline"`: run the review as a new turn on the existing thread. The response’s `reviewThreadId` equals the original `threadId`, and no new `thread/started` notification is emitted.
- `"detached"`: fork a new review thread from the parent conversation and run the review there. The response’s `reviewThreadId` is the id of this new review thread, and the server emits a `thread/started` notification for it before streaming review items.
Example request/response:
```json
{ "method": "review/start", "id": 40, "params": {
"threadId": "thr_123",
"appendToOriginalThread": true,
"delivery": "inline",
"target": { "type": "commit", "sha": "1234567deadbeef", "title": "Polish tui colors" }
} }
{ "id": 40, "result": { "turn": {
"id": "turn_900",
"status": "inProgress",
"items": [
{ "type": "userMessage", "id": "turn_900", "content": [ { "type": "text", "text": "Review commit 1234567: Polish tui colors" } ] }
],
"error": null
} } }
{ "id": 40, "result": {
"turn": {
"id": "turn_900",
"status": "inProgress",
"items": [
{ "type": "userMessage", "id": "turn_900", "content": [ { "type": "text", "text": "Review commit 1234567: Polish tui colors" } ] }
],
"error": null
},
"reviewThreadId": "thr_123"
} }
```
For a detached review, use `"delivery": "detached"`. The response is the same shape, but `reviewThreadId` will be the id of the new review thread (different from the original `threadId`). The server also emits a `thread/started` notification for that new thread before streaming the review turn.
Codex streams the usual `turn/started` notification followed by an `item/started`
with the same `codeReview` item id so clients can show progress:
with an `enteredReviewMode` item so clients can show progress:
```json
{ "method": "item/started", "params": { "item": {
"type": "codeReview",
"type": "enteredReviewMode",
"id": "turn_900",
"review": "current changes"
} } }
```
When the reviewer finishes, the server emits `item/completed` containing the same
`codeReview` item with the final review text:
When the reviewer finishes, the server emits `item/started` and `item/completed`
containing an `exitedReviewMode` item with the final review text:
```json
{ "method": "item/completed", "params": { "item": {
"type": "codeReview",
"type": "exitedReviewMode",
"id": "turn_900",
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
} } }
```
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::CodeReview` in the generated schema). Use this notification to render the reviewer output in your client.
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
## Events (work-in-progress)
@@ -340,16 +340,26 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::EnteredReviewMode(review_request) => {
let notification = ItemStartedNotification {
let review = review_request.user_facing_hint;
let item = ThreadItem::EnteredReviewMode {
id: event_turn_id.clone(),
review,
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item: ThreadItem::CodeReview {
id: event_turn_id.clone(),
review: review_request.user_facing_hint,
},
item: item.clone(),
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.send_server_notification(ServerNotification::ItemStarted(started))
.await;
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::ItemStarted(item_started_event) => {
@@ -375,21 +385,29 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::ExitedReviewMode(review_event) => {
let review_text = match review_event.review_output {
let review = match review_event.review_output {
Some(output) => render_review_output_text(&output),
None => REVIEW_FALLBACK_MESSAGE.to_string(),
};
let review_item_id = event_turn_id.clone();
let notification = ItemCompletedNotification {
let item = ThreadItem::ExitedReviewMode {
id: event_turn_id.clone(),
review,
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item: ThreadItem::CodeReview {
id: review_item_id,
review: review_text,
},
item: item.clone(),
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(notification))
.send_server_notification(ServerNotification::ItemStarted(started))
.await;
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
@@ -61,7 +61,9 @@ use codex_app_server_protocol::RemoveConversationSubscriptionResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::ResumeConversationResponse;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::SendUserMessageParams;
@@ -120,6 +122,7 @@ use codex_core::git_info::git_diff_to_remote;
use codex_core::parse_cursor;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDelivery as CoreReviewDelivery;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::read_head_for_summary;
@@ -253,7 +256,6 @@ impl CodexMessageProcessor {
fn review_request_from_target(
target: ReviewTarget,
append_to_original_thread: bool,
) -> Result<(ReviewRequest, String), JSONRPCErrorError> {
fn invalid_request(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
@@ -269,7 +271,6 @@ impl CodexMessageProcessor {
ReviewRequest {
prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(),
user_facing_hint: "current changes".to_string(),
append_to_original_thread,
},
"Review uncommitted changes".to_string(),
)),
@@ -285,7 +286,6 @@ impl CodexMessageProcessor {
ReviewRequest {
prompt,
user_facing_hint: hint,
append_to_original_thread,
},
display,
))
@@ -314,7 +314,6 @@ impl CodexMessageProcessor {
ReviewRequest {
prompt,
user_facing_hint: hint,
append_to_original_thread,
},
display,
))
@@ -328,7 +327,6 @@ impl CodexMessageProcessor {
ReviewRequest {
prompt: trimmed.clone(),
user_facing_hint: trimmed.clone(),
append_to_original_thread,
},
trimmed,
))
@@ -2497,60 +2495,220 @@ impl CodexMessageProcessor {
}
}
async fn review_start(&self, request_id: RequestId, params: ReviewStartParams) {
fn build_review_turn(turn_id: String, display_text: &str) -> Turn {
let items = if display_text.is_empty() {
Vec::new()
} else {
vec![ThreadItem::UserMessage {
id: turn_id.clone(),
content: vec![V2UserInput::Text {
text: display_text.to_string(),
}],
}]
};
Turn {
id: turn_id,
items,
status: TurnStatus::InProgress,
}
}
async fn emit_review_started(
&self,
request_id: &RequestId,
turn: Turn,
parent_thread_id: String,
review_thread_id: String,
) {
let response = ReviewStartResponse {
turn: turn.clone(),
review_thread_id,
};
self.outgoing
.send_response(request_id.clone(), response)
.await;
let notif = TurnStartedNotification {
thread_id: parent_thread_id,
turn,
};
self.outgoing
.send_server_notification(ServerNotification::TurnStarted(notif))
.await;
}
async fn start_inline_review(
&self,
request_id: &RequestId,
parent_conversation: Arc<CodexConversation>,
review_request: ReviewRequest,
display_text: &str,
parent_thread_id: String,
) -> std::result::Result<(), JSONRPCErrorError> {
let turn_id = parent_conversation
.submit(Op::Review { review_request })
.await;
match turn_id {
Ok(turn_id) => {
let turn = Self::build_review_turn(turn_id, display_text);
self.emit_review_started(
request_id,
turn,
parent_thread_id.clone(),
parent_thread_id,
)
.await;
Ok(())
}
Err(err) => Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to start review: {err}"),
data: None,
}),
}
}
async fn start_detached_review(
&mut self,
request_id: &RequestId,
parent_conversation_id: ConversationId,
review_request: ReviewRequest,
display_text: &str,
) -> std::result::Result<(), JSONRPCErrorError> {
let rollout_path = find_conversation_path_by_id_str(
&self.config.codex_home,
&parent_conversation_id.to_string(),
)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to locate conversation id {parent_conversation_id}: {err}"),
data: None,
})?
.ok_or_else(|| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("no rollout found for conversation id {parent_conversation_id}"),
data: None,
})?;
let mut config = self.config.as_ref().clone();
config.model = self.config.review_model.clone();
let NewConversation {
conversation_id,
conversation,
session_configured,
..
} = self
.conversation_manager
.fork_conversation(usize::MAX, config, rollout_path)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error creating detached review conversation: {err}"),
data: None,
})?;
if let Err(err) = self
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
.await
{
tracing::warn!(
"failed to attach listener for review conversation {}: {}",
conversation_id,
err.message
);
}
let rollout_path = conversation.rollout_path();
let fallback_provider = self.config.model_provider_id.as_str();
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
Ok(summary) => {
let thread = summary_to_thread(summary);
let notif = ThreadStartedNotification { thread };
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
}
Err(err) => {
tracing::warn!(
"failed to load summary for review conversation {}: {}",
session_configured.session_id,
err
);
}
}
let turn_id = conversation
.submit(Op::Review { review_request })
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to start detached review turn: {err}"),
data: None,
})?;
let turn = Self::build_review_turn(turn_id, display_text);
let review_thread_id = conversation_id.to_string();
self.emit_review_started(request_id, turn, review_thread_id.clone(), review_thread_id)
.await;
Ok(())
}
async fn review_start(&mut self, request_id: RequestId, params: ReviewStartParams) {
let ReviewStartParams {
thread_id,
target,
append_to_original_thread,
delivery,
} = params;
let (_, conversation) = match self.conversation_from_thread_id(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let (review_request, display_text) =
match Self::review_request_from_target(target, append_to_original_thread) {
Ok(value) => value,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
let (parent_conversation_id, parent_conversation) =
match self.conversation_from_thread_id(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let turn_id = conversation.submit(Op::Review { review_request }).await;
match turn_id {
Ok(turn_id) => {
let mut items = Vec::new();
if !display_text.is_empty() {
items.push(ThreadItem::UserMessage {
id: turn_id.clone(),
content: vec![V2UserInput::Text { text: display_text }],
});
}
let turn = Turn {
id: turn_id.clone(),
items,
status: TurnStatus::InProgress,
};
let response = TurnStartResponse { turn: turn.clone() };
self.outgoing.send_response(request_id, response).await;
let notif = TurnStartedNotification { thread_id, turn };
self.outgoing
.send_server_notification(ServerNotification::TurnStarted(notif))
.await;
}
let (review_request, display_text) = match Self::review_request_from_target(target) {
Ok(value) => value,
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to start review: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
self.outgoing.send_error(request_id, err).await;
return;
}
};
let delivery = delivery.unwrap_or(ApiReviewDelivery::Inline).to_core();
match delivery {
CoreReviewDelivery::Inline => {
if let Err(err) = self
.start_inline_review(
&request_id,
parent_conversation,
review_request,
display_text.as_str(),
thread_id.clone(),
)
.await
{
self.outgoing.send_error(request_id, err).await;
}
}
CoreReviewDelivery::Detached => {
if let Err(err) = self
.start_detached_review(
&request_id,
parent_conversation_id,
review_request,
display_text.as_str(),
)
.await
{
self.outgoing.send_error(request_id, err).await;
}
}
}
}
+90 -20
View File
@@ -9,12 +9,13 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use serde_json::json;
use tempfile::TempDir;
@@ -59,7 +60,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
let review_req = mcp
.send_review_start_request(ReviewStartParams {
thread_id: thread_id.clone(),
append_to_original_thread: true,
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::Commit {
sha: "1234567deadbeef".to_string(),
title: Some("Tidy UI colors".to_string()),
@@ -71,7 +72,15 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
mcp.read_stream_until_response_message(RequestId::Integer(review_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(review_resp)?;
let ReviewStartResponse {
turn,
review_thread_id,
} = to_response::<ReviewStartResponse>(review_resp)?;
assert_eq!(
review_thread_id,
thread_id.clone(),
"expected inline review to run on parent thread id"
);
let turn_id = turn.id.clone();
assert_eq!(turn.status, TurnStatus::InProgress);
assert_eq!(turn.items.len(), 1);
@@ -91,23 +100,33 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
let item_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/started"),
)
.await??;
let started: ItemStartedNotification =
serde_json::from_value(item_started.params.expect("params must be present"))?;
match started.item {
ThreadItem::CodeReview { id, review } => {
assert_eq!(id, turn_id);
assert_eq!(review, "commit 1234567");
let mut saw_entered_review_mode = false;
for _ in 0..10 {
let item_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/started"),
)
.await??;
let started: ItemStartedNotification =
serde_json::from_value(item_started.params.expect("params must be present"))?;
match started.item {
ThreadItem::EnteredReviewMode { id, review } => {
assert_eq!(id, turn_id);
assert_eq!(review, "commit 1234567");
saw_entered_review_mode = true;
break;
}
ThreadItem::UserMessage { .. } => continue,
other => panic!("unexpected item/started payload: {other:?}"),
}
other => panic!("expected code review item, got {other:?}"),
}
assert!(
saw_entered_review_mode,
"did not observe enteredReviewMode item"
);
let mut review_body: Option<String> = None;
for _ in 0..5 {
for _ in 0..10 {
let review_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),
@@ -116,12 +135,13 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
let completed: ItemCompletedNotification =
serde_json::from_value(review_notif.params.expect("params must be present"))?;
match completed.item {
ThreadItem::CodeReview { id, review } => {
ThreadItem::ExitedReviewMode { id, review } => {
assert_eq!(id, turn_id);
review_body = Some(review);
break;
}
ThreadItem::UserMessage { .. } => continue,
ThreadItem::EnteredReviewMode { .. } => continue,
other => panic!("unexpected item/completed payload: {other:?}"),
}
}
@@ -146,7 +166,7 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
let request_id = mcp
.send_review_start_request(ReviewStartParams {
thread_id,
append_to_original_thread: true,
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::BaseBranch {
branch: " ".to_string(),
},
@@ -167,6 +187,56 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<()> {
let review_payload = json!({
"findings": [],
"overall_correctness": "ok",
"overall_explanation": "detached review",
"overall_confidence_score": 0.5
})
.to_string();
let responses = vec![create_final_assistant_message_sse_response(
&review_payload,
)?];
let server = create_mock_chat_completions_server_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_id = start_default_thread(&mut mcp).await?;
let review_req = mcp
.send_review_start_request(ReviewStartParams {
thread_id: thread_id.clone(),
delivery: Some(ReviewDelivery::Detached),
target: ReviewTarget::Custom {
instructions: "detached review".to_string(),
},
})
.await?;
let review_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(review_req)),
)
.await??;
let ReviewStartResponse {
turn,
review_thread_id,
} = to_response::<ReviewStartResponse>(review_resp)?;
assert_eq!(turn.status, TurnStatus::InProgress);
assert_ne!(
review_thread_id, thread_id,
"detached review should run on a different thread"
);
Ok(())
}
#[tokio::test]
async fn review_start_rejects_empty_commit_sha() -> Result<()> {
let server = create_mock_chat_completions_server_unchecked(vec![]).await;
@@ -180,7 +250,7 @@ async fn review_start_rejects_empty_commit_sha() -> Result<()> {
let request_id = mcp
.send_review_start_request(ReviewStartParams {
thread_id,
append_to_original_thread: true,
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::Commit {
sha: "\t".to_string(),
title: None,
@@ -215,7 +285,7 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> {
let request_id = mcp
.send_review_start_request(ReviewStartParams {
thread_id,
append_to_original_thread: true,
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::Custom {
instructions: "\n\n".to_string(),
},