//! Exercise the HTTP adapter's clock, authentication and turn-bound stream. use chrono::DateTime; use chrono::Utc; use codex_agent_message_board_client::AccessToken; use codex_agent_message_board_client::BoardNotification; use codex_agent_message_board_client::RemoteAgentMessageBoard; use codex_http_client::HttpClientBuilder; use codex_message_board::AgentMessageBoard; use codex_message_board::AppendRequest; use codex_message_board::PostContent; use codex_message_board::PostDestination; use codex_message_board::PostMetadata; use codex_message_board::PostPreview; use codex_message_board::PostRequest; use codex_message_board::ReadPostRequest; use codex_message_board::UpsertChannelRequest; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; use std::num::NonZeroU32; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; #[tokio::test] async fn remote_board_preserves_the_host_contract() -> anyhow::Result<()> { let server = MockServer::start().await; let http = HttpClientBuilder::new().build_direct()?; let board = SessionId::new(); let caller = ThreadId::new(); let members = HashMap::from([(caller, AgentPath::root())]); let endpoint = format!("{}/board-service", server.uri()); let base = format!("/board-service/v1/boards/{board}"); let admin_token = AccessToken::new("administrator-credential-for-integration".into())?; for invalid in [ "not a URL", "file:///tmp/board", "https://board.test/?query", "https://board.test/#fragment", ] { assert!( RemoteAgentMessageBoard::new(http.clone(), invalid, board, admin_token.clone()) .is_err() ); } let admin = RemoteAgentMessageBoard::new(http.clone(), &format!("{endpoint}/"), board, admin_token)?; Mock::given(method("PUT")) .and(path(&base)) .and(header( "authorization", "Bearer administrator-credential-for-integration", )) .respond_with(ResponseTemplate::new(200).set_body_json(board)) .expect(1) .mount(&server) .await; assert_eq!(admin.create_board().await?, board); let invalid_registration = Mock::given(method("POST")) .and(path(format!("{base}/members"))) .respond_with(ResponseTemplate::new(200).set_body_json("too-short")) .expect(1) .mount_as_scoped(&server) .await; let error = admin.register_members(members.clone()).await.unwrap_err(); assert!(matches!(error.details(), CodexErrorDetails::Json(_))); drop(invalid_registration); Mock::given(method("POST")) .and(path(format!("{base}/members"))) .and(body_json(json!({"members": members}))) .respond_with( ResponseTemplate::new(200).set_body_json("runtime-credential-for-integration-32"), ) .expect(1) .mount(&server) .await; let token = admin.register_members(members).await?; let now: DateTime = "2026-09-24T12:00:00Z".parse()?; let clock_available = Arc::new(AtomicBool::new(true)); let clock = clock_available.clone(); let client = RemoteAgentMessageBoard::new(http, &endpoint, board, token)?.with_clock(move |_| { let available = clock.load(Ordering::Relaxed); Box::pin(async move { if available { Ok(now) } else { Err(CodexErr::Io(std::io::Error::other("clock unavailable"))) } }) }); let metadata: PostMetadata = serde_json::from_value(json!({ "message_id": "00000000-0000-4000-8000-000000000001", "thread_id": "00000000-0000-4000-8000-000000000001", "author": "/root", "channel_name": "results", "created_at": now, }))?; let post = PostRequest { request_id: "call-1".into(), destination: PostDestination::NewChannel("results".into()), text: "Straße 🦀".into(), agents_to_notify: vec![AgentPath::root()], }; Mock::given(method("POST")) .and(path(format!("{base}/call"))) .and(header( "authorization", "Bearer runtime-credential-for-integration-32", )) .and(body_json( json!({"caller": caller, "timestamp": now, "method": "post", "params": post}), )) // The post committed, but a gateway failed to return its result. .respond_with( ResponseTemplate::new(503) .set_body_json(json!({"code": "unavailable", "message": "response unavailable"})), ) .expect(1) .mount(&server) .await; let board_handle: &dyn AgentMessageBoard = &client; assert_eq!(board_handle.identity(), board); let requests_before = server.received_requests().await.unwrap().len(); let unsupported = board_handle .append( caller, AppendRequest { request_id: post.request_id.clone(), destination: post.destination.clone(), text: post.text.clone(), }, ) .await .unwrap_err(); assert!(matches!( unsupported.details(), CodexErrorDetails::InvalidRequest(_) )); let unsupported = board_handle .upsert_channel( caller, UpsertChannelRequest { channel_name: "results".into(), description: "Updated purpose".to_owned().try_into()?, }, ) .await .unwrap_err(); assert!(matches!( unsupported.details(), CodexErrorDetails::InvalidRequest(_) )); assert_eq!( server.received_requests().await.unwrap().len(), requests_before ); assert!(board_handle.post(caller, post.clone()).await.is_err()); clock_available.store(false, Ordering::Relaxed); Mock::given(method("POST")) .and(path(format!("{base}/call"))) .and(body_json( json!({"caller": caller, "timestamp": null, "method": "post", "params": post}), )) .respond_with(ResponseTemplate::new(200).set_body_json(&metadata)) .expect(1) .mount(&server) .await; assert_eq!(client.post(caller, post).await?, metadata); let request = ReadPostRequest { message_id: metadata.message_id, offset_chars: 7, limit_chars: NonZeroU32::MIN, }; let content = PostContent { metadata: metadata.clone(), text: "🦀".into(), n_chars: 8, next_offset_chars: 8, }; Mock::given(method("POST")) .and(path(format!("{base}/call"))) .and(body_json( json!({"caller": caller, "timestamp": null, "method": "read_post", "params": request}), )) .respond_with(ResponseTemplate::new(200).set_body_json(&content)) .expect(1) .mount(&server) .await; assert_eq!(client.read_post(caller, request).await?, content); let notice = BoardNotification { recipient: caller, turn_id: "turn-1".into(), post: PostPreview { metadata, text_preview: "Straße 🦀".into(), n_chars: 8, truncated: false, }, }; let mut stale = notice.clone(); stale.turn_id = "turn-0".into(); let mut oversized = notice.clone(); oversized.post.text_preview = "🦀".repeat(151); let events = [¬ice, &stale, &oversized] .into_iter() .map(|event| { Ok(format!( "event: notification\ndata: {}\n\n", serde_json::to_string(event)? )) }) .collect::>()?; // The limit is per frame, not per connection; support all SSE line endings. let heartbeats = ": heartbeat\r\n\r\n: heartbeat\r\r: heartbeat\n\n".repeat(16 * 1024); Mock::given(method("POST")) .and(path(format!("{base}/notifications"))) .and(body_json(json!({"caller": caller, "turn_id": "turn-1"}))) .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") .set_body_string(format!("{heartbeats}event: ready\ndata: {{}}\n\n{events}")), ) .expect(1) .mount(&server) .await; let mut receiver = client.notifications(caller, "turn-1".into()).await?; assert_eq!(receiver.next().await?, Some(notice)); assert_eq!(receiver.next().await?, None); for body in [ format!( "event: ready\nid: {}\ndata: {{}}\n\n", "x".repeat(512 * 1024) ), format!("event: ready\ndata: {{}}\n\nid: {}", "x".repeat(512 * 1024)), format!( "event: ready\ndata: {{}}\n\n{}", "data: x\n".repeat(64 * 1024 + 1) ), ] { let _response = Mock::given(method("POST")) .and(path(format!("{base}/notifications"))) .and(body_json(json!({"caller": caller, "turn_id": "limit"}))) .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") .set_body_string(body), ) .expect(1) .mount_as_scoped(&server) .await; let error = match client.notifications(caller, "limit".into()).await { Ok(mut receiver) => receiver.next().await.unwrap_err(), Err(error) => error, }; assert!( error .to_string() .contains("board SSE frame exceeds the service limit") ); } let invalid_response = Mock::given(method("DELETE")) .and(path(&base)) .respond_with(ResponseTemplate::new(502).set_body_string("not JSON")) .expect(1) .mount_as_scoped(&server) .await; let error = admin.delete_board().await.unwrap_err(); assert!(error.to_string().contains("HTTP 502")); assert!( anyhow::Error::new(error) .chain() .any(::is::) ); drop(invalid_response); Mock::given(method("DELETE")) .and(path(base)) .respond_with( ResponseTemplate::new(503) .set_body_json(json!({"code": "unavailable", "message": "storage unavailable"})), ) .expect(1) .mount(&server) .await; assert!( admin .delete_board() .await .unwrap_err() .to_string() .contains("storage unavailable") ); Ok(()) }