|
| 1 | +use anyhow::Result; |
| 2 | +use app_test_support::ChatGptIdTokenClaims; |
| 3 | +use app_test_support::TestAppServer; |
| 4 | +use app_test_support::encode_id_token; |
| 5 | +use app_test_support::to_response; |
| 6 | +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; |
| 7 | +use codex_app_server_protocol::JSONRPCResponse; |
| 8 | +use codex_app_server_protocol::LoginAccountResponse; |
| 9 | +use codex_app_server_protocol::RequestId; |
| 10 | +use codex_app_server_protocol::ThreadStartParams; |
| 11 | +use codex_app_server_protocol::ThreadStartResponse; |
| 12 | +use codex_app_server_protocol::TurnStartParams; |
| 13 | +use codex_app_server_protocol::UserInput; |
| 14 | +use core_test_support::apps_test_server::AppsTestServer; |
| 15 | +use core_test_support::responses; |
| 16 | +use serde_json::Value; |
| 17 | +use serde_json::json; |
| 18 | +use std::time::Duration; |
| 19 | +use tempfile::TempDir; |
| 20 | +use tokio::time::timeout; |
| 21 | +use wiremock::Mock; |
| 22 | +use wiremock::ResponseTemplate; |
| 23 | +use wiremock::matchers::method; |
| 24 | +use wiremock::matchers::path; |
| 25 | +use wiremock::matchers::query_param; |
| 26 | + |
| 27 | +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(20); |
| 28 | +const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174010"; |
| 29 | + |
| 30 | +#[tokio::test] |
| 31 | +async fn first_turn_after_external_login_waits_for_recommended_plugins() -> Result<()> { |
| 32 | + let server = responses::start_mock_server().await; |
| 33 | + let apps_server = AppsTestServer::mount(&server).await?; |
| 34 | + Mock::given(method("GET")) |
| 35 | + .and(path("/ps/plugins/suggested")) |
| 36 | + .and(query_param("scope", "GLOBAL")) |
| 37 | + .respond_with( |
| 38 | + ResponseTemplate::new(200) |
| 39 | + .set_delay(Duration::from_millis(250)) |
| 40 | + .set_body_json(json!({ |
| 41 | + "enabled": true, |
| 42 | + "plugins": [{ |
| 43 | + "id": "plugin_github", |
| 44 | + "name": "github", |
| 45 | + "status": "ENABLED", |
| 46 | + "installation_policy": "AVAILABLE", |
| 47 | + "release": {"display_name": "GitHub"} |
| 48 | + }] |
| 49 | + })), |
| 50 | + ) |
| 51 | + .expect(1) |
| 52 | + .mount(&server) |
| 53 | + .await; |
| 54 | + let response = responses::sse(vec![ |
| 55 | + responses::ev_response_created("resp-1"), |
| 56 | + responses::ev_assistant_message("msg-1", "done"), |
| 57 | + responses::ev_completed("resp-1"), |
| 58 | + ]); |
| 59 | + let responses_mock = responses::mount_sse_once(&server, response).await; |
| 60 | + |
| 61 | + let codex_home = TempDir::new()?; |
| 62 | + write_mock_responses_config_toml_with_chatgpt_base_url( |
| 63 | + codex_home.path(), |
| 64 | + &server.uri(), |
| 65 | + &apps_server.chatgpt_base_url, |
| 66 | + )?; |
| 67 | + let config_path = codex_home.path().join("config.toml"); |
| 68 | + let config = std::fs::read_to_string(&config_path)?; |
| 69 | + std::fs::write( |
| 70 | + config_path, |
| 71 | + format!( |
| 72 | + "{config}\n[features]\napps = true\nplugins = true\nremote_plugin = true\ntool_suggest = true\n" |
| 73 | + ), |
| 74 | + )?; |
| 75 | + |
| 76 | + let sqlite_home = codex_home.path().to_string_lossy(); |
| 77 | + let mut app_server = TestAppServer::new_without_managed_config_with_env( |
| 78 | + codex_home.path(), |
| 79 | + &[("CODEX_SQLITE_HOME", Some(sqlite_home.as_ref()))], |
| 80 | + ) |
| 81 | + .await?; |
| 82 | + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; |
| 83 | + |
| 84 | + let access_token = encode_id_token( |
| 85 | + &ChatGptIdTokenClaims::new() |
| 86 | + .email("embedded@example.com") |
| 87 | + .plan_type("pro") |
| 88 | + .chatgpt_account_id(WORKSPACE_ID), |
| 89 | + )?; |
| 90 | + let login_id = app_server |
| 91 | + .send_chatgpt_auth_tokens_login_request( |
| 92 | + access_token, |
| 93 | + WORKSPACE_ID.to_string(), |
| 94 | + Some("pro".to_string()), |
| 95 | + ) |
| 96 | + .await?; |
| 97 | + let login_response: JSONRPCResponse = timeout( |
| 98 | + DEFAULT_READ_TIMEOUT, |
| 99 | + app_server.read_stream_until_response_message(RequestId::Integer(login_id)), |
| 100 | + ) |
| 101 | + .await??; |
| 102 | + assert_eq!( |
| 103 | + to_response::<LoginAccountResponse>(login_response)?, |
| 104 | + LoginAccountResponse::ChatgptAuthTokens {} |
| 105 | + ); |
| 106 | + |
| 107 | + let thread_id = app_server |
| 108 | + .send_thread_start_request(ThreadStartParams { |
| 109 | + model: Some("mock-model".to_string()), |
| 110 | + ..Default::default() |
| 111 | + }) |
| 112 | + .await?; |
| 113 | + let thread_response: JSONRPCResponse = timeout( |
| 114 | + DEFAULT_READ_TIMEOUT, |
| 115 | + app_server.read_stream_until_response_message(RequestId::Integer(thread_id)), |
| 116 | + ) |
| 117 | + .await??; |
| 118 | + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; |
| 119 | + |
| 120 | + let turn_id = app_server |
| 121 | + .send_turn_start_request(TurnStartParams { |
| 122 | + thread_id: thread.id, |
| 123 | + input: vec![UserInput::Text { |
| 124 | + text: "suggest a plugin".to_string(), |
| 125 | + text_elements: Vec::new(), |
| 126 | + }], |
| 127 | + ..Default::default() |
| 128 | + }) |
| 129 | + .await?; |
| 130 | + let _: JSONRPCResponse = timeout( |
| 131 | + DEFAULT_READ_TIMEOUT, |
| 132 | + app_server.read_stream_until_response_message(RequestId::Integer(turn_id)), |
| 133 | + ) |
| 134 | + .await??; |
| 135 | + timeout( |
| 136 | + DEFAULT_READ_TIMEOUT, |
| 137 | + app_server.read_stream_until_notification_message("turn/completed"), |
| 138 | + ) |
| 139 | + .await??; |
| 140 | + |
| 141 | + let requests = responses_mock.requests(); |
| 142 | + let request = requests |
| 143 | + .iter() |
| 144 | + .find(|request| { |
| 145 | + request |
| 146 | + .message_input_texts("user") |
| 147 | + .iter() |
| 148 | + .any(|text| text.contains("suggest a plugin")) |
| 149 | + }) |
| 150 | + .expect("turn request"); |
| 151 | + let contextual_user_message = request.message_input_texts("user").join("\n"); |
| 152 | + assert!(contextual_user_message.contains("<recommended_plugins>")); |
| 153 | + assert!(contextual_user_message.contains("- GitHub (github@openai-curated-remote)")); |
| 154 | + let body = request.body_json(); |
| 155 | + let tool_names = body |
| 156 | + .get("tools") |
| 157 | + .and_then(Value::as_array) |
| 158 | + .into_iter() |
| 159 | + .flatten() |
| 160 | + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) |
| 161 | + .collect::<Vec<_>>(); |
| 162 | + assert!(tool_names.contains(&"request_plugin_install")); |
| 163 | + assert!(!tool_names.contains(&"list_available_plugins_to_install")); |
| 164 | + Ok(()) |
| 165 | +} |
0 commit comments