Skip to main content

imagegen_bridge_codex_app_server/
provider.rs

1//! Image provider and persistent Codex thread semantics.
2
3use 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/// Durable binding interface implemented by memory and runtime `SQLite` stores.
31#[async_trait]
32pub trait SessionBindingStore: Send + Sync {
33    /// Looks up a provider thread for a caller session key.
34    async fn get(&self, key: &str) -> Result<Option<String>, BridgeError>;
35    /// Atomically creates or replaces a session binding.
36    async fn put(&self, key: &str, thread_id: &str) -> Result<(), BridgeError>;
37    /// Deletes a binding.
38    async fn delete(&self, key: &str) -> Result<(), BridgeError>;
39}
40
41/// In-memory session store for embedded use and tests.
42#[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/// App-server image provider settings.
68#[derive(Debug, Clone)]
69pub struct AppServerProviderConfig {
70    /// Optional Codex chat model used to orchestrate the image tool.
71    pub codex_model: Option<String>,
72    /// Working directory visible to Codex.
73    pub cwd: PathBuf,
74    /// Limits used to verify returned base64 images.
75    pub image_limits: ImageLimits,
76}
77
78/// Secure input loading and bridge-owned staging for app-server references.
79#[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    /// Creates a reference loader with a per-request aggregate decoded-byte bound.
101    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
122/// Provider backed by one initialized app-server connection.
123pub 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    /// Creates a provider over an existing initialized connection.
145    #[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    /// Creates a provider over an existing connection with secure references.
162    #[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    /// Creates a provider that owns and shuts down its Codex child process.
180    #[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    /// Creates an owned provider with secure bridge-staged reference inputs.
197    #[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(&notification.params, thread_id, &turn_id) {
478                accumulator.diagnostics.observe(&notification);
479            }
480            if notification.method == "item/completed"
481                && belongs_to(&notification.params, thread_id, &turn_id)
482                && notification.params["item"]["type"] == "imageGeneration"
483            {
484                let item = &notification.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(&notification.params, thread_id, &turn_id)
501            {
502                if notification.params["turn"]["status"] != "completed" {
503                    return Err(turn_failure_error(&notification.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(&notification.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 = &notification.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}