Skip to main content

imagegen_bridge_codex_app_server/
provider.rs

1//! Image provider and persistent Codex thread semantics.
2
3use 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/// 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        };
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(&notification.params, thread_id, &turn_id)
478                && notification.params["item"]["type"] == "imageGeneration"
479            {
480                let item = &notification.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(&notification.params, thread_id, &turn_id)
497            {
498                if notification.params["turn"]["status"] != "completed" {
499                    return Err(turn_failure_error(&notification.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(&notification.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}