mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
## Why Pending requests can outlive a session switch, and different threads or connections can reuse request IDs. Replies and resolution notifications must stay associated with the request that prompted them. ## What changed - Retain the original app-server reply handle for pending and buffered requests, including requests replayed after thread attachment or session reset. - Scope user input, MCP elicitation, and user verification bookkeeping by thread, and send user input answers to the requesting thread. - Keep background resolution notifications from dismissing foreground prompts. - Consume failed replies to closed connections and clear pending replay state without falling through to another request route. ## Testing Add WebSocket regression tests for direct and buffered approvals with colliding request IDs and replies to closed connections. Extend coverage for foreground prompt preservation and thread-scoped user verification cancellation. GitOrigin-RevId: 541acb9910e92f6f2e83c27d2a2384185d4f8fec
239 lines
8.1 KiB
Rust
239 lines
8.1 KiB
Rust
use crate::app::app_server_requests::AppServerRequestResolution;
|
|
use crate::app::app_server_requests::PendingAppServerRequests;
|
|
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
|
use crate::app::app_server_requests::ResolvedThreadAppServerRequest;
|
|
use crate::app_command::AppCommand;
|
|
use crate::app_command::UserVerificationResponse;
|
|
use codex_app_server_protocol::McpServerElicitationRequest;
|
|
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::UserVerificationProof;
|
|
use codex_protocol::ThreadId;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn verification_request() -> ServerRequest {
|
|
ServerRequest::McpServerElicitationRequest {
|
|
request_id: RequestId::Integer(7),
|
|
params: McpServerElicitationRequestParams {
|
|
thread_id: ThreadId::new().to_string(),
|
|
turn_id: None,
|
|
server_name: "deployments".to_string(),
|
|
request: McpServerElicitationRequest::UserVerification {
|
|
meta: None,
|
|
title: "Approve deployment?".to_string(),
|
|
description: "Deploy the reviewed change.".to_string(),
|
|
challenge: "AQID".to_string(),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
fn thread_id(request: &ServerRequest) -> &str {
|
|
let ServerRequest::McpServerElicitationRequest { params, .. } = request else {
|
|
unreachable!()
|
|
};
|
|
¶ms.thread_id
|
|
}
|
|
|
|
#[test]
|
|
fn user_verification_cancellation_invalidates_the_inflight_result() {
|
|
let mut pending = PendingAppServerRequests::default();
|
|
let mut request = verification_request();
|
|
let ServerRequest::McpServerElicitationRequest { params, .. } = &mut request else {
|
|
unreachable!()
|
|
};
|
|
params.thread_id.retain(|character| character != '-');
|
|
let canonical_thread_id = ThreadId::from_string(¶ms.thread_id)
|
|
.expect("valid compact thread ID")
|
|
.to_string();
|
|
pending.note_server_request(&request);
|
|
let attempt = pending
|
|
.user_verification
|
|
.begin(&canonical_thread_id, "deployments", request.id())
|
|
.expect("pending request");
|
|
assert!(
|
|
pending
|
|
.user_verification
|
|
.begin(&canonical_thread_id, "deployments", request.id())
|
|
.is_none()
|
|
);
|
|
assert!(pending.user_verification.is_pending(
|
|
&canonical_thread_id,
|
|
"deployments",
|
|
request.id(),
|
|
attempt.id
|
|
));
|
|
let resolution = pending
|
|
.take_resolution(
|
|
&canonical_thread_id,
|
|
AppCommand::resolve_user_verification(
|
|
"deployments".to_string(),
|
|
request.id().clone(),
|
|
UserVerificationResponse::Cancel,
|
|
),
|
|
)
|
|
.expect("cancel response");
|
|
assert_eq!(
|
|
resolution,
|
|
Some(AppServerRequestResolution {
|
|
request_id: request.id().clone(),
|
|
result: serde_json::json!({ "action": "cancel", "content": null, "_meta": {"openai/userVerificationReason": "userCancelled"} }),
|
|
})
|
|
);
|
|
assert!(!pending.user_verification.is_pending(
|
|
&canonical_thread_id,
|
|
"deployments",
|
|
request.id(),
|
|
attempt.id
|
|
));
|
|
assert!(attempt.cancelled.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn discarded_thread_cancels_only_its_verification_attempts() {
|
|
let mut pending = PendingAppServerRequests::default();
|
|
let request = verification_request();
|
|
let ServerRequest::McpServerElicitationRequest { params, .. } = &request else {
|
|
unreachable!()
|
|
};
|
|
let discarded_thread_id = params.thread_id.clone();
|
|
pending.note_server_request(&request);
|
|
let discarded_attempt = pending
|
|
.user_verification
|
|
.begin(thread_id(&request), "deployments", request.id())
|
|
.expect("discarded thread attempt");
|
|
|
|
let mut other_request = verification_request();
|
|
let ServerRequest::McpServerElicitationRequest { params, .. } = &mut other_request else {
|
|
unreachable!()
|
|
};
|
|
params.thread_id = ThreadId::new().to_string();
|
|
pending.note_server_request(&other_request);
|
|
let other_attempt = pending
|
|
.user_verification
|
|
.begin(thread_id(&other_request), "deployments", other_request.id())
|
|
.expect("other thread attempt");
|
|
assert!(!discarded_attempt.cancelled.is_cancelled());
|
|
|
|
pending.cancel_thread_verification(&discarded_thread_id);
|
|
|
|
assert!(discarded_attempt.cancelled.is_cancelled());
|
|
assert!(!pending.contains_server_request(&request));
|
|
assert!(!pending.user_verification.is_pending(
|
|
thread_id(&request),
|
|
"deployments",
|
|
request.id(),
|
|
discarded_attempt.id
|
|
));
|
|
assert!(!other_attempt.cancelled.is_cancelled());
|
|
assert!(pending.contains_server_request(&other_request));
|
|
assert!(pending.user_verification.is_pending(
|
|
thread_id(&other_request),
|
|
"deployments",
|
|
other_request.id(),
|
|
other_attempt.id
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn user_verification_reconnect_cannot_accept_a_previous_connections_proof() {
|
|
let mut pending = PendingAppServerRequests::default();
|
|
let request = verification_request();
|
|
pending.note_server_request(&request);
|
|
let previous = pending
|
|
.user_verification
|
|
.begin(thread_id(&request), "deployments", request.id())
|
|
.expect("first request");
|
|
pending.clear();
|
|
assert!(previous.cancelled.is_cancelled());
|
|
pending.note_server_request(&request);
|
|
let current = pending
|
|
.user_verification
|
|
.begin(thread_id(&request), "deployments", request.id())
|
|
.expect("new request");
|
|
assert!(!pending.user_verification.is_pending(
|
|
thread_id(&request),
|
|
"deployments",
|
|
request.id(),
|
|
previous.id
|
|
));
|
|
assert!(pending.user_verification.is_pending(
|
|
thread_id(&request),
|
|
"deployments",
|
|
request.id(),
|
|
current.id
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn user_verification_server_resolution_invalidates_the_inflight_result() {
|
|
let mut pending = PendingAppServerRequests::default();
|
|
let request = verification_request();
|
|
pending.note_server_request(&request);
|
|
let attempt = pending
|
|
.user_verification
|
|
.begin(thread_id(&request), "deployments", request.id())
|
|
.expect("pending request");
|
|
assert_eq!(
|
|
pending.resolve_notification(thread_id(&request), request.id()),
|
|
Some(ResolvedThreadAppServerRequest {
|
|
thread_id: thread_id(&request).to_string(),
|
|
request: ResolvedAppServerRequest::McpElicitation {
|
|
server_name: "deployments".to_string(),
|
|
request_id: request.id().clone(),
|
|
},
|
|
})
|
|
);
|
|
assert!(!pending.user_verification.is_pending(
|
|
thread_id(&request),
|
|
"deployments",
|
|
request.id(),
|
|
attempt.id
|
|
));
|
|
assert!(attempt.cancelled.is_cancelled());
|
|
}
|
|
|
|
#[test]
|
|
fn user_verification_proof_is_sent_to_the_server_but_not_session_recording() {
|
|
let request = verification_request();
|
|
let proof = UserVerificationProof {
|
|
credential_id: "private-credential".to_string(),
|
|
signature: "private-signature".to_string(),
|
|
};
|
|
let op = AppCommand::resolve_user_verification(
|
|
"deployments".to_string(),
|
|
request.id().clone(),
|
|
UserVerificationResponse::Accept {
|
|
proof: proof.clone(),
|
|
},
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_value(&op).expect("recording payload"),
|
|
serde_json::json!({
|
|
"ResolveUserVerification": {
|
|
"server_name": "deployments",
|
|
"request_id": 7,
|
|
"response": { "Accept": {} },
|
|
},
|
|
})
|
|
);
|
|
let mut pending = PendingAppServerRequests::default();
|
|
pending.note_server_request(&request);
|
|
let resolution = pending
|
|
.take_resolution(thread_id(&request), op)
|
|
.expect("response should serialize")
|
|
.expect("pending request");
|
|
assert_eq!(
|
|
resolution,
|
|
AppServerRequestResolution {
|
|
request_id: request.id().clone(),
|
|
result: serde_json::json!({
|
|
"action": "accept",
|
|
"content": proof,
|
|
"_meta": null,
|
|
}),
|
|
}
|
|
);
|
|
}
|