1use std::{
4 collections::{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 };
447 loop {
448 let remaining = context.deadline.saturating_duration_since(Instant::now());
449 let notification = tokio::select! {
450 result = notifications.recv() => result.map_err(|error| notification_error(&error))?,
451 () = context.cancellation.cancelled() => {
452 return Err(self
453 .interrupt_outcome(
454 rpc,
455 &mut notifications,
456 thread_id,
457 &turn_id,
458 ErrorCode::Cancelled,
459 "image generation was cancelled",
460 )
461 .await);
462 }
463 () = tokio::time::sleep(remaining) => {
464 return Err(self
465 .interrupt_outcome(
466 rpc,
467 &mut notifications,
468 thread_id,
469 &turn_id,
470 ErrorCode::Timeout,
471 "image generation timed out",
472 )
473 .await);
474 }
475 };
476 if notification.method == "item/completed"
477 && belongs_to(¬ification.params, thread_id, &turn_id)
478 && notification.params["item"]["type"] == "imageGeneration"
479 {
480 let item = ¬ification.params["item"];
481 if item["status"] == "completed"
482 && let Err(error) = accumulator.capture(item)
483 {
484 return Err(self
485 .protocol_failure_outcome(
486 rpc,
487 &mut notifications,
488 thread_id,
489 &turn_id,
490 error,
491 )
492 .await);
493 }
494 }
495 if notification.method == "turn/completed"
496 && belongs_to(¬ification.params, thread_id, &turn_id)
497 {
498 if notification.params["turn"]["status"] != "completed" {
499 return Err(turn_failure_error(¬ification.params["turn"], request));
500 }
501 if accumulator.images.is_empty() {
502 return Err(BridgeError::new(
503 ErrorCode::Upstream,
504 "Codex turn completed without an image",
505 )
506 .with_provider("codex-app-server"));
507 }
508 return Ok(TurnImages {
509 images: accumulator.images,
510 revised_prompt: accumulator.revised_prompt,
511 });
512 }
513 }
514 }
515
516 async fn reference_paths(&self, request: &ImageRequest) -> Result<Vec<PathBuf>, BridgeError> {
517 let inputs = request_images(request);
518 if inputs.is_empty() {
519 return Ok(Vec::new());
520 }
521 let materializer = self.reference_inputs.as_ref().ok_or_else(|| {
522 BridgeError::new(
523 ErrorCode::Input,
524 "app-server reference inputs require a configured secure staging store",
525 )
526 })?;
527 let mut total = 0_u64;
528 let mut paths = Vec::with_capacity(inputs.len());
529 for input in inputs {
530 let loaded = match &input.source {
531 imagegen_bridge_core::ImageSource::Url { url } => {
532 materializer
533 .remote_fetcher
534 .as_ref()
535 .ok_or_else(|| {
536 BridgeError::new(ErrorCode::Input, "remote image inputs are disabled")
537 })?
538 .fetch(url)
539 .await?
540 }
541 _ => materializer.loader.load(&input)?,
542 };
543 total = total.checked_add(loaded.metadata.bytes).ok_or_else(|| {
544 BridgeError::new(ErrorCode::Input, "reference image bytes overflowed")
545 })?;
546 if total > materializer.max_aggregate_bytes {
547 return Err(BridgeError::new(
548 ErrorCode::Input,
549 "aggregate reference images exceed the configured byte limit",
550 ));
551 }
552 let stored = materializer.staging_store.publish(
553 &loaded.bytes,
554 input.filename.as_deref().or(Some("reference")),
555 Some(loaded.metadata.format),
556 )?;
557 paths.push(stored.path);
558 }
559 Ok(paths)
560 }
561
562 async fn interrupt_and_confirm(
563 &self,
564 rpc: &AppServerRpc,
565 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
566 thread_id: &str,
567 turn_id: &str,
568 ) -> Result<String, BridgeError> {
569 let cleanup_deadline = Instant::now() + TURN_CLEANUP_GRACE;
570 rpc.request_side_effecting_until(
571 "turn/interrupt",
572 json!({"threadId": thread_id, "turnId": turn_id}),
573 cleanup_deadline,
574 tokio_util::sync::CancellationToken::new(),
575 )
576 .await?;
577 loop {
578 let remaining = cleanup_deadline.saturating_duration_since(Instant::now());
579 let notification = tokio::time::timeout(remaining, notifications.recv())
580 .await
581 .map_err(|_| {
582 unknown_app_server_outcome(
583 ErrorCode::Timeout,
584 "turn interruption was not confirmed",
585 )
586 })?
587 .map_err(|error| notification_error(&error))?;
588 if notification.method == "turn/completed"
589 && belongs_to(¬ification.params, thread_id, turn_id)
590 {
591 return Ok(notification.params["turn"]["status"]
592 .as_str()
593 .and_then(safe_failure_label)
594 .unwrap_or("unknown")
595 .to_owned());
596 }
597 }
598 }
599
600 async fn interrupt_outcome(
601 &self,
602 rpc: &AppServerRpc,
603 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
604 thread_id: &str,
605 turn_id: &str,
606 code: ErrorCode,
607 message: &str,
608 ) -> BridgeError {
609 match self
610 .interrupt_and_confirm(rpc, notifications, thread_id, turn_id)
611 .await
612 {
613 Ok(status) if matches!(status.as_str(), "interrupted" | "cancelled" | "failed") => {
614 BridgeError::new(code, message)
615 .with_provider("codex-app-server")
616 .retryable(code == ErrorCode::Timeout)
617 .with_detail("outcome", "cancelled")
618 .with_detail("terminal_status", status)
619 }
620 Ok(status) => {
621 unknown_app_server_outcome(code, message).with_detail("terminal_status", status)
622 }
623 Err(_) => unknown_app_server_outcome(code, message),
624 }
625 }
626
627 async fn protocol_failure_outcome(
628 &self,
629 rpc: &AppServerRpc,
630 notifications: &mut broadcast::Receiver<crate::RpcNotification>,
631 thread_id: &str,
632 turn_id: &str,
633 error: BridgeError,
634 ) -> BridgeError {
635 match self
636 .interrupt_and_confirm(rpc, notifications, thread_id, turn_id)
637 .await
638 {
639 Ok(status) if matches!(status.as_str(), "interrupted" | "cancelled" | "failed") => {
640 error.with_detail("terminal_status", status)
641 }
642 Ok(status) => error
643 .retryable(false)
644 .with_detail("outcome", "unknown")
645 .with_detail("terminal_status", status),
646 Err(_) => error.retryable(false).with_detail("outcome", "unknown"),
647 }
648 }
649
650 fn normalized_image(&self, encoded: &str) -> Result<GeneratedImage, BridgeError> {
651 let bytes = STANDARD.decode(encoded.trim()).map_err(|_| {
652 BridgeError::new(
653 ErrorCode::Protocol,
654 "Codex returned malformed base64 image data",
655 )
656 })?;
657 let metadata =
658 inspect_image(&bytes, self.config.image_limits).map_err(|error| BridgeError {
659 code: ErrorCode::Protocol,
660 provider: Some("codex-app-server".to_owned()),
661 ..error
662 })?;
663 Ok(GeneratedImage {
664 index: 0,
665 payload: ImagePayload::B64Json {
666 b64_json: encoded.to_owned(),
667 },
668 format: metadata.format,
669 width: metadata.width,
670 height: metadata.height,
671 bytes: metadata.bytes,
672 sha256: metadata.sha256,
673 generation_ms: None,
674 metadata_name: None,
675 })
676 }
677}
678
679#[async_trait]
680impl ImageProvider for AppServerImageProvider {
681 fn descriptor(&self) -> ProviderDescriptor {
682 ProviderDescriptor {
683 name: "codex-app-server".to_owned(),
684 display_name: "Codex app-server".to_owned(),
685 version: env!("CARGO_PKG_VERSION").to_owned(),
686 experimental: false,
687 models: vec!["gpt-image-2".to_owned()],
688 }
689 }
690
691 async fn capabilities(&self, model: Option<&str>) -> Result<ProviderCapabilities, BridgeError> {
692 let capabilities = capabilities();
693 if let Some(model) = model
694 && capabilities.model.as_deref() != Some(model)
695 {
696 return Err(BridgeError::new(
697 ErrorCode::UnsupportedCapability,
698 "Codex app-server image generation only exposes gpt-image-2",
699 )
700 .with_provider("codex-app-server")
701 .with_detail("field", "routing.model")
702 .with_detail("requested_model", model)
703 .with_detail("effective_model", "gpt-image-2"));
704 }
705 Ok(capabilities)
706 }
707
708 async fn execute(
709 &self,
710 request: ImageRequest,
711 context: ProviderContext,
712 ) -> Result<ImageResponse, BridgeError> {
713 self.execute_inner(request, context).await
714 }
715
716 async fn check_ready(&self) -> Result<(), BridgeError> {
717 let account = self
718 .connection()
719 .await?
720 .request("account/read", json!({}))
721 .await?;
722 if account["account"].is_null() {
723 return Err(BridgeError::new(
724 ErrorCode::Authentication,
725 "Codex app-server requires authentication",
726 )
727 .with_provider("codex-app-server"));
728 }
729 Ok(())
730 }
731
732 async fn get_session(&self, key: &str) -> Result<SessionMetadata, BridgeError> {
733 let thread_id = self.sessions.get(key).await?.ok_or_else(|| {
734 BridgeError::new(ErrorCode::Session, "persistent session was not found")
735 .with_provider("codex-app-server")
736 })?;
737 Ok(SessionMetadata {
738 key: Some(key.to_owned()),
739 thread_id: Some(thread_id),
740 reused: true,
741 })
742 }
743
744 async fn delete_session(&self, key: &str) -> Result<(), BridgeError> {
745 if self.sessions.get(key).await?.is_none() {
746 return Err(
747 BridgeError::new(ErrorCode::Session, "persistent session was not found")
748 .with_provider("codex-app-server"),
749 );
750 }
751 self.sessions.delete(key).await
752 }
753
754 fn restart_count(&self) -> Option<u64> {
755 self.process
756 .as_ref()
757 .map(|process| process.generation().saturating_sub(1))
758 }
759
760 async fn shutdown(&self) -> Result<(), BridgeError> {
761 if let Some(process) = &self.process {
762 process.shutdown().await?;
763 }
764 Ok(())
765 }
766}
767
768fn capabilities() -> ProviderCapabilities {
769 let no_input = InputCapabilities {
770 support: SupportLevel::Unsupported,
771 max_count: 0,
772 max_bytes_each: 0,
773 max_bytes_total: 0,
774 };
775 let references = InputCapabilities {
776 support: SupportLevel::Native,
777 max_count: 5,
778 max_bytes_each: 32 * 1024 * 1024,
779 max_bytes_total: 64 * 1024 * 1024,
780 };
781 ProviderCapabilities {
782 provider: "codex-app-server".to_owned(),
783 implementation_version: env!("CARGO_PKG_VERSION").to_owned(),
784 model: Some("gpt-image-2".to_owned()),
785 experimental: false,
786 generation: true,
787 edits: true,
788 count: U8Range { min: 1, max: 1 },
789 batching: BatchCapabilities {
790 mode: BatchMode::Native,
791 native_count: U8Range { min: 1, max: 1 },
792 max_parallel_outputs: 1,
793 },
794 sizes: SizeCapabilities {
795 auto: true,
796 allowed: BTreeSet::new(),
797 arbitrary: false,
798 min_edge: None,
799 max_edge: None,
800 edge_multiple: None,
801 min_pixels: None,
802 max_pixels: None,
803 max_aspect_ratio: None,
804 },
805 aspect_ratio: SupportLevel::Unsupported,
806 resolution: SupportLevel::Unsupported,
807 qualities: BTreeSet::from([Quality::Auto]),
808 output_formats: BTreeSet::from([OutputFormat::Png]),
809 backgrounds: BTreeSet::from([Background::Auto]),
810 transparent_background: SupportLevel::Emulated,
811 moderation: BTreeSet::from([Moderation::Auto]),
812 negative_prompt: SupportLevel::Emulated,
813 revised_prompt: SupportLevel::Emulated,
814 user_attribution: SupportLevel::Unsupported,
815 input_fidelities: BTreeSet::from([InputFidelity::High]),
816 actions: BTreeSet::from([ImageAction::Auto]),
817 reference_images: references.clone(),
818 edit_images: references,
819 masks: no_input,
820 partial_images: U8Range { min: 0, max: 0 },
821 persistent_sessions: true,
822 explicit_threads: true,
823 }
824}
825
826fn request_images(request: &ImageRequest) -> Vec<imagegen_bridge_core::ImageInput> {
827 match &request.operation {
828 ImageOperation::Generate { reference_images } => reference_images.clone(),
829 ImageOperation::Edit {
830 images,
831 reference_images,
832 ..
833 } => images.iter().chain(reference_images).cloned().collect(),
834 }
835}
836
837fn turn_prompt(prompt: &str, paths: &[PathBuf]) -> String {
838 if paths.is_empty() {
839 format!("Generate this image now with image_gen.imagegen: {prompt}")
840 } else {
841 let paths = paths
842 .iter()
843 .map(|path| path.display().to_string())
844 .collect::<Vec<_>>()
845 .join(", ");
846 format!(
847 "Generate or edit this image now with image_gen.imagegen. Use referenced_image_paths exactly as listed: [{paths}]. Prompt: {prompt}"
848 )
849 }
850}
851
852fn string_at(value: &Value, path: &[&str], message: &str) -> Result<String, BridgeError> {
853 let mut value = value;
854 for component in path {
855 value = &value[*component];
856 }
857 value.as_str().map(str::to_owned).ok_or_else(|| {
858 BridgeError::new(ErrorCode::Protocol, message).with_provider("codex-app-server")
859 })
860}
861
862fn belongs_to(params: &Value, thread_id: &str, turn_id: &str) -> bool {
863 params["threadId"] == thread_id
864 && (params["turnId"] == turn_id || params["turn"]["id"] == turn_id)
865}
866
867fn notification_error(error: &broadcast::error::RecvError) -> BridgeError {
868 match error {
869 broadcast::error::RecvError::Closed => {
870 BridgeError::new(ErrorCode::Protocol, "app-server notification stream closed")
871 }
872 broadcast::error::RecvError::Lagged(_) => BridgeError::new(
873 ErrorCode::Overloaded,
874 "app-server notification consumer lagged behind",
875 ),
876 }
877 .with_provider("codex-app-server")
878}
879
880fn turn_failure_error(turn: &Value, request: &ImageRequest) -> BridgeError {
881 let upstream_code = turn["error"]["code"]
882 .as_str()
883 .or_else(|| turn["errorCode"].as_str())
884 .and_then(safe_failure_label);
885 let upstream_status = turn["status"].as_str().and_then(safe_failure_label);
886 let safety_rejected = upstream_code.is_some_and(is_safety_failure_label)
887 || upstream_status.is_some_and(is_safety_failure_label);
888 let mut error = if safety_rejected {
889 BridgeError::safety_rejected("Codex app-server rejected the image request")
890 .with_detail("requested_moderation", request.parameters.moderation)
891 .with_detail("input_images_present", !request_images(request).is_empty())
892 } else {
893 BridgeError::new(
894 ErrorCode::Upstream,
895 "Codex image generation turn did not complete successfully",
896 )
897 }
898 .with_provider("codex-app-server");
899 if let Some(code) = upstream_code {
900 error = error.with_detail("upstream_code", code);
901 }
902 if let Some(status) = upstream_status {
903 error = error.with_detail("upstream_status", status);
904 }
905 error
906}
907
908fn safe_failure_label(value: &str) -> Option<&str> {
909 (!value.is_empty()
910 && value.len() <= 64
911 && value
912 .bytes()
913 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')))
914 .then_some(value)
915}
916
917fn is_safety_failure_label(value: &str) -> bool {
918 let lower = value.to_ascii_lowercase();
919 lower.contains("safety")
920 || lower.contains("content_policy")
921 || lower.contains("moderation")
922 || lower.contains("refusal")
923}
924
925fn unknown_app_server_outcome(code: ErrorCode, message: &str) -> BridgeError {
926 BridgeError::new(code, message)
927 .with_provider("codex-app-server")
928 .retryable(false)
929 .with_detail("outcome", "unknown")
930}
931
932struct TurnAccumulator {
933 images: Vec<String>,
934 revised_prompt: Option<String>,
935 completed_item_ids: HashSet<String>,
936 result_bytes: usize,
937 max_images: usize,
938 max_result_bytes: usize,
939}
940
941impl TurnAccumulator {
942 fn capture(&mut self, item: &Value) -> Result<(), BridgeError> {
943 let item_id = item["id"]
944 .as_str()
945 .filter(|value| !value.is_empty())
946 .ok_or_else(|| app_server_protocol_error("completed image has no item ID"))?;
947 if self.completed_item_ids.contains(item_id) {
948 return Err(app_server_protocol_error(
949 "app-server returned a duplicate completed image item",
950 ));
951 }
952 if self.images.len() >= self.max_images {
953 return Err(app_server_protocol_error(
954 "app-server returned more images than negotiated",
955 ));
956 }
957 let result = item["result"]
958 .as_str()
959 .filter(|value| !value.is_empty())
960 .ok_or_else(|| app_server_protocol_error("completed image has no result"))?;
961 let next_result_bytes = self
962 .result_bytes
963 .checked_add(result.len())
964 .ok_or_else(|| app_server_protocol_error("aggregate image result size overflowed"))?;
965 if next_result_bytes > self.max_result_bytes {
966 return Err(app_server_protocol_error(
967 "aggregate image results exceed the byte limit",
968 ));
969 }
970 let revised = item["revisedPrompt"]
971 .as_str()
972 .or_else(|| item["revised_prompt"].as_str())
973 .filter(|value| !value.is_empty());
974 if revised.is_some_and(|value| value.len() > MAX_REVISED_PROMPT_BYTES) {
975 return Err(app_server_protocol_error(
976 "Codex revised prompt exceeds the configured limit",
977 ));
978 }
979 self.completed_item_ids.insert(item_id.to_owned());
980 self.result_bytes = next_result_bytes;
981 self.images.push(result.to_owned());
982 if let Some(revised) = revised {
983 self.revised_prompt = Some(revised.to_owned());
984 }
985 Ok(())
986 }
987}
988
989fn app_server_protocol_error(message: &str) -> BridgeError {
990 BridgeError::new(ErrorCode::Protocol, message).with_provider("codex-app-server")
991}
992
993struct TurnImages {
994 images: Vec<String>,
995 revised_prompt: Option<String>,
996}
997
998#[cfg(test)]
999mod tests {
1000 #![allow(clippy::unwrap_used)]
1001
1002 use std::{fs, time::Duration};
1003
1004 #[cfg(unix)]
1005 use std::os::unix::fs::PermissionsExt as _;
1006
1007 use futures_util::{SinkExt as _, StreamExt as _};
1008 use tokio::io::duplex;
1009 use tokio_util::{
1010 codec::{Framed, LinesCodec},
1011 sync::CancellationToken,
1012 };
1013
1014 use super::*;
1015 use crate::RpcConfig;
1016
1017 const ONE_PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
1018
1019 #[test]
1020 fn turn_safety_failures_are_classified_without_reflecting_messages() {
1021 let mut request = ImageRequest::generate("safe fixture");
1022 request.parameters.moderation = Moderation::Auto;
1023 let error = turn_failure_error(
1024 &json!({
1025 "status": "failed",
1026 "error": {
1027 "code": "content_policy_violation",
1028 "message": "secret upstream prompt detail"
1029 }
1030 }),
1031 &request,
1032 );
1033 assert_eq!(error.code, ErrorCode::SafetyRejected);
1034 assert_eq!(error.details["recovery"], "revise_prompt_or_inputs");
1035 assert_eq!(error.details["requested_moderation"], "auto");
1036 assert!(!error.message.contains("secret"));
1037 }
1038
1039 #[test]
1040 fn turn_accumulator_bounds_count_identity_bytes_and_revised_prompt() {
1041 fn accumulator(max_images: usize, max_result_bytes: usize) -> TurnAccumulator {
1042 TurnAccumulator {
1043 images: Vec::new(),
1044 revised_prompt: None,
1045 completed_item_ids: HashSet::new(),
1046 result_bytes: 0,
1047 max_images,
1048 max_result_bytes,
1049 }
1050 }
1051
1052 let first = json!({
1053 "id": "image-1",
1054 "result": "1234",
1055 "revisedPrompt": "safe"
1056 });
1057 let mut bounded = accumulator(1, 4);
1058 bounded.capture(&first).unwrap();
1059 assert_eq!(bounded.images, ["1234"]);
1060 assert!(
1061 bounded
1062 .capture(&first)
1063 .unwrap_err()
1064 .message
1065 .contains("duplicate")
1066 );
1067
1068 let second = json!({"id": "image-2", "result": "1"});
1069 assert!(
1070 bounded
1071 .capture(&second)
1072 .unwrap_err()
1073 .message
1074 .contains("more images")
1075 );
1076
1077 let mut bytes = accumulator(2, 3);
1078 assert!(
1079 bytes
1080 .capture(&first)
1081 .unwrap_err()
1082 .message
1083 .contains("byte limit")
1084 );
1085
1086 let mut revised = accumulator(1, 4);
1087 let revised_item = json!({
1088 "id": "image-1",
1089 "result": "1",
1090 "revisedPrompt": "x".repeat(MAX_REVISED_PROMPT_BYTES + 1)
1091 });
1092 assert!(
1093 revised
1094 .capture(&revised_item)
1095 .unwrap_err()
1096 .message
1097 .contains("revised prompt")
1098 );
1099 }
1100
1101 async fn rpc_and_server() -> (
1102 Arc<AppServerRpc>,
1103 Framed<tokio::io::DuplexStream, LinesCodec>,
1104 ) {
1105 let (client, server) = duplex(128 * 1024);
1106 let (reader, writer) = tokio::io::split(client);
1107 let mut server = Framed::new(server, LinesCodec::new());
1108 let connection = tokio::spawn(AppServerRpc::connect(
1109 reader,
1110 writer,
1111 RpcConfig {
1112 max_message_bytes: 128 * 1024,
1113 max_notification_bytes: 128 * 1024,
1114 request_timeout: Duration::from_secs(2),
1115 notification_capacity: 16,
1116 },
1117 ));
1118 let initialize = next_message(&mut server).await;
1119 server
1120 .send(json!({"id": initialize["id"], "result": {}}).to_string())
1121 .await
1122 .unwrap();
1123 let rpc = connection.await.unwrap().unwrap();
1124 assert_eq!(next_message(&mut server).await["method"], "initialized");
1125 (rpc, server)
1126 }
1127
1128 async fn next_message(server: &mut Framed<tokio::io::DuplexStream, LinesCodec>) -> Value {
1129 serde_json::from_str(&server.next().await.unwrap().unwrap()).unwrap()
1130 }
1131
1132 fn request() -> ImageRequest {
1133 let mut request = ImageRequest::generate("a tiny blue square");
1134 request.session.mode = SessionMode::Persistent;
1135 request.session.key = Some("gallery".to_owned());
1136 request
1137 }
1138
1139 fn context(id: &str) -> ProviderContext {
1140 ProviderContext {
1141 request_id: id.to_owned(),
1142 deadline: Instant::now() + Duration::from_secs(2),
1143 cancellation: CancellationToken::new(),
1144 events: None,
1145 }
1146 }
1147
1148 async fn complete_turn(
1149 server: &mut Framed<tokio::io::DuplexStream, LinesCodec>,
1150 thread_id: &str,
1151 turn_id: &str,
1152 ) {
1153 server
1154 .send(
1155 json!({
1156 "method": "item/completed",
1157 "params": {
1158 "threadId": thread_id,
1159 "turnId": turn_id,
1160 "completedAtMs": 1,
1161 "item": {
1162 "type": "imageGeneration",
1163 "id": "image-1",
1164 "status": "completed",
1165 "revisedPrompt": "a tiny blue square",
1166 "result": ONE_PIXEL_PNG
1167 }
1168 }
1169 })
1170 .to_string(),
1171 )
1172 .await
1173 .unwrap();
1174 server
1175 .send(
1176 json!({
1177 "method": "turn/completed",
1178 "params": {
1179 "threadId": thread_id,
1180 "turn": {"id": turn_id, "status": "completed", "items": []}
1181 }
1182 })
1183 .to_string(),
1184 )
1185 .await
1186 .unwrap();
1187 }
1188
1189 #[tokio::test]
1190 async fn persistent_requests_start_once_then_resume_the_same_thread() {
1191 let (rpc, mut server) = rpc_and_server().await;
1192 let provider = Arc::new(AppServerImageProvider::new(
1193 rpc,
1194 Arc::new(MemorySessionBindingStore::default()),
1195 AppServerProviderConfig {
1196 codex_model: None,
1197 cwd: PathBuf::from("/tmp"),
1198 image_limits: ImageLimits::default(),
1199 },
1200 ));
1201
1202 let first = tokio::spawn({
1203 let provider = Arc::clone(&provider);
1204 async move { provider.execute(request(), context("request-1")).await }
1205 });
1206 let start = next_message(&mut server).await;
1207 assert_eq!(start["method"], "thread/start");
1208 assert_eq!(start["params"]["ephemeral"], false);
1209 server
1210 .send(json!({"id": start["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1211 .await
1212 .unwrap();
1213 let turn = next_message(&mut server).await;
1214 assert_eq!(turn["method"], "turn/start");
1215 assert_eq!(turn["params"]["threadId"], "thread-1");
1216 server
1217 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-1"}}}).to_string())
1218 .await
1219 .unwrap();
1220 complete_turn(&mut server, "thread-1", "turn-1").await;
1221 let first = first.await.unwrap().unwrap();
1222 assert!(!first.session.unwrap().reused);
1223 assert_eq!(first.data.len(), 1);
1224
1225 let second = tokio::spawn({
1226 let provider = Arc::clone(&provider);
1227 async move { provider.execute(request(), context("request-2")).await }
1228 });
1229 let resume = next_message(&mut server).await;
1230 assert_eq!(resume["method"], "thread/resume");
1231 assert_eq!(resume["params"]["threadId"], "thread-1");
1232 server
1233 .send(json!({"id": resume["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1234 .await
1235 .unwrap();
1236 let turn = next_message(&mut server).await;
1237 server
1238 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-2"}}}).to_string())
1239 .await
1240 .unwrap();
1241 complete_turn(&mut server, "thread-1", "turn-2").await;
1242 let second = second.await.unwrap().unwrap();
1243 assert!(second.session.unwrap().reused);
1244 }
1245
1246 #[tokio::test]
1247 async fn stages_inline_references_under_bridge_owned_storage() {
1248 let (rpc, _server) = rpc_and_server().await;
1249 let directory = tempfile::tempdir().unwrap();
1250 let input_root = directory.path().join("inputs");
1251 let staging_root = directory.path().join("staging");
1252 fs::create_dir(&input_root).unwrap();
1253 let limits = ImageLimits::default();
1254 let reference_inputs = AppServerReferenceInputs::new(
1255 Arc::new(InputLoader::new([input_root], limits).unwrap()),
1256 None,
1257 Arc::new(ArtifactStore::new(&staging_root, limits).unwrap()),
1258 1024 * 1024,
1259 )
1260 .unwrap();
1261 let provider = AppServerImageProvider::new_with_inputs(
1262 rpc,
1263 Arc::new(MemorySessionBindingStore::default()),
1264 AppServerProviderConfig {
1265 codex_model: None,
1266 cwd: directory.path().to_owned(),
1267 image_limits: limits,
1268 },
1269 reference_inputs,
1270 );
1271 let mut request = request();
1272 request.operation = ImageOperation::Generate {
1273 reference_images: vec![imagegen_bridge_core::ImageInput {
1274 source: imagegen_bridge_core::ImageSource::Base64 {
1275 data: ONE_PIXEL_PNG.to_owned(),
1276 },
1277 media_type: Some("image/png".to_owned()),
1278 filename: Some("visual reference.png".to_owned()),
1279 }],
1280 };
1281
1282 let paths = provider.reference_paths(&request).await.unwrap();
1283 assert_eq!(paths.len(), 1);
1284 assert!(paths[0].starts_with(staging_root.canonicalize().unwrap()));
1285 assert!(paths[0].is_file());
1286 assert!(!paths[0].to_string_lossy().contains("visual reference"));
1287 }
1288
1289 #[tokio::test]
1290 async fn rejects_references_when_secure_staging_is_not_configured() {
1291 let (rpc, _server) = rpc_and_server().await;
1292 let provider = AppServerImageProvider::new(
1293 rpc,
1294 Arc::new(MemorySessionBindingStore::default()),
1295 AppServerProviderConfig {
1296 codex_model: None,
1297 cwd: PathBuf::from("/tmp"),
1298 image_limits: ImageLimits::default(),
1299 },
1300 );
1301 let mut request = request();
1302 request.operation = ImageOperation::Generate {
1303 reference_images: vec![imagegen_bridge_core::ImageInput {
1304 source: imagegen_bridge_core::ImageSource::File {
1305 path: PathBuf::from("outside.png"),
1306 },
1307 media_type: None,
1308 filename: None,
1309 }],
1310 };
1311 let error = provider.reference_paths(&request).await.unwrap_err();
1312 assert_eq!(error.code, ErrorCode::Input);
1313 }
1314
1315 #[tokio::test]
1316 async fn session_lifecycle_is_visible_without_exposing_other_state() {
1317 let (rpc, _server) = rpc_and_server().await;
1318 let sessions = Arc::new(MemorySessionBindingStore::default());
1319 sessions.put("gallery", "thread-1").await.unwrap();
1320 let provider = AppServerImageProvider::new(
1321 rpc,
1322 sessions,
1323 AppServerProviderConfig {
1324 codex_model: None,
1325 cwd: PathBuf::from("/tmp"),
1326 image_limits: ImageLimits::default(),
1327 },
1328 );
1329 let session = provider.get_session("gallery").await.unwrap();
1330 assert_eq!(session.key.as_deref(), Some("gallery"));
1331 assert_eq!(session.thread_id.as_deref(), Some("thread-1"));
1332 provider.delete_session("gallery").await.unwrap();
1333 assert_eq!(
1334 provider.get_session("gallery").await.unwrap_err().code,
1335 ErrorCode::Session
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn waiting_on_the_same_session_is_cancellable_without_starting_a_turn() {
1341 let (rpc, mut server) = rpc_and_server().await;
1342 let provider = Arc::new(AppServerImageProvider::new(
1343 rpc,
1344 Arc::new(MemorySessionBindingStore::default()),
1345 AppServerProviderConfig {
1346 codex_model: None,
1347 cwd: PathBuf::from("/tmp"),
1348 image_limits: ImageLimits::default(),
1349 },
1350 ));
1351 let first = tokio::spawn({
1352 let provider = Arc::clone(&provider);
1353 async move { provider.execute(request(), context("first")).await }
1354 });
1355 let start = next_message(&mut server).await;
1356 server
1357 .send(json!({"id": start["id"], "result": {"thread": {"id": "thread-1"}}}).to_string())
1358 .await
1359 .unwrap();
1360 let turn = next_message(&mut server).await;
1361 server
1362 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-1"}}}).to_string())
1363 .await
1364 .unwrap();
1365
1366 let cancellation = CancellationToken::new();
1367 let second = tokio::spawn({
1368 let provider = Arc::clone(&provider);
1369 let cancellation = cancellation.clone();
1370 async move {
1371 provider
1372 .execute(
1373 request(),
1374 ProviderContext {
1375 request_id: "second".to_owned(),
1376 deadline: Instant::now() + Duration::from_secs(2),
1377 cancellation,
1378 events: None,
1379 },
1380 )
1381 .await
1382 }
1383 });
1384 tokio::task::yield_now().await;
1385 cancellation.cancel();
1386 let error = second.await.unwrap().unwrap_err();
1387 assert_eq!(error.code, ErrorCode::Cancelled);
1388
1389 complete_turn(&mut server, "thread-1", "turn-1").await;
1390 assert!(first.await.unwrap().is_ok());
1391 }
1392
1393 #[cfg(unix)]
1394 #[tokio::test]
1395 async fn process_restart_resumes_the_durable_thread_without_starting_a_new_chat() {
1396 let directory = tempfile::tempdir().unwrap();
1397 let script = directory.path().join("fake-codex");
1398 let state = directory.path().join("started-once");
1399 let source = format!(
1400 r#"#!/bin/sh
1401STATE='{}'
1402if [ -e "$STATE" ]; then
1403 RUN=2
1404else
1405 : > "$STATE"
1406 RUN=1
1407fi
1408while IFS= read -r LINE; do
1409 case "$LINE" in
1410 *'"method":"initialize"'*)
1411 printf '%s\n' '{{"id":1,"result":{{}}}}'
1412 ;;
1413 *'"method":"thread/resume"'*)
1414 printf '%s\n' '{{"id":2,"result":{{"thread":{{"id":"thread-1"}}}}}}'
1415 ;;
1416 *'"method":"turn/start"'*)
1417 if [ "$RUN" -eq 1 ]; then
1418 exit 17
1419 fi
1420 printf '%s\n' '{{"id":3,"result":{{"turn":{{"id":"turn-2"}}}}}}'
1421 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":"{}"}}}}}}'
1422 printf '%s\n' '{{"method":"turn/completed","params":{{"threadId":"thread-1","turn":{{"id":"turn-2","status":"completed","items":[]}}}}}}'
1423 ;;
1424 esac
1425done
1426"#,
1427 state.display(),
1428 ONE_PIXEL_PNG
1429 );
1430 fs::write(&script, source).unwrap();
1431 let mut permissions = fs::metadata(&script).unwrap().permissions();
1432 permissions.set_mode(0o700);
1433 fs::set_permissions(&script, permissions).unwrap();
1434
1435 let process = Arc::new(
1436 CodexProcess::spawn(crate::CodexProcessConfig {
1437 executable: script,
1438 cwd: Some(directory.path().to_owned()),
1439 rpc: RpcConfig {
1440 request_timeout: Duration::from_secs(2),
1441 ..RpcConfig::default()
1442 },
1443 restart_backoff: Duration::ZERO,
1444 ..crate::CodexProcessConfig::default()
1445 })
1446 .await
1447 .unwrap(),
1448 );
1449 let sessions = Arc::new(MemorySessionBindingStore::default());
1450 sessions.put("gallery", "thread-1").await.unwrap();
1451 let provider = AppServerImageProvider::with_process(
1452 Arc::clone(&process),
1453 sessions,
1454 AppServerProviderConfig {
1455 codex_model: None,
1456 cwd: directory.path().to_owned(),
1457 image_limits: ImageLimits::default(),
1458 },
1459 );
1460
1461 let first = provider
1462 .execute(request(), context("crashing-request"))
1463 .await
1464 .unwrap_err();
1465 assert_eq!(first.code, ErrorCode::Protocol);
1466 let second = provider
1467 .execute(request(), context("resumed-request"))
1468 .await
1469 .unwrap();
1470 let session = second.session.unwrap();
1471 assert!(session.reused);
1472 assert_eq!(session.thread_id.as_deref(), Some("thread-1"));
1473 assert_eq!(process.generation(), 2);
1474 provider.shutdown().await.unwrap();
1475 }
1476
1477 #[tokio::test]
1478 async fn deadline_interrupts_an_active_turn() {
1479 let (rpc, mut server) = rpc_and_server().await;
1480 let provider = Arc::new(AppServerImageProvider::new(
1481 rpc,
1482 Arc::new(MemorySessionBindingStore::default()),
1483 AppServerProviderConfig {
1484 codex_model: None,
1485 cwd: PathBuf::from("/tmp"),
1486 image_limits: ImageLimits::default(),
1487 },
1488 ));
1489 let execution = tokio::spawn({
1490 let provider = Arc::clone(&provider);
1491 async move {
1492 provider
1493 .execute(
1494 ImageRequest::generate("timeout"),
1495 ProviderContext {
1496 request_id: "timeout".to_owned(),
1497 deadline: Instant::now() + Duration::from_millis(20),
1498 cancellation: CancellationToken::new(),
1499 events: None,
1500 },
1501 )
1502 .await
1503 }
1504 });
1505 let start = next_message(&mut server).await;
1506 assert_eq!(start["method"], "thread/start");
1507 server
1508 .send(
1509 json!({"id": start["id"], "result": {"thread": {"id": "thread-timeout"}}})
1510 .to_string(),
1511 )
1512 .await
1513 .unwrap();
1514 let turn = next_message(&mut server).await;
1515 server
1516 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-timeout"}}}).to_string())
1517 .await
1518 .unwrap();
1519 let interrupt = next_message(&mut server).await;
1520 assert_eq!(interrupt["method"], "turn/interrupt");
1521 server
1522 .send(json!({"id": interrupt["id"], "result": {}}).to_string())
1523 .await
1524 .unwrap();
1525 server
1526 .send(
1527 json!({
1528 "method": "turn/completed",
1529 "params": {
1530 "threadId": "thread-timeout",
1531 "turn": {"id": "turn-timeout", "status": "interrupted"}
1532 }
1533 })
1534 .to_string(),
1535 )
1536 .await
1537 .unwrap();
1538 let archive = next_message(&mut server).await;
1539 assert_eq!(archive["method"], "thread/archive");
1540 server
1541 .send(json!({"id": archive["id"], "result": {}}).to_string())
1542 .await
1543 .unwrap();
1544 let error = execution.await.unwrap().unwrap_err();
1545 assert_eq!(error.code, ErrorCode::Timeout);
1546 assert!(error.retryable);
1547 assert_eq!(error.details["outcome"], "cancelled");
1548 }
1549
1550 #[tokio::test]
1551 async fn cancellation_interrupts_an_active_turn() {
1552 let (rpc, mut server) = rpc_and_server().await;
1553 let provider = Arc::new(AppServerImageProvider::new(
1554 rpc,
1555 Arc::new(MemorySessionBindingStore::default()),
1556 AppServerProviderConfig {
1557 codex_model: None,
1558 cwd: PathBuf::from("/tmp"),
1559 image_limits: ImageLimits::default(),
1560 },
1561 ));
1562 let cancellation = CancellationToken::new();
1563 let execution = tokio::spawn({
1564 let provider = Arc::clone(&provider);
1565 let cancellation = cancellation.clone();
1566 async move {
1567 provider
1568 .execute(
1569 ImageRequest::generate("cancel"),
1570 ProviderContext {
1571 request_id: "cancel".to_owned(),
1572 deadline: Instant::now() + Duration::from_secs(2),
1573 cancellation,
1574 events: None,
1575 },
1576 )
1577 .await
1578 }
1579 });
1580 let start = next_message(&mut server).await;
1581 server
1582 .send(
1583 json!({"id": start["id"], "result": {"thread": {"id": "thread-cancel"}}})
1584 .to_string(),
1585 )
1586 .await
1587 .unwrap();
1588 let turn = next_message(&mut server).await;
1589 server
1590 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-cancel"}}}).to_string())
1591 .await
1592 .unwrap();
1593 tokio::time::sleep(Duration::from_millis(5)).await;
1594 cancellation.cancel();
1595 let interrupt = next_message(&mut server).await;
1596 assert_eq!(interrupt["method"], "turn/interrupt");
1597 assert_eq!(interrupt["params"]["threadId"], "thread-cancel");
1598 server
1599 .send(json!({"id": interrupt["id"], "result": {}}).to_string())
1600 .await
1601 .unwrap();
1602 server
1603 .send(
1604 json!({
1605 "method": "turn/completed",
1606 "params": {
1607 "threadId": "thread-cancel",
1608 "turn": {"id": "turn-cancel", "status": "interrupted"}
1609 }
1610 })
1611 .to_string(),
1612 )
1613 .await
1614 .unwrap();
1615 let archive = next_message(&mut server).await;
1616 assert_eq!(archive["method"], "thread/archive");
1617 server
1618 .send(json!({"id": archive["id"], "result": {}}).to_string())
1619 .await
1620 .unwrap();
1621 let error = execution.await.unwrap().unwrap_err();
1622 assert_eq!(error.code, ErrorCode::Cancelled);
1623 assert!(!error.retryable);
1624 assert_eq!(error.details["outcome"], "cancelled");
1625 }
1626
1627 #[tokio::test]
1628 async fn completed_turn_without_a_valid_image_is_rejected() {
1629 let (rpc, mut server) = rpc_and_server().await;
1630 let provider = Arc::new(AppServerImageProvider::new(
1631 rpc,
1632 Arc::new(MemorySessionBindingStore::default()),
1633 AppServerProviderConfig {
1634 codex_model: None,
1635 cwd: PathBuf::from("/tmp"),
1636 image_limits: ImageLimits::default(),
1637 },
1638 ));
1639 let execution = tokio::spawn({
1640 let provider = Arc::clone(&provider);
1641 async move {
1642 provider
1643 .execute(ImageRequest::generate("missing"), context("missing"))
1644 .await
1645 }
1646 });
1647 let start = next_message(&mut server).await;
1648 server
1649 .send(
1650 json!({"id": start["id"], "result": {"thread": {"id": "thread-missing"}}})
1651 .to_string(),
1652 )
1653 .await
1654 .unwrap();
1655 let turn = next_message(&mut server).await;
1656 server
1657 .send(json!({"id": turn["id"], "result": {"turn": {"id": "turn-missing"}}}).to_string())
1658 .await
1659 .unwrap();
1660 server
1661 .send(
1662 json!({
1663 "method": "turn/completed",
1664 "params": {
1665 "threadId": "thread-missing",
1666 "turn": {"id": "turn-missing", "status": "completed", "items": []}
1667 }
1668 })
1669 .to_string(),
1670 )
1671 .await
1672 .unwrap();
1673 let archive = next_message(&mut server).await;
1674 server
1675 .send(json!({"id": archive["id"], "result": {}}).to_string())
1676 .await
1677 .unwrap();
1678 let error = execution.await.unwrap().unwrap_err();
1679 assert_eq!(error.code, ErrorCode::Upstream);
1680 }
1681
1682 #[tokio::test]
1683 #[ignore = "requires authenticated Codex OAuth and performs a real image generation"]
1684 async fn live_codex_generates_a_verified_image() {
1685 if std::env::var("IMAGEGEN_BRIDGE_LIVE_CODEX").as_deref() != Ok("1") {
1686 return;
1687 }
1688 let process = Arc::new(
1689 CodexProcess::spawn(crate::CodexProcessConfig {
1690 rpc: RpcConfig {
1691 request_timeout: Duration::from_secs(30),
1692 ..RpcConfig::default()
1693 },
1694 ..crate::CodexProcessConfig::default()
1695 })
1696 .await
1697 .unwrap(),
1698 );
1699 let provider = AppServerImageProvider::with_process(
1700 Arc::clone(&process),
1701 Arc::new(MemorySessionBindingStore::default()),
1702 AppServerProviderConfig {
1703 codex_model: None,
1704 cwd: std::env::current_dir().unwrap(),
1705 image_limits: ImageLimits::default(),
1706 },
1707 );
1708 provider.check_ready().await.unwrap();
1709 let mut live_request = ImageRequest::generate(
1710 "A single cobalt-blue circle centered on a plain white background",
1711 );
1712 live_request.session.mode = SessionMode::Persistent;
1713 live_request.session.key = Some("live-persistent-session".to_owned());
1714 let first = provider
1715 .execute(
1716 live_request.clone(),
1717 ProviderContext {
1718 request_id: "live-test-1".to_owned(),
1719 deadline: Instant::now() + Duration::from_secs(240),
1720 cancellation: CancellationToken::new(),
1721 events: None,
1722 },
1723 )
1724 .await
1725 .unwrap();
1726 assert_eq!(first.data.len(), 1);
1727 assert!(first.data[0].bytes > 0);
1728 let first_session = first.session.unwrap();
1729 assert!(!first_session.reused);
1730
1731 let second = provider
1732 .execute(
1733 live_request,
1734 ProviderContext {
1735 request_id: "live-test-2".to_owned(),
1736 deadline: Instant::now() + Duration::from_secs(240),
1737 cancellation: CancellationToken::new(),
1738 events: None,
1739 },
1740 )
1741 .await
1742 .unwrap();
1743 assert_eq!(second.data.len(), 1);
1744 assert!(second.data[0].bytes > 0);
1745 let second_session = second.session.unwrap();
1746 assert!(second_session.reused);
1747 assert_eq!(first_session.thread_id, second_session.thread_id);
1748 provider.shutdown().await.unwrap();
1749 }
1750}