1use std::{
4 collections::{BTreeMap, BTreeSet, HashMap, HashSet},
5 path::PathBuf,
6 sync::{Arc, Weak},
7 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
8};
9
10use async_trait::async_trait;
11use base64::{Engine as _, engine::general_purpose::STANDARD};
12use imagegen_bridge_artifacts::{
13 ArtifactStore, ImageLimits, InputLoader, RemoteImageFetcher, inspect_image,
14};
15use imagegen_bridge_core::{
16 Background, BatchCapabilities, BatchMode, BridgeError, ErrorCode, GeneratedImage, ImageAction,
17 ImageOperation, ImagePayload, ImageProvider, ImageRequest, ImageResponse, InputCapabilities,
18 InputFidelity, Moderation, OutputFormat, ProviderCapabilities, ProviderContext,
19 ProviderDescriptor, Quality, RequestLimits, SessionMetadata, SessionMode, SizeCapabilities,
20 SupportLevel, Timings, U8Range, Usage, negotiate_request, validate_request,
21};
22use serde_json::{Value, json};
23use tokio::sync::{Mutex, broadcast};
24
25use crate::{AppServerRpc, CodexProcess};
26
27const TURN_CLEANUP_GRACE: Duration = Duration::from_secs(2);
28const MAX_REVISED_PROMPT_BYTES: usize = 128 * 1024;
29
30#[async_trait]
32pub trait SessionBindingStore: Send + Sync {
33 async fn get(&self, key: &str) -> Result<Option<String>, BridgeError>;
35 async fn put(&self, key: &str, thread_id: &str) -> Result<(), BridgeError>;
37 async fn delete(&self, key: &str) -> Result<(), BridgeError>;
39}
40
41#[derive(Debug, Default)]
43pub struct MemorySessionBindingStore {
44 bindings: Mutex<HashMap<String, String>>,
45}
46
47#[async_trait]
48impl SessionBindingStore for MemorySessionBindingStore {
49 async fn get(&self, key: &str) -> Result<Option<String>, BridgeError> {
50 Ok(self.bindings.lock().await.get(key).cloned())
51 }
52
53 async fn put(&self, key: &str, thread_id: &str) -> Result<(), BridgeError> {
54 self.bindings
55 .lock()
56 .await
57 .insert(key.to_owned(), thread_id.to_owned());
58 Ok(())
59 }
60
61 async fn delete(&self, key: &str) -> Result<(), BridgeError> {
62 self.bindings.lock().await.remove(key);
63 Ok(())
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct AppServerProviderConfig {
70 pub codex_model: Option<String>,
72 pub cwd: PathBuf,
74 pub image_limits: ImageLimits,
76}
77
78#[derive(Clone)]
80pub struct AppServerReferenceInputs {
81 loader: Arc<InputLoader>,
82 remote_fetcher: Option<RemoteImageFetcher>,
83 staging_store: Arc<ArtifactStore>,
84 max_aggregate_bytes: u64,
85}
86
87impl std::fmt::Debug for AppServerReferenceInputs {
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 formatter
90 .debug_struct("AppServerReferenceInputs")
91 .field("loader", &"[CAPABILITY ROOTS]")
92 .field("remote_fetcher", &self.remote_fetcher.is_some())
93 .field("staging_store", &"[BRIDGE-OWNED ROOT]")
94 .field("max_aggregate_bytes", &self.max_aggregate_bytes)
95 .finish()
96 }
97}
98
99impl AppServerReferenceInputs {
100 pub fn new(
102 loader: Arc<InputLoader>,
103 remote_fetcher: Option<RemoteImageFetcher>,
104 staging_store: Arc<ArtifactStore>,
105 max_aggregate_bytes: u64,
106 ) -> Result<Self, BridgeError> {
107 if max_aggregate_bytes == 0 {
108 return Err(BridgeError::new(
109 ErrorCode::Configuration,
110 "app-server reference byte limit must be greater than zero",
111 ));
112 }
113 Ok(Self {
114 loader,
115 remote_fetcher,
116 staging_store,
117 max_aggregate_bytes,
118 })
119 }
120}
121
122pub struct AppServerImageProvider {
124 fixed_rpc: Option<Arc<AppServerRpc>>,
125 process: Option<Arc<CodexProcess>>,
126 sessions: Arc<dyn SessionBindingStore>,
127 thread_locks: Mutex<HashMap<String, Weak<Mutex<()>>>>,
128 config: AppServerProviderConfig,
129 reference_inputs: Option<AppServerReferenceInputs>,
130}
131
132impl std::fmt::Debug for AppServerImageProvider {
133 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 formatter
135 .debug_struct("AppServerImageProvider")
136 .field("has_fixed_rpc", &self.fixed_rpc.is_some())
137 .field("has_process", &self.process.is_some())
138 .field("config", &self.config)
139 .finish_non_exhaustive()
140 }
141}
142
143impl AppServerImageProvider {
144 #[must_use]
146 pub fn new(
147 rpc: Arc<AppServerRpc>,
148 sessions: Arc<dyn SessionBindingStore>,
149 config: AppServerProviderConfig,
150 ) -> Self {
151 Self {
152 fixed_rpc: Some(rpc),
153 process: None,
154 sessions,
155 thread_locks: Mutex::new(HashMap::new()),
156 config,
157 reference_inputs: None,
158 }
159 }
160
161 #[must_use]
163 pub fn new_with_inputs(
164 rpc: Arc<AppServerRpc>,
165 sessions: Arc<dyn SessionBindingStore>,
166 config: AppServerProviderConfig,
167 reference_inputs: AppServerReferenceInputs,
168 ) -> Self {
169 Self {
170 fixed_rpc: Some(rpc),
171 process: None,
172 sessions,
173 thread_locks: Mutex::new(HashMap::new()),
174 config,
175 reference_inputs: Some(reference_inputs),
176 }
177 }
178
179 #[must_use]
181 pub fn with_process(
182 process: Arc<CodexProcess>,
183 sessions: Arc<dyn SessionBindingStore>,
184 config: AppServerProviderConfig,
185 ) -> Self {
186 Self {
187 fixed_rpc: None,
188 process: Some(process),
189 sessions,
190 thread_locks: Mutex::new(HashMap::new()),
191 config,
192 reference_inputs: None,
193 }
194 }
195
196 #[must_use]
198 pub fn with_process_and_inputs(
199 process: Arc<CodexProcess>,
200 sessions: Arc<dyn SessionBindingStore>,
201 config: AppServerProviderConfig,
202 reference_inputs: AppServerReferenceInputs,
203 ) -> Self {
204 Self {
205 fixed_rpc: None,
206 process: Some(process),
207 sessions,
208 thread_locks: Mutex::new(HashMap::new()),
209 config,
210 reference_inputs: Some(reference_inputs),
211 }
212 }
213
214 async fn execute_inner(
215 &self,
216 request: ImageRequest,
217 context: ProviderContext,
218 ) -> Result<ImageResponse, BridgeError> {
219 validate_request(&request, RequestLimits::default())?;
220 let negotiated = negotiate_request(&request, &capabilities())?;
221 let request = negotiated.effective_request;
222 let rpc = self.connection().await?;
223 let lock_key = request
224 .session
225 .key
226 .clone()
227 .or_else(|| request.session.thread_id.clone())
228 .unwrap_or_else(|| context.request_id.clone());
229 let thread_lock = {
230 let mut locks = self.thread_locks.lock().await;
231 locks.retain(|_, lock| lock.strong_count() > 0);
232 if let Some(lock) = locks.get(&lock_key).and_then(Weak::upgrade) {
233 lock
234 } else {
235 let lock = Arc::new(Mutex::new(()));
236 locks.insert(lock_key, Arc::downgrade(&lock));
237 lock
238 }
239 };
240 let remaining = context.deadline.saturating_duration_since(Instant::now());
241 let _guard = tokio::select! {
242 guard = thread_lock.lock_owned() => guard,
243 () = context.cancellation.cancelled() => {
244 return Err(BridgeError::new(
245 ErrorCode::Cancelled,
246 "request was cancelled while waiting for the session",
247 ));
248 }
249 () = tokio::time::sleep(remaining) => {
250 return Err(BridgeError::new(
251 ErrorCode::Timeout,
252 "request timed out while waiting for the session",
253 ).retryable(true));
254 }
255 };
256 let started = Instant::now();
257 let (thread_id, reused) = self.resolve_thread(&rpc, &request, &context).await?;
258 let result = self.run_turn(&rpc, &thread_id, &request, &context).await;
259 if request.session.mode == SessionMode::Isolated {
260 let cleanup_deadline = Instant::now() + TURN_CLEANUP_GRACE;
261 let _ = rpc
262 .request_side_effecting_until(
263 "thread/archive",
264 json!({"threadId": thread_id}),
265 cleanup_deadline,
266 tokio_util::sync::CancellationToken::new(),
267 )
268 .await;
269 }
270 let turn = result?;
271 let mut images = turn
272 .images
273 .into_iter()
274 .map(|encoded| self.normalized_image(&encoded))
275 .collect::<Result<Vec<_>, BridgeError>>()?;
276 let elapsed = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
277 for image in &mut images {
278 image.generation_ms = Some(elapsed);
279 }
280 Ok(ImageResponse {
281 id: context.request_id,
282 created: SystemTime::now()
283 .duration_since(UNIX_EPOCH)
284 .map_or(0, |duration| duration.as_secs()),
285 provider: "codex-app-server".to_owned(),
286 model: "gpt-image-2".to_owned(),
287 requested: negotiated.requested,
288 effective: request.parameters,
289 normalizations: negotiated.normalizations,
290 attempts: Vec::new(),
291 data: images,
292 failures: Vec::new(),
293 revised_prompt: turn.revised_prompt,
294 usage: None::<Usage>,
295 session: Some(SessionMetadata {
296 key: request.session.key,
297 thread_id: Some(thread_id),
298 reused,
299 }),
300 timings: Timings {
301 provider_ms: elapsed,
302 total_ms: elapsed,
303 ..Timings::default()
304 },
305 warnings: Vec::new(),
306 })
307 }
308
309 async fn connection(&self) -> Result<Arc<AppServerRpc>, BridgeError> {
310 if let Some(process) = &self.process {
311 process.rpc().await
312 } else {
313 self.fixed_rpc.clone().ok_or_else(|| {
314 BridgeError::new(
315 ErrorCode::Internal,
316 "app-server provider has no connection source",
317 )
318 })
319 }
320 }
321
322 async fn resolve_thread(
323 &self,
324 rpc: &AppServerRpc,
325 request: &ImageRequest,
326 context: &ProviderContext,
327 ) -> Result<(String, bool), BridgeError> {
328 match request.session.mode {
329 SessionMode::Isolated => self
330 .start_thread(rpc, true, context)
331 .await
332 .map(|id| (id, false)),
333 SessionMode::Persistent => {
334 let key = request.session.key.as_deref().ok_or_else(|| {
335 BridgeError::new(ErrorCode::Session, "persistent session key is missing")
336 })?;
337 if let Some(thread_id) = self.sessions.get(key).await? {
338 self.resume_thread(rpc, &thread_id, context).await?;
339 Ok((thread_id, true))
340 } else {
341 let thread_id = self.start_thread(rpc, false, context).await?;
342 self.sessions.put(key, &thread_id).await?;
343 Ok((thread_id, false))
344 }
345 }
346 SessionMode::Thread => {
347 let thread_id = request.session.thread_id.as_deref().ok_or_else(|| {
348 BridgeError::new(ErrorCode::Session, "explicit thread ID is missing")
349 })?;
350 self.resume_thread(rpc, thread_id, context).await?;
351 Ok((thread_id.to_owned(), true))
352 }
353 }
354 }
355
356 async fn start_thread(
357 &self,
358 rpc: &AppServerRpc,
359 ephemeral: bool,
360 context: &ProviderContext,
361 ) -> Result<String, BridgeError> {
362 let result = rpc
363 .request_until(
364 "thread/start",
365 json!({
366 "model": self.config.codex_model,
367 "cwd": self.config.cwd,
368 "approvalPolicy": "never",
369 "sandbox": "read-only",
370 "ephemeral": ephemeral,
371 "developerInstructions": "For each request, call image_gen.imagegen exactly once and do not use unrelated tools."
372 }),
373 context.deadline,
374 context.cancellation.clone(),
375 )
376 .await?;
377 string_at(
378 &result,
379 &["thread", "id"],
380 "thread/start returned no thread ID",
381 )
382 }
383
384 async fn resume_thread(
385 &self,
386 rpc: &AppServerRpc,
387 thread_id: &str,
388 context: &ProviderContext,
389 ) -> Result<(), BridgeError> {
390 rpc.request_until(
391 "thread/resume",
392 json!({"threadId": thread_id}),
393 context.deadline,
394 context.cancellation.clone(),
395 )
396 .await?;
397 Ok(())
398 }
399
400 async fn run_turn(
401 &self,
402 rpc: &AppServerRpc,
403 thread_id: &str,
404 request: &ImageRequest,
405 context: &ProviderContext,
406 ) -> Result<TurnImages, BridgeError> {
407 let paths = self.reference_paths(request).await?;
408 let mut input = vec![json!({
409 "type": "text",
410 "text": turn_prompt(&request.prompt, &paths),
411 "textElements": []
412 })];
413 input.extend(
414 paths
415 .iter()
416 .map(|path| json!({"type": "localImage", "path": path, "detail": "original"})),
417 );
418 let mut notifications = rpc.subscribe();
419 let result = rpc
420 .request_side_effecting_until(
421 "turn/start",
422 json!({"threadId": thread_id, "input": input}),
423 context.deadline,
424 context.cancellation.clone(),
425 )
426 .await?;
427 let turn_id = string_at(&result, &["turn", "id"], "turn/start returned no turn ID")?;
428 let max_images = usize::from(request.parameters.n);
429 let encoded_each = self
430 .config
431 .image_limits
432 .max_encoded_bytes
433 .saturating_add(2)
434 .saturating_div(3)
435 .saturating_mul(4);
436 let max_result_bytes = usize::try_from(encoded_each)
437 .unwrap_or(usize::MAX)
438 .saturating_mul(max_images);
439 let mut accumulator = TurnAccumulator {
440 images: Vec::with_capacity(max_images),
441 revised_prompt: None,
442 completed_item_ids: HashSet::with_capacity(max_images),
443 result_bytes: 0,
444 max_images,
445 max_result_bytes,
446 diagnostics: TurnDiagnostics::default(),
447 };
448 loop {
449 let remaining = context.deadline.saturating_duration_since(Instant::now());
450 let notification = tokio::select! {
451 result = notifications.recv() => result.map_err(|error| notification_error(&error))?,
452 () = context.cancellation.cancelled() => {
453 return Err(self
454 .interrupt_outcome(
455 rpc,
456 &mut notifications,
457 thread_id,
458 &turn_id,
459 ErrorCode::Cancelled,
460 "image generation was cancelled",
461 )
462 .await);
463 }
464 () = tokio::time::sleep(remaining) => {
465 return Err(self
466 .interrupt_outcome(
467 rpc,
468 &mut notifications,
469 thread_id,
470 &turn_id,
471 ErrorCode::Timeout,
472 "image generation timed out",
473 )
474 .await);
475 }
476 };
477 if belongs_to(¬ification.params, thread_id, &turn_id) {
478 accumulator.diagnostics.observe(¬ification);
479 }
480 if notification.method == "item/completed"
481 && belongs_to(¬ification.params, thread_id, &turn_id)
482 && notification.params["item"]["type"] == "imageGeneration"
483 {
484 let item = ¬ification.params["item"];
485 if item["status"] == "completed"
486 && let Err(error) = accumulator.capture(item)
487 {
488 return Err(self
489 .protocol_failure_outcome(
490 rpc,
491 &mut notifications,
492 thread_id,
493 &turn_id,
494 error,
495 )
496 .await);
497 }
498 }
499 if notification.method == "turn/completed"
500 && belongs_to(¬ification.params, thread_id, &turn_id)
501 {
502 if notification.params["turn"]["status"] != "completed" {
503 return Err(turn_failure_error(¬ification.params["turn"], request));
504 }
505 if accumulator.images.is_empty() {
506 let diagnostics = accumulator.diagnostics.as_json();
507 tracing::warn!(
508 request_id = %context.request_id,
509 observed_notifications = accumulator.diagnostics.observed_notifications,
510 completed_items = accumulator.diagnostics.completed_items,
511 image_items = accumulator.diagnostics.image_items,
512 completed_image_items = accumulator.diagnostics.completed_image_items,
513 "Codex app-server turn completed without an image"
514 );
515 return Err(BridgeError::new(
516 ErrorCode::Upstream,
517 "Codex turn completed without an image",
518 )
519 .with_provider("codex-app-server")
520 .with_detail("turn_diagnostics", diagnostics));
521 }
522 return Ok(TurnImages {
523 images: accumulator.images,
524 revised_prompt: accumulator.revised_prompt,
525 });
526 }
527 }
528 }
529
530 async fn reference_paths(&self, request: &ImageRequest) -> Result<Vec<PathBuf>, BridgeError> {
531 let inputs = request_images(request);
532 if inputs.is_empty() {
533 return Ok(Vec::new());
534 }
535 let materializer = self.reference_inputs.as_ref().ok_or_else(|| {
536 BridgeError::new(
537 ErrorCode::Input,
538 "app-server reference inputs require a configured secure staging store",
539 )
540 })?;
541 let mut total = 0_u64;
542 let mut paths = Vec::with_capacity(inputs.len());
543 for input in inputs {
544 let loaded = match &input.source {
545 imagegen_bridge_core::ImageSource::Url { url } => {
546 materializer
547 .remote_fetcher
548 .as_ref()
549 .ok_or_else(|| {
550 BridgeError::new(ErrorCode::Input, "remote image inputs are disabled")
551 })?
552 .fetch(url)
553 .await?
554 }
555 _ => materializer.loader.load(&input)?,
556 };
557 total = total.checked_add(loaded.metadata.bytes).ok_or_else(|| {
558 BridgeError::new(ErrorCode::Input, "reference image bytes overflowed")
559 })?;
560 if total > materializer.max_aggregate_bytes {
561 return Err(BridgeError::new(
562 ErrorCode::Input,
563 "aggregate reference images exceed the configured byte limit",
564 ));
565 }
566 let stored = materializer.staging_store.publish(
567 &loaded.bytes,
568 input.filename.as_deref().or(Some("reference")),
569 Some(loaded.metadata.format),
570 )?;
571 paths.push(stored.path);
572 }
573 Ok(paths)
574 }
575
576 async fn interrupt_and_confirm(
577 &self,
578 rpc: &AppServerRpc,
579 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
580 thread_id: &str,
581 turn_id: &str,
582 ) -> Result<String, BridgeError> {
583 let cleanup_deadline = Instant::now() + TURN_CLEANUP_GRACE;
584 rpc.request_side_effecting_until(
585 "turn/interrupt",
586 json!({"threadId": thread_id, "turnId": turn_id}),
587 cleanup_deadline,
588 tokio_util::sync::CancellationToken::new(),
589 )
590 .await?;
591 loop {
592 let remaining = cleanup_deadline.saturating_duration_since(Instant::now());
593 let notification = tokio::time::timeout(remaining, notifications.recv())
594 .await
595 .map_err(|_| {
596 unknown_app_server_outcome(
597 ErrorCode::Timeout,
598 "turn interruption was not confirmed",
599 )
600 })?
601 .map_err(|error| notification_error(&error))?;
602 if notification.method == "turn/completed"
603 && belongs_to(¬ification.params, thread_id, turn_id)
604 {
605 return Ok(notification.params["turn"]["status"]
606 .as_str()
607 .and_then(safe_failure_label)
608 .unwrap_or("unknown")
609 .to_owned());
610 }
611 }
612 }
613
614 async fn interrupt_outcome(
615 &self,
616 rpc: &AppServerRpc,
617 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
618 thread_id: &str,
619 turn_id: &str,
620 code: ErrorCode,
621 message: &str,
622 ) -> BridgeError {
623 match self
624 .interrupt_and_confirm(rpc, notifications, thread_id, turn_id)
625 .await
626 {
627 Ok(status) if matches!(status.as_str(), "interrupted" | "cancelled" | "failed") => {
628 BridgeError::new(code, message)
629 .with_provider("codex-app-server")
630 .retryable(code == ErrorCode::Timeout)
631 .with_detail("outcome", "cancelled")
632 .with_detail("terminal_status", status)
633 }
634 Ok(status) => {
635 unknown_app_server_outcome(code, message).with_detail("terminal_status", status)
636 }
637 Err(_) => unknown_app_server_outcome(code, message),
638 }
639 }
640
641 async fn protocol_failure_outcome(
642 &self,
643 rpc: &AppServerRpc,
644 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
645 thread_id: &str,
646 turn_id: &str,
647 error: BridgeError,
648 ) -> BridgeError {
649 match self
650 .interrupt_and_confirm(rpc, notifications, thread_id, turn_id)
651 .await
652 {
653 Ok(status) if matches!(status.as_str(), "interrupted" | "cancelled" | "failed") => {
654 error.with_detail("terminal_status", status)
655 }
656 Ok(status) => error
657 .retryable(false)
658 .with_detail("outcome", "unknown")
659 .with_detail("terminal_status", status),
660 Err(_) => error.retryable(false).with_detail("outcome", "unknown"),
661 }
662 }
663
664 fn normalized_image(&self, encoded: &str) -> Result<GeneratedImage, BridgeError> {
665 let bytes = STANDARD.decode(encoded.trim()).map_err(|_| {
666 BridgeError::new(
667 ErrorCode::Protocol,
668 "Codex returned malformed base64 image data",
669 )
670 })?;
671 let metadata =
672 inspect_image(&bytes, self.config.image_limits).map_err(|error| BridgeError {
673 code: ErrorCode::Protocol,
674 provider: Some("codex-app-server".to_owned()),
675 ..error
676 })?;
677 Ok(GeneratedImage {
678 index: 0,
679 payload: ImagePayload::B64Json {
680 b64_json: encoded.to_owned(),
681 },
682 format: metadata.format,
683 width: metadata.width,
684 height: metadata.height,
685 bytes: metadata.bytes,
686 sha256: metadata.sha256,
687 generation_ms: None,
688 metadata_name: None,
689 })
690 }
691}
692
693#[async_trait]
694impl ImageProvider for AppServerImageProvider {
695 fn descriptor(&self) -> ProviderDescriptor {
696 ProviderDescriptor {
697 name: "codex-app-server".to_owned(),
698 display_name: "Codex app-server".to_owned(),
699 version: env!("CARGO_PKG_VERSION").to_owned(),
700 experimental: false,
701 models: vec!["gpt-image-2".to_owned()],
702 }
703 }
704
705 async fn capabilities(&self, model: Option<&str>) -> Result<ProviderCapabilities, BridgeError> {
706 let capabilities = capabilities();
707 if let Some(model) = model
708 && capabilities.model.as_deref() != Some(model)
709 {
710 return Err(BridgeError::new(
711 ErrorCode::UnsupportedCapability,
712 "Codex app-server image generation only exposes gpt-image-2",
713 )
714 .with_provider("codex-app-server")
715 .with_detail("field", "routing.model")
716 .with_detail("requested_model", model)
717 .with_detail("effective_model", "gpt-image-2"));
718 }
719 Ok(capabilities)
720 }
721
722 async fn execute(
723 &self,
724 request: ImageRequest,
725 context: ProviderContext,
726 ) -> Result<ImageResponse, BridgeError> {
727 self.execute_inner(request, context).await
728 }
729
730 async fn check_ready(&self) -> Result<(), BridgeError> {
731 let connection = self.connection().await?;
732 let account = connection.request("account/read", json!({})).await?;
733 if account["account"].is_null() {
734 return Err(BridgeError::new(
735 ErrorCode::Authentication,
736 "Codex app-server requires authentication",
737 )
738 .with_provider("codex-app-server"));
739 }
740 let capabilities = connection
741 .request("modelProvider/capabilities/read", json!({}))
742 .await?;
743 if capabilities["imageGeneration"] != true {
744 return Err(BridgeError::new(
745 ErrorCode::UnsupportedCapability,
746 "Codex app-server does not report image generation capability",
747 )
748 .with_provider("codex-app-server")
749 .with_detail("field", "imageGeneration"));
750 }
751 Ok(())
752 }
753
754 async fn get_session(&self, key: &str) -> Result<SessionMetadata, BridgeError> {
755 let thread_id = self.sessions.get(key).await?.ok_or_else(|| {
756 BridgeError::new(ErrorCode::Session, "persistent session was not found")
757 .with_provider("codex-app-server")
758 })?;
759 Ok(SessionMetadata {
760 key: Some(key.to_owned()),
761 thread_id: Some(thread_id),
762 reused: true,
763 })
764 }
765
766 async fn delete_session(&self, key: &str) -> Result<(), BridgeError> {
767 if self.sessions.get(key).await?.is_none() {
768 return Err(
769 BridgeError::new(ErrorCode::Session, "persistent session was not found")
770 .with_provider("codex-app-server"),
771 );
772 }
773 self.sessions.delete(key).await
774 }
775
776 fn restart_count(&self) -> Option<u64> {
777 self.process
778 .as_ref()
779 .map(|process| process.generation().saturating_sub(1))
780 }
781
782 async fn shutdown(&self) -> Result<(), BridgeError> {
783 if let Some(process) = &self.process {
784 process.shutdown().await?;
785 }
786 Ok(())
787 }
788}
789
790fn capabilities() -> ProviderCapabilities {
791 let no_input = InputCapabilities {
792 support: SupportLevel::Unsupported,
793 max_count: 0,
794 max_bytes_each: 0,
795 max_bytes_total: 0,
796 };
797 let references = InputCapabilities {
798 support: SupportLevel::Native,
799 max_count: 5,
800 max_bytes_each: 32 * 1024 * 1024,
801 max_bytes_total: 64 * 1024 * 1024,
802 };
803 ProviderCapabilities {
804 provider: "codex-app-server".to_owned(),
805 implementation_version: env!("CARGO_PKG_VERSION").to_owned(),
806 model: Some("gpt-image-2".to_owned()),
807 experimental: false,
808 generation: true,
809 edits: true,
810 count: U8Range { min: 1, max: 1 },
811 batching: BatchCapabilities {
812 mode: BatchMode::Native,
813 native_count: U8Range { min: 1, max: 1 },
814 max_parallel_outputs: 1,
815 },
816 sizes: SizeCapabilities {
817 auto: true,
818 allowed: BTreeSet::new(),
819 arbitrary: false,
820 min_edge: None,
821 max_edge: None,
822 edge_multiple: None,
823 min_pixels: None,
824 max_pixels: None,
825 max_aspect_ratio: None,
826 },
827 aspect_ratio: SupportLevel::Unsupported,
828 resolution: SupportLevel::Unsupported,
829 qualities: BTreeSet::from([Quality::Auto]),
830 output_formats: BTreeSet::from([OutputFormat::Png]),
831 backgrounds: BTreeSet::from([Background::Auto]),
832 transparent_background: SupportLevel::Emulated,
833 moderation: BTreeSet::from([Moderation::Auto]),
834 negative_prompt: SupportLevel::Emulated,
835 revised_prompt: SupportLevel::Emulated,
836 user_attribution: SupportLevel::Unsupported,
837 input_fidelities: BTreeSet::from([InputFidelity::High]),
838 actions: BTreeSet::from([ImageAction::Auto]),
839 reference_images: references.clone(),
840 edit_images: references,
841 masks: no_input,
842 partial_images: U8Range { min: 0, max: 0 },
843 persistent_sessions: true,
844 explicit_threads: true,
845 }
846}
847
848fn request_images(request: &ImageRequest) -> Vec<imagegen_bridge_core::ImageInput> {
849 match &request.operation {
850 ImageOperation::Generate { reference_images } => reference_images.clone(),
851 ImageOperation::Edit {
852 images,
853 reference_images,
854 ..
855 } => images.iter().chain(reference_images).cloned().collect(),
856 }
857}
858
859fn turn_prompt(prompt: &str, paths: &[PathBuf]) -> String {
860 if paths.is_empty() {
861 format!("Generate this image now with image_gen.imagegen: {prompt}")
862 } else {
863 let paths = paths
864 .iter()
865 .map(|path| path.display().to_string())
866 .collect::<Vec<_>>()
867 .join(", ");
868 format!(
869 "Generate or edit this image now with image_gen.imagegen. Use referenced_image_paths exactly as listed: [{paths}]. Prompt: {prompt}"
870 )
871 }
872}
873
874fn string_at(value: &Value, path: &[&str], message: &str) -> Result<String, BridgeError> {
875 let mut value = value;
876 for component in path {
877 value = &value[*component];
878 }
879 value.as_str().map(str::to_owned).ok_or_else(|| {
880 BridgeError::new(ErrorCode::Protocol, message).with_provider("codex-app-server")
881 })
882}
883
884fn belongs_to(params: &Value, thread_id: &str, turn_id: &str) -> bool {
885 params["threadId"] == thread_id
886 && (params["turnId"] == turn_id || params["turn"]["id"] == turn_id)
887}
888
889fn notification_error(error: &broadcast::error::RecvError) -> BridgeError {
890 match error {
891 broadcast::error::RecvError::Closed => {
892 BridgeError::new(ErrorCode::Protocol, "app-server notification stream closed")
893 }
894 broadcast::error::RecvError::Lagged(_) => BridgeError::new(
895 ErrorCode::Overloaded,
896 "app-server notification consumer lagged behind",
897 ),
898 }
899 .with_provider("codex-app-server")
900}
901
902fn turn_failure_error(turn: &Value, request: &ImageRequest) -> BridgeError {
903 let upstream_code = turn["error"]["code"]
904 .as_str()
905 .or_else(|| turn["errorCode"].as_str())
906 .and_then(safe_failure_label);
907 let upstream_status = turn["status"].as_str().and_then(safe_failure_label);
908 let safety_rejected = upstream_code.is_some_and(is_safety_failure_label)
909 || upstream_status.is_some_and(is_safety_failure_label);
910 let mut error = if safety_rejected {
911 BridgeError::safety_rejected("Codex app-server rejected the image request")
912 .with_detail("requested_moderation", request.parameters.moderation)
913 .with_detail("input_images_present", !request_images(request).is_empty())
914 } else {
915 BridgeError::new(
916 ErrorCode::Upstream,
917 "Codex image generation turn did not complete successfully",
918 )
919 }
920 .with_provider("codex-app-server");
921 if let Some(code) = upstream_code {
922 error = error.with_detail("upstream_code", code);
923 }
924 if let Some(status) = upstream_status {
925 error = error.with_detail("upstream_status", status);
926 }
927 error
928}
929
930fn safe_failure_label(value: &str) -> Option<&str> {
931 (!value.is_empty()
932 && value.len() <= 64
933 && value
934 .bytes()
935 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')))
936 .then_some(value)
937}
938
939fn is_safety_failure_label(value: &str) -> bool {
940 let lower = value.to_ascii_lowercase();
941 lower.contains("safety")
942 || lower.contains("content_policy")
943 || lower.contains("moderation")
944 || lower.contains("refusal")
945}
946
947fn unknown_app_server_outcome(code: ErrorCode, message: &str) -> BridgeError {
948 BridgeError::new(code, message)
949 .with_provider("codex-app-server")
950 .retryable(false)
951 .with_detail("outcome", "unknown")
952}
953
954struct TurnAccumulator {
955 images: Vec<String>,
956 revised_prompt: Option<String>,
957 completed_item_ids: HashSet<String>,
958 result_bytes: usize,
959 max_images: usize,
960 max_result_bytes: usize,
961 diagnostics: TurnDiagnostics,
962}
963
964#[derive(Default)]
965struct TurnDiagnostics {
966 observed_notifications: usize,
967 completed_items: usize,
968 image_items: usize,
969 completed_image_items: usize,
970 failed_image_items: usize,
971 terminal_item_count: usize,
972 item_types: BTreeMap<String, usize>,
973 item_statuses: BTreeMap<String, usize>,
974}
975
976impl TurnDiagnostics {
977 fn observe(&mut self, notification: &crate::RpcNotification) {
978 self.observed_notifications = self.observed_notifications.saturating_add(1);
979 if notification.method == "turn/completed" {
980 self.terminal_item_count = notification.params["turn"]["items"]
981 .as_array()
982 .map_or(0, Vec::len);
983 return;
984 }
985 if notification.method != "item/completed" {
986 return;
987 }
988 self.completed_items = self.completed_items.saturating_add(1);
989 let item = ¬ification.params["item"];
990 let item_type = item["type"]
991 .as_str()
992 .and_then(safe_failure_label)
993 .unwrap_or("unknown");
994 if self.item_types.len() < 16 || self.item_types.contains_key(item_type) {
995 let count = self.item_types.entry(item_type.to_owned()).or_default();
996 *count = count.saturating_add(1);
997 }
998 let status = item["status"]
999 .as_str()
1000 .and_then(safe_failure_label)
1001 .unwrap_or("unknown");
1002 if self.item_statuses.len() < 16 || self.item_statuses.contains_key(status) {
1003 let count = self.item_statuses.entry(status.to_owned()).or_default();
1004 *count = count.saturating_add(1);
1005 }
1006 if item_type == "imageGeneration" {
1007 self.image_items = self.image_items.saturating_add(1);
1008 if status == "completed" {
1009 self.completed_image_items = self.completed_image_items.saturating_add(1);
1010 } else if matches!(status, "failed" | "declined" | "cancelled") {
1011 self.failed_image_items = self.failed_image_items.saturating_add(1);
1012 }
1013 }
1014 }
1015
1016 fn as_json(&self) -> Value {
1017 json!({
1018 "observed_notifications": self.observed_notifications,
1019 "completed_items": self.completed_items,
1020 "image_items": self.image_items,
1021 "completed_image_items": self.completed_image_items,
1022 "failed_image_items": self.failed_image_items,
1023 "terminal_item_count": self.terminal_item_count,
1024 "item_types": self.item_types,
1025 "item_statuses": self.item_statuses,
1026 })
1027 }
1028}
1029
1030impl TurnAccumulator {
1031 fn capture(&mut self, item: &Value) -> Result<(), BridgeError> {
1032 let item_id = item["id"]
1033 .as_str()
1034 .filter(|value| !value.is_empty())
1035 .ok_or_else(|| app_server_protocol_error("completed image has no item ID"))?;
1036 if self.completed_item_ids.contains(item_id) {
1037 return Err(app_server_protocol_error(
1038 "app-server returned a duplicate completed image item",
1039 ));
1040 }
1041 if self.images.len() >= self.max_images {
1042 return Err(app_server_protocol_error(
1043 "app-server returned more images than negotiated",
1044 ));
1045 }
1046 let result = item["result"]
1047 .as_str()
1048 .filter(|value| !value.is_empty())
1049 .ok_or_else(|| app_server_protocol_error("completed image has no result"))?;
1050 let next_result_bytes = self
1051 .result_bytes
1052 .checked_add(result.len())
1053 .ok_or_else(|| app_server_protocol_error("aggregate image result size overflowed"))?;
1054 if next_result_bytes > self.max_result_bytes {
1055 return Err(app_server_protocol_error(
1056 "aggregate image results exceed the byte limit",
1057 ));
1058 }
1059 let revised = item["revisedPrompt"]
1060 .as_str()
1061 .or_else(|| item["revised_prompt"].as_str())
1062 .filter(|value| !value.is_empty());
1063 if revised.is_some_and(|value| value.len() > MAX_REVISED_PROMPT_BYTES) {
1064 return Err(app_server_protocol_error(
1065 "Codex revised prompt exceeds the configured limit",
1066 ));
1067 }
1068 self.completed_item_ids.insert(item_id.to_owned());
1069 self.result_bytes = next_result_bytes;
1070 self.images.push(result.to_owned());
1071 if let Some(revised) = revised {
1072 self.revised_prompt = Some(revised.to_owned());
1073 }
1074 Ok(())
1075 }
1076}
1077
1078fn app_server_protocol_error(message: &str) -> BridgeError {
1079 BridgeError::new(ErrorCode::Protocol, message).with_provider("codex-app-server")
1080}
1081
1082struct TurnImages {
1083 images: Vec<String>,
1084 revised_prompt: Option<String>,
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089 #![allow(clippy::unwrap_used)]
1090
1091 use std::{fs, time::Duration};
1092
1093 #[cfg(unix)]
1094 use std::os::unix::fs::PermissionsExt as _;
1095
1096 use futures_util::{SinkExt as _, StreamExt as _};
1097 use tokio::io::duplex;
1098 use tokio_util::{
1099 codec::{Framed, LinesCodec},
1100 sync::CancellationToken,
1101 };
1102
1103 use super::*;
1104 use crate::RpcConfig;
1105
1106 const ONE_PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
1107
1108 #[test]
1109 fn turn_safety_failures_are_classified_without_reflecting_messages() {
1110 let mut request = ImageRequest::generate("safe fixture");
1111 request.parameters.moderation = Moderation::Auto;
1112 let error = turn_failure_error(
1113 &json!({
1114 "status": "failed",
1115 "error": {
1116 "code": "content_policy_violation",
1117 "message": "secret upstream prompt detail"
1118 }
1119 }),
1120 &request,
1121 );
1122 assert_eq!(error.code, ErrorCode::SafetyRejected);
1123 assert_eq!(error.details["recovery"], "revise_prompt_or_inputs");
1124 assert_eq!(error.details["requested_moderation"], "auto");
1125 assert!(!error.message.contains("secret"));
1126 }
1127
1128 #[test]
1129 fn turn_accumulator_bounds_count_identity_bytes_and_revised_prompt() {
1130 fn accumulator(max_images: usize, max_result_bytes: usize) -> TurnAccumulator {
1131 TurnAccumulator {
1132 images: Vec::new(),
1133 revised_prompt: None,
1134 completed_item_ids: HashSet::new(),
1135 result_bytes: 0,
1136 max_images,
1137 max_result_bytes,
1138 diagnostics: TurnDiagnostics::default(),
1139 }
1140 }
1141
1142 let first = json!({
1143 "id": "image-1",
1144 "result": "1234",
1145 "revisedPrompt": "safe"
1146 });
1147 let mut bounded = accumulator(1, 4);
1148 bounded.capture(&first).unwrap();
1149 assert_eq!(bounded.images, ["1234"]);
1150 assert!(
1151 bounded
1152 .capture(&first)
1153 .unwrap_err()
1154 .message
1155 .contains("duplicate")
1156 );
1157
1158 let second = json!({"id": "image-2", "result": "1"});
1159 assert!(
1160 bounded
1161 .capture(&second)
1162 .unwrap_err()
1163 .message
1164 .contains("more images")
1165 );
1166
1167 let mut bytes = accumulator(2, 3);
1168 assert!(
1169 bytes
1170 .capture(&first)
1171 .unwrap_err()
1172 .message
1173 .contains("byte limit")
1174 );
1175
1176 let mut revised = accumulator(1, 4);
1177 let revised_item = json!({
1178 "id": "image-1",
1179 "result": "1",
1180 "revisedPrompt": "x".repeat(MAX_REVISED_PROMPT_BYTES + 1)
1181 });
1182 assert!(
1183 revised
1184 .capture(&revised_item)
1185 .unwrap_err()
1186 .message
1187 .contains("revised prompt")
1188 );
1189 }
1190
1191 async fn rpc_and_server() -> (
1192 Arc<AppServerRpc>,
1193 Framed<tokio::io::DuplexStream, LinesCodec>,
1194 ) {
1195 let (client, server) = duplex(128 * 1024);
1196 let (reader, writer) = tokio::io::split(client);
1197 let mut server = Framed::new(server, LinesCodec::new());
1198 let connection = tokio::spawn(AppServerRpc::connect(
1199 reader,
1200 writer,
1201 RpcConfig {
1202 max_message_bytes: 128 * 1024,
1203 max_notification_bytes: 128 * 1024,
1204 request_timeout: Duration::from_secs(2),
1205 notification_capacity: 16,
1206 },
1207 ));
1208 let initialize = next_message(&mut server).await;
1209 server
1210 .send(json!({"id": initialize["id"], "result": {}}).to_string())
1211 .await
1212 .unwrap();
1213 let rpc = connection.await.unwrap().unwrap();
1214 assert_eq!(next_message(&mut server).await["method"], "initialized");
1215 (rpc, server)
1216 }
1217
1218 async fn next_message(server: &mut Framed<tokio::io::DuplexStream, LinesCodec>) -> Value {
1219 serde_json::from_str(&server.next().await.unwrap().unwrap()).unwrap()
1220 }
1221
1222 fn request() -> ImageRequest {
1223 let mut request = ImageRequest::generate("a tiny blue square");
1224 request.session.mode = SessionMode::Persistent;
1225 request.session.key = Some("gallery".to_owned());
1226 request
1227 }
1228
1229 fn context(id: &str) -> ProviderContext {
1230 ProviderContext {
1231 request_id: id.to_owned(),
1232 deadline: Instant::now() + Duration::from_secs(2),
1233 cancellation: CancellationToken::new(),
1234 events: None,
1235 }
1236 }
1237
1238 async fn complete_turn(
1239 server: &mut Framed<tokio::io::DuplexStream, LinesCodec>,
1240 thread_id: &str,
1241 turn_id: &str,
1242 ) {
1243 server
1244 .send(
1245 json!({
1246 "method": "item/completed",
1247 "params": {
1248 "threadId": thread_id,
1249 "turnId": turn_id,
1250 "completedAtMs": 1,
1251 "item": {
1252 "type": "imageGeneration",
1253 "id": "image-1",
1254 "status": "completed",
1255 "revisedPrompt": "a tiny blue square",
1256 "result": ONE_PIXEL_PNG
1257 }
1258 }
1259 })
1260 .to_string(),
1261 )
1262 .await
1263 .unwrap();
1264 server
1265 .send(
1266 json!({
1267 "method": "turn/completed",
1268 "params": {
1269 "threadId": thread_id,
1270 "turn": {"id": turn_id, "status": "completed", "items": []}
1271 }
1272 })
1273 .to_string(),
1274 )
1275 .await
1276 .unwrap();
1277 }
1278
1279 #[tokio::test]
1280 async fn persistent_requests_start_once_then_resume_the_same_thread() {
1281 let (rpc, mut server) = rpc_and_server().await;
1282 let provider = Arc::new(AppServerImageProvider::new(
1283 rpc,
1284 Arc::new(MemorySessionBindingStore::default()),
1285 AppServerProviderConfig {
1286 codex_model: None,
1287 cwd: PathBuf::from("/tmp"),
1288 image_limits: ImageLimits::default(),
1289 },
1290 ));
1291
1292 let first = tokio::spawn({
1293 let provider = Arc::clone(&provider);
1294 async move { provider.execute(request(), context("request-1")).await }
1295 });
1296 let start = next_message(&mut server).await;
1297 assert_eq!(start["method"], "thread/start");
1298 assert_eq!(start["params"]["ephemeral"], false);
1299 server
1300 .send(json!({"id": start["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1301 .await
1302 .unwrap();
1303 let turn = next_message(&mut server).await;
1304 assert_eq!(turn["method"], "turn/start");
1305 assert_eq!(turn["params"]["threadId"], "thread-1");
1306 server
1307 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-1"}}}).to_string())
1308 .await
1309 .unwrap();
1310 complete_turn(&mut server, "thread-1", "turn-1").await;
1311 let first = first.await.unwrap().unwrap();
1312 assert!(!first.session.unwrap().reused);
1313 assert_eq!(first.data.len(), 1);
1314
1315 let second = tokio::spawn({
1316 let provider = Arc::clone(&provider);
1317 async move { provider.execute(request(), context("request-2")).await }
1318 });
1319 let resume = next_message(&mut server).await;
1320 assert_eq!(resume["method"], "thread/resume");
1321 assert_eq!(resume["params"]["threadId"], "thread-1");
1322 server
1323 .send(json!({"id": resume["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1324 .await
1325 .unwrap();
1326 let turn = next_message(&mut server).await;
1327 server
1328 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-2"}}}).to_string())
1329 .await
1330 .unwrap();
1331 complete_turn(&mut server, "thread-1", "turn-2").await;
1332 let second = second.await.unwrap().unwrap();
1333 assert!(second.session.unwrap().reused);
1334 }
1335
1336 #[tokio::test]
1337 async fn stages_inline_references_under_bridge_owned_storage() {
1338 let (rpc, _server) = rpc_and_server().await;
1339 let directory = tempfile::tempdir().unwrap();
1340 let input_root = directory.path().join("inputs");
1341 let staging_root = directory.path().join("staging");
1342 fs::create_dir(&input_root).unwrap();
1343 let limits = ImageLimits::default();
1344 let reference_inputs = AppServerReferenceInputs::new(
1345 Arc::new(InputLoader::new([input_root], limits).unwrap()),
1346 None,
1347 Arc::new(ArtifactStore::new(&staging_root, limits).unwrap()),
1348 1024 * 1024,
1349 )
1350 .unwrap();
1351 let provider = AppServerImageProvider::new_with_inputs(
1352 rpc,
1353 Arc::new(MemorySessionBindingStore::default()),
1354 AppServerProviderConfig {
1355 codex_model: None,
1356 cwd: directory.path().to_owned(),
1357 image_limits: limits,
1358 },
1359 reference_inputs,
1360 );
1361 let mut request = request();
1362 request.operation = ImageOperation::Generate {
1363 reference_images: vec![imagegen_bridge_core::ImageInput {
1364 source: imagegen_bridge_core::ImageSource::Base64 {
1365 data: ONE_PIXEL_PNG.to_owned(),
1366 },
1367 media_type: Some("image/png".to_owned()),
1368 filename: Some("visual reference.png".to_owned()),
1369 }],
1370 };
1371
1372 let paths = provider.reference_paths(&request).await.unwrap();
1373 assert_eq!(paths.len(), 1);
1374 assert!(paths[0].starts_with(staging_root.canonicalize().unwrap()));
1375 assert!(paths[0].is_file());
1376 assert!(!paths[0].to_string_lossy().contains("visual reference"));
1377 }
1378
1379 #[tokio::test]
1380 async fn rejects_references_when_secure_staging_is_not_configured() {
1381 let (rpc, _server) = rpc_and_server().await;
1382 let provider = AppServerImageProvider::new(
1383 rpc,
1384 Arc::new(MemorySessionBindingStore::default()),
1385 AppServerProviderConfig {
1386 codex_model: None,
1387 cwd: PathBuf::from("/tmp"),
1388 image_limits: ImageLimits::default(),
1389 },
1390 );
1391 let mut request = request();
1392 request.operation = ImageOperation::Generate {
1393 reference_images: vec![imagegen_bridge_core::ImageInput {
1394 source: imagegen_bridge_core::ImageSource::File {
1395 path: PathBuf::from("outside.png"),
1396 },
1397 media_type: None,
1398 filename: None,
1399 }],
1400 };
1401 let error = provider.reference_paths(&request).await.unwrap_err();
1402 assert_eq!(error.code, ErrorCode::Input);
1403 }
1404
1405 #[tokio::test]
1406 async fn session_lifecycle_is_visible_without_exposing_other_state() {
1407 let (rpc, _server) = rpc_and_server().await;
1408 let sessions = Arc::new(MemorySessionBindingStore::default());
1409 sessions.put("gallery", "thread-1").await.unwrap();
1410 let provider = AppServerImageProvider::new(
1411 rpc,
1412 sessions,
1413 AppServerProviderConfig {
1414 codex_model: None,
1415 cwd: PathBuf::from("/tmp"),
1416 image_limits: ImageLimits::default(),
1417 },
1418 );
1419 let session = provider.get_session("gallery").await.unwrap();
1420 assert_eq!(session.key.as_deref(), Some("gallery"));
1421 assert_eq!(session.thread_id.as_deref(), Some("thread-1"));
1422 provider.delete_session("gallery").await.unwrap();
1423 assert_eq!(
1424 provider.get_session("gallery").await.unwrap_err().code,
1425 ErrorCode::Session
1426 );
1427 }
1428
1429 #[tokio::test]
1430 async fn waiting_on_the_same_session_is_cancellable_without_starting_a_turn() {
1431 let (rpc, mut server) = rpc_and_server().await;
1432 let provider = Arc::new(AppServerImageProvider::new(
1433 rpc,
1434 Arc::new(MemorySessionBindingStore::default()),
1435 AppServerProviderConfig {
1436 codex_model: None,
1437 cwd: PathBuf::from("/tmp"),
1438 image_limits: ImageLimits::default(),
1439 },
1440 ));
1441 let first = tokio::spawn({
1442 let provider = Arc::clone(&provider);
1443 async move { provider.execute(request(), context("first")).await }
1444 });
1445 let start = next_message(&mut server).await;
1446 server
1447 .send(json!({"id": start["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1448 .await
1449 .unwrap();
1450 let turn = next_message(&mut server).await;
1451 server
1452 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-1"}}}).to_string())
1453 .await
1454 .unwrap();
1455
1456 let cancellation = CancellationToken::new();
1457 let second = tokio::spawn({
1458 let provider = Arc::clone(&provider);
1459 let cancellation = cancellation.clone();
1460 async move {
1461 provider
1462 .execute(
1463 request(),
1464 ProviderContext {
1465 request_id: "second".to_owned(),
1466 deadline: Instant::now() + Duration::from_secs(2),
1467 cancellation,
1468 events: None,
1469 },
1470 )
1471 .await
1472 }
1473 });
1474 tokio::task::yield_now().await;
1475 cancellation.cancel();
1476 let error = second.await.unwrap().unwrap_err();
1477 assert_eq!(error.code, ErrorCode::Cancelled);
1478
1479 complete_turn(&mut server, "thread-1", "turn-1").await;
1480 assert!(first.await.unwrap().is_ok());
1481 }
1482
1483 #[cfg(unix)]
1484 #[tokio::test]
1485 async fn process_restart_resumes_the_durable_thread_without_starting_a_new_chat() {
1486 let directory = tempfile::tempdir().unwrap();
1487 let script = directory.path().join("fake-codex");
1488 let state = directory.path().join("started-once");
1489 let source = format!(
1490 r#"#!/bin/sh
1491STATE='{}'
1492if [ -e "$STATE" ]; then
1493 RUN=2
1494else
1495 : > "$STATE"
1496 RUN=1
1497fi
1498while IFS= read -r LINE; do
1499 case "$LINE" in
1500 *'"method":"initialize"'*)
1501 printf '%s\n' '{{"id":1,"result":{{}}}}'
1502 ;;
1503 *'"method":"thread/resume"'*)
1504 printf '%s\n' '{{"id":2,"result":{{"thread":{{"id":"thread-1"}}}}}}'
1505 ;;
1506 *'"method":"turn/start"'*)
1507 if [ "$RUN" -eq 1 ]; then
1508 exit 17
1509 fi
1510 printf '%s\n' '{{"id":3,"result":{{"turn":{{"id":"turn-2"}}}}}}'
1511 printf '%s\n' '{{"method":"item/completed","params":{{"threadId":"thread-1","turnId":"turn-2","item":{{"type":"imageGeneration","id":"image-1","status":"completed","revisedPrompt":"resumed prompt","result":"{}"}}}}}}'
1512 printf '%s\n' '{{"method":"turn/completed","params":{{"threadId":"thread-1","turn":{{"id":"turn-2","status":"completed","items":[]}}}}}}'
1513 ;;
1514 esac
1515done
1516"#,
1517 state.display(),
1518 ONE_PIXEL_PNG
1519 );
1520 fs::write(&script, source).unwrap();
1521 let mut permissions = fs::metadata(&script).unwrap().permissions();
1522 permissions.set_mode(0o700);
1523 fs::set_permissions(&script, permissions).unwrap();
1524
1525 let process = Arc::new(
1526 CodexProcess::spawn(crate::CodexProcessConfig {
1527 executable: script,
1528 cwd: Some(directory.path().to_owned()),
1529 rpc: RpcConfig {
1530 request_timeout: Duration::from_secs(2),
1531 ..RpcConfig::default()
1532 },
1533 restart_backoff: Duration::ZERO,
1534 ..crate::CodexProcessConfig::default()
1535 })
1536 .await
1537 .unwrap(),
1538 );
1539 let sessions = Arc::new(MemorySessionBindingStore::default());
1540 sessions.put("gallery", "thread-1").await.unwrap();
1541 let provider = AppServerImageProvider::with_process(
1542 Arc::clone(&process),
1543 sessions,
1544 AppServerProviderConfig {
1545 codex_model: None,
1546 cwd: directory.path().to_owned(),
1547 image_limits: ImageLimits::default(),
1548 },
1549 );
1550
1551 let first = provider
1552 .execute(request(), context("crashing-request"))
1553 .await
1554 .unwrap_err();
1555 assert_eq!(first.code, ErrorCode::Protocol);
1556 let second = provider
1557 .execute(request(), context("resumed-request"))
1558 .await
1559 .unwrap();
1560 let session = second.session.unwrap();
1561 assert!(session.reused);
1562 assert_eq!(session.thread_id.as_deref(), Some("thread-1"));
1563 assert_eq!(process.generation(), 2);
1564 provider.shutdown().await.unwrap();
1565 }
1566
1567 #[tokio::test]
1568 async fn deadline_interrupts_an_active_turn() {
1569 let (rpc, mut server) = rpc_and_server().await;
1570 let provider = Arc::new(AppServerImageProvider::new(
1571 rpc,
1572 Arc::new(MemorySessionBindingStore::default()),
1573 AppServerProviderConfig {
1574 codex_model: None,
1575 cwd: PathBuf::from("/tmp"),
1576 image_limits: ImageLimits::default(),
1577 },
1578 ));
1579 let execution = tokio::spawn({
1580 let provider = Arc::clone(&provider);
1581 async move {
1582 provider
1583 .execute(
1584 ImageRequest::generate("timeout"),
1585 ProviderContext {
1586 request_id: "timeout".to_owned(),
1587 deadline: Instant::now() + Duration::from_millis(20),
1588 cancellation: CancellationToken::new(),
1589 events: None,
1590 },
1591 )
1592 .await
1593 }
1594 });
1595 let start = next_message(&mut server).await;
1596 assert_eq!(start["method"], "thread/start");
1597 server
1598 .send(
1599 json!({"id": start["id"], "result": {"thread": {"id": "thread-timeout"}}})
1600 .to_string(),
1601 )
1602 .await
1603 .unwrap();
1604 let turn = next_message(&mut server).await;
1605 server
1606 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-timeout"}}}).to_string())
1607 .await
1608 .unwrap();
1609 let interrupt = next_message(&mut server).await;
1610 assert_eq!(interrupt["method"], "turn/interrupt");
1611 server
1612 .send(json!({"id": interrupt["id"], "result": {}}).to_string())
1613 .await
1614 .unwrap();
1615 server
1616 .send(
1617 json!({
1618 "method": "turn/completed",
1619 "params": {
1620 "threadId": "thread-timeout",
1621 "turn": {"id": "turn-timeout", "status": "interrupted"}
1622 }
1623 })
1624 .to_string(),
1625 )
1626 .await
1627 .unwrap();
1628 let archive = next_message(&mut server).await;
1629 assert_eq!(archive["method"], "thread/archive");
1630 server
1631 .send(json!({"id": archive["id"], "result": {}}).to_string())
1632 .await
1633 .unwrap();
1634 let error = execution.await.unwrap().unwrap_err();
1635 assert_eq!(error.code, ErrorCode::Timeout);
1636 assert!(error.retryable);
1637 assert_eq!(error.details["outcome"], "cancelled");
1638 }
1639
1640 #[tokio::test]
1641 async fn cancellation_interrupts_an_active_turn() {
1642 let (rpc, mut server) = rpc_and_server().await;
1643 let provider = Arc::new(AppServerImageProvider::new(
1644 rpc,
1645 Arc::new(MemorySessionBindingStore::default()),
1646 AppServerProviderConfig {
1647 codex_model: None,
1648 cwd: PathBuf::from("/tmp"),
1649 image_limits: ImageLimits::default(),
1650 },
1651 ));
1652 let cancellation = CancellationToken::new();
1653 let execution = tokio::spawn({
1654 let provider = Arc::clone(&provider);
1655 let cancellation = cancellation.clone();
1656 async move {
1657 provider
1658 .execute(
1659 ImageRequest::generate("cancel"),
1660 ProviderContext {
1661 request_id: "cancel".to_owned(),
1662 deadline: Instant::now() + Duration::from_secs(2),
1663 cancellation,
1664 events: None,
1665 },
1666 )
1667 .await
1668 }
1669 });
1670 let start = next_message(&mut server).await;
1671 server
1672 .send(
1673 json!({"id": start["id"], "result": {"thread": {"id": "thread-cancel"}}})
1674 .to_string(),
1675 )
1676 .await
1677 .unwrap();
1678 let turn = next_message(&mut server).await;
1679 server
1680 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-cancel"}}}).to_string())
1681 .await
1682 .unwrap();
1683 tokio::time::sleep(Duration::from_millis(5)).await;
1684 cancellation.cancel();
1685 let interrupt = next_message(&mut server).await;
1686 assert_eq!(interrupt["method"], "turn/interrupt");
1687 assert_eq!(interrupt["params"]["threadId"], "thread-cancel");
1688 server
1689 .send(json!({"id": interrupt["id"], "result": {}}).to_string())
1690 .await
1691 .unwrap();
1692 server
1693 .send(
1694 json!({
1695 "method": "turn/completed",
1696 "params": {
1697 "threadId": "thread-cancel",
1698 "turn": {"id": "turn-cancel", "status": "interrupted"}
1699 }
1700 })
1701 .to_string(),
1702 )
1703 .await
1704 .unwrap();
1705 let archive = next_message(&mut server).await;
1706 assert_eq!(archive["method"], "thread/archive");
1707 server
1708 .send(json!({"id": archive["id"], "result": {}}).to_string())
1709 .await
1710 .unwrap();
1711 let error = execution.await.unwrap().unwrap_err();
1712 assert_eq!(error.code, ErrorCode::Cancelled);
1713 assert!(!error.retryable);
1714 assert_eq!(error.details["outcome"], "cancelled");
1715 }
1716
1717 #[tokio::test]
1718 async fn completed_turn_without_a_valid_image_is_rejected() {
1719 let (rpc, mut server) = rpc_and_server().await;
1720 let provider = Arc::new(AppServerImageProvider::new(
1721 rpc,
1722 Arc::new(MemorySessionBindingStore::default()),
1723 AppServerProviderConfig {
1724 codex_model: None,
1725 cwd: PathBuf::from("/tmp"),
1726 image_limits: ImageLimits::default(),
1727 },
1728 ));
1729 let execution = tokio::spawn({
1730 let provider = Arc::clone(&provider);
1731 async move {
1732 provider
1733 .execute(ImageRequest::generate("missing"), context("missing"))
1734 .await
1735 }
1736 });
1737 let start = next_message(&mut server).await;
1738 server
1739 .send(
1740 json!({"id": start["id"], "result": {"thread": {"id": "thread-missing"}}})
1741 .to_string(),
1742 )
1743 .await
1744 .unwrap();
1745 let turn = next_message(&mut server).await;
1746 server
1747 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-missing"}}}).to_string())
1748 .await
1749 .unwrap();
1750 server
1751 .send(
1752 json!({
1753 "method": "item/completed",
1754 "params": {
1755 "threadId": "thread-missing",
1756 "turnId": "turn-missing",
1757 "item": {
1758 "type": "agentMessage",
1759 "id": "message-1",
1760 "status": "completed",
1761 "text": "secret upstream prose must not enter diagnostics"
1762 }
1763 }
1764 })
1765 .to_string(),
1766 )
1767 .await
1768 .unwrap();
1769 server
1770 .send(
1771 json!({
1772 "method": "turn/completed",
1773 "params": {
1774 "threadId": "thread-missing",
1775 "turn": {"id": "turn-missing", "status": "completed", "items": []}
1776 }
1777 })
1778 .to_string(),
1779 )
1780 .await
1781 .unwrap();
1782 let archive = next_message(&mut server).await;
1783 server
1784 .send(json!({"id": archive["id"], "result": {}}).to_string())
1785 .await
1786 .unwrap();
1787 let error = execution.await.unwrap().unwrap_err();
1788 assert_eq!(error.code, ErrorCode::Upstream);
1789 assert_eq!(error.details["turn_diagnostics"]["completed_items"], 1);
1790 assert_eq!(
1791 error.details["turn_diagnostics"]["item_types"]["agentMessage"],
1792 1
1793 );
1794 assert_eq!(error.details["turn_diagnostics"]["image_items"], 0);
1795 assert!(
1796 !serde_json::to_string(&error)
1797 .unwrap()
1798 .contains("secret upstream prose")
1799 );
1800 }
1801
1802 #[tokio::test]
1803 #[ignore = "requires authenticated Codex OAuth and performs a real image generation"]
1804 async fn live_codex_generates_a_verified_image() {
1805 if std::env::var("IMAGEGEN_BRIDGE_LIVE_CODEX").as_deref() != Ok("1") {
1806 return;
1807 }
1808 let process = Arc::new(
1809 CodexProcess::spawn(crate::CodexProcessConfig {
1810 rpc: RpcConfig {
1811 request_timeout: Duration::from_secs(30),
1812 ..RpcConfig::default()
1813 },
1814 ..crate::CodexProcessConfig::default()
1815 })
1816 .await
1817 .unwrap(),
1818 );
1819 let provider = AppServerImageProvider::with_process(
1820 Arc::clone(&process),
1821 Arc::new(MemorySessionBindingStore::default()),
1822 AppServerProviderConfig {
1823 codex_model: None,
1824 cwd: std::env::current_dir().unwrap(),
1825 image_limits: ImageLimits::default(),
1826 },
1827 );
1828 provider.check_ready().await.unwrap();
1829 let mut live_request = ImageRequest::generate(
1830 "A single cobalt-blue circle centered on a plain white background",
1831 );
1832 live_request.session.mode = SessionMode::Persistent;
1833 live_request.session.key = Some("live-persistent-session".to_owned());
1834 live_request.parameters.action = ImageAction::Generate;
1835 let first = provider
1836 .execute(
1837 live_request.clone(),
1838 ProviderContext {
1839 request_id: "live-test-1".to_owned(),
1840 deadline: Instant::now() + Duration::from_secs(240),
1841 cancellation: CancellationToken::new(),
1842 events: None,
1843 },
1844 )
1845 .await
1846 .unwrap();
1847 assert_eq!(first.data.len(), 1);
1848 assert!(first.data[0].bytes > 0);
1849 assert!(first.normalizations.iter().any(|normalization| {
1850 normalization.field == "parameters.action"
1851 && normalization.reason == "provider_uses_automatic_action"
1852 }));
1853 let first_session = first.session.unwrap();
1854 assert!(!first_session.reused);
1855
1856 let second = provider
1857 .execute(
1858 live_request,
1859 ProviderContext {
1860 request_id: "live-test-2".to_owned(),
1861 deadline: Instant::now() + Duration::from_secs(240),
1862 cancellation: CancellationToken::new(),
1863 events: None,
1864 },
1865 )
1866 .await
1867 .unwrap();
1868 assert_eq!(second.data.len(), 1);
1869 assert!(second.data[0].bytes > 0);
1870 let second_session = second.session.unwrap();
1871 assert!(second_session.reused);
1872 assert_eq!(first_session.thread_id, second_session.thread_id);
1873 provider.shutdown().await.unwrap();
1874 }
1875}