Skip to main content

agentic_core/executor/
engine.rs

1//! Stateful conversation executor.
2//!
3//! Exposes each step of the conversation pipeline as a public function so consumers
4//! can compose them directly (e.g. as Praxis filters). [`ExecuteRequest`] is the
5//! primary entry point; [`execute`] is a convenience shim for callers that don't
6//! need per-request configuration.
7
8use std::sync::Arc;
9
10use async_stream::stream;
11use either::Either;
12use tokio::sync::mpsc;
13use tracing::debug;
14
15use super::compaction::{compact_items, maybe_compact_context};
16use super::gateway::{
17    GatewayCallResult, LoopDecision, append_gateway_calls_to_new_input, append_output_items_to_input,
18    append_tool_outputs, classify_round, compaction_event_plans, complete_gateway_event_plans,
19    emit_gateway_completed_events, emit_gateway_start_events, emit_response_start_events,
20    execute_and_emit_output_calls, execute_output_calls, gateway_event_plans, has_client_owned_calls,
21    is_client_custom_call, is_gateway_owned_call, public_output_items,
22};
23use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk};
24use crate::events::EventFrame;
25use crate::executor::error::ExecutorResult;
26use crate::executor::inference::DONE_MARKER;
27use crate::executor::persist::persist_if_needed;
28use crate::executor::rehydrate::rehydrate_conversation;
29use crate::executor::request::{ExecutionContext, RequestContext};
30use crate::executor::upstream::{emit_deferred_stream_events, fetch_blocking_payload, fetch_stream_payload};
31use crate::tool::{ToolRegistry, mcp};
32use crate::types::io::{InputItem, OutputItem, ResponseUsage, ResponsesInput, ToolChoice};
33use crate::types::request_response::{IncompleteDetails, RequestPayload, ResponsePayload};
34use crate::utils::common::utcnow_str;
35
36pub use crate::executor::inference::BoxStream;
37
38const MAX_GATEWAY_TOOL_ROUNDS: usize = 10;
39
40fn add_usage(total: ResponseUsage, usage: ResponseUsage) -> ResponseUsage {
41    ResponseUsage {
42        input_tokens: total.input_tokens.saturating_add(usage.input_tokens),
43        output_tokens: total.output_tokens.saturating_add(usage.output_tokens),
44        total_tokens: total.total_tokens.saturating_add(usage.total_tokens),
45        input_tokens_details: crate::types::io::InputTokenDetails {
46            cached_tokens: total
47                .input_tokens_details
48                .cached_tokens
49                .saturating_add(usage.input_tokens_details.cached_tokens),
50        },
51        output_tokens_details: crate::types::io::OutputTokenDetails {
52            reasoning_tokens: total
53                .output_tokens_details
54                .reasoning_tokens
55                .saturating_add(usage.output_tokens_details.reasoning_tokens),
56        },
57    }
58}
59
60fn accumulate_usage(total: &mut Option<ResponseUsage>, usage: Option<ResponseUsage>) {
61    if let Some(usage) = usage {
62        *total = Some(total.map_or(usage, |current| add_usage(current, usage)));
63    }
64}
65
66struct AbortOnDrop<T> {
67    handle: tokio::task::JoinHandle<T>,
68}
69
70impl<T> AbortOnDrop<T> {
71    fn new(handle: tokio::task::JoinHandle<T>) -> Self {
72        Self { handle }
73    }
74}
75
76impl<T> std::ops::Deref for AbortOnDrop<T> {
77    type Target = tokio::task::JoinHandle<T>;
78
79    fn deref(&self) -> &Self::Target {
80        &self.handle
81    }
82}
83
84impl<T> std::ops::DerefMut for AbortOnDrop<T> {
85    fn deref_mut(&mut self) -> &mut Self::Target {
86        &mut self.handle
87    }
88}
89
90impl<T> Drop for AbortOnDrop<T> {
91    fn drop(&mut self) {
92        if !self.handle.is_finished() {
93            self.handle.abort();
94        }
95    }
96}
97
98async fn run_until_gateway_tools_complete(
99    ctx: RequestContext,
100    exec_ctx: &ExecutionContext,
101    auth: Option<&str>,
102    stream_upstream: bool,
103    mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
104) -> ExecutorResult<(ResponsePayload, RequestContext)> {
105    if ctx.enriched_request.input.has_compaction_trigger() {
106        let (payload, ctx) = run_compaction_trigger(ctx, exec_ctx, auth).await?;
107        if let Some((stream_accumulator, stream_sender)) = stream.as_mut() {
108            emit_response_start_events(&payload, stream_accumulator, stream_sender)?;
109            let event_plans = compaction_event_plans(&payload.output, 0);
110            emit_gateway_start_events(&event_plans, stream_accumulator, stream_sender)?;
111            emit_gateway_completed_events(&payload.output, &event_plans, stream_accumulator, stream_sender)?;
112        }
113        return Ok((payload, ctx));
114    }
115
116    run_gateway_tool_loop(ctx, exec_ctx, auth, stream_upstream, stream).await
117}
118
119async fn run_gateway_tool_loop(
120    mut ctx: RequestContext,
121    exec_ctx: &ExecutionContext,
122    auth: Option<&str>,
123    stream_upstream: bool,
124    mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
125) -> ExecutorResult<(ResponsePayload, RequestContext)> {
126    let mut executors = exec_ctx.gateway_executors.request_scoped();
127    let registry: ToolRegistry = match ctx.enriched_request.tools.as_mut() {
128        Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?,
129        None => ToolRegistry::default(),
130    };
131    let mut combined_output: Vec<OutputItem> = registry
132        .mcp_list_tools_items()
133        .iter()
134        .map(mcp::handler::list_tools_output_item)
135        .collect();
136    let mut combined_usage = None;
137
138    for round in 0..MAX_GATEWAY_TOOL_ROUNDS {
139        let compaction_usage = maybe_compact_context(&mut ctx, exec_ctx, auth).await?;
140        accumulate_usage(&mut combined_usage, compaction_usage);
141        let output_offset = combined_output.len();
142        let (mut payload, deferred_stream_events): (ResponsePayload, Vec<_>) = if stream_upstream {
143            let stream_payload = fetch_stream_payload(
144                &ctx,
145                exec_ctx,
146                auth,
147                &registry,
148                stream
149                    .as_mut()
150                    .map(|(accumulator, sender)| (&mut **accumulator, *sender)),
151                output_offset,
152            )
153            .await?;
154            (stream_payload.payload, stream_payload.deferred_events)
155        } else {
156            (fetch_blocking_payload(&ctx, exec_ctx, auth).await?, Vec::new())
157        };
158        registry.restore_final_payload_output(&mut payload.output);
159        accumulate_usage(&mut combined_usage, payload.usage.take());
160        let current_output = std::mem::take(&mut payload.output);
161        for item in &current_output {
162            if let OutputItem::CustomToolCall(call) = item {
163                debug!(
164                    response_id = %ctx.response_id,
165                    call_id = %call.call_id,
166                    name = %call.name,
167                    input_bytes = call.input.len(),
168                    "custom tool call requires client execution"
169                );
170            }
171        }
172        let has_client_owned = has_client_owned_calls(&current_output, &registry);
173        let gateway_results = execute_and_emit_round_output_calls(
174            &current_output,
175            &registry,
176            output_offset,
177            deferred_stream_events,
178            &ctx,
179            stream
180                .as_mut()
181                .map(|(accumulator, sender)| (&mut **accumulator, *sender)),
182        )
183        .await?;
184        let public_output = public_output_items(&current_output, &registry, &gateway_results);
185        combined_output.extend(public_output);
186
187        match classify_round(has_client_owned, &gateway_results, round, MAX_GATEWAY_TOOL_ROUNDS) {
188            // Client-owned calls (plain function or Codex namespace tools) are
189            // handed back to the caller. Gateway calls in the same turn are
190            // still recorded so the returned conversation is complete.
191            LoopDecision::RequiresClientAction => {
192                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
193                append_tool_outputs(
194                    &mut ctx,
195                    gateway_results.into_iter().map(|result| result.input_item).collect(),
196                );
197                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
198                return Ok((payload, ctx));
199            }
200            // No gateway work remains — this turn is the final response.
201            LoopDecision::Done => {
202                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
203                return Ok((payload, ctx));
204            }
205            // Budget exhausted while the model was still requesting gateway
206            // tools: surface the accumulated work as a partial
207            // `status: "incomplete"` response instead of failing the request.
208            // The final round's gateway calls and outputs are recorded so a
209            // continuation is not fed a dangling tool call.
210            LoopDecision::Incomplete(reason) => {
211                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
212                append_tool_outputs(
213                    &mut ctx,
214                    gateway_results.into_iter().map(|result| result.input_item).collect(),
215                );
216                finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
217                "incomplete".clone_into(&mut payload.status);
218                payload.incomplete_details = Some(IncompleteDetails { reason: Some(reason) });
219                return Ok((payload, ctx));
220            }
221            // Gateway tools ran and rounds remain; feed outputs back and loop.
222            LoopDecision::Continue => {
223                ctx.enriched_request.tool_choice = Some(ToolChoice::Auto);
224                append_output_items_to_input(&mut ctx.enriched_request.input, &current_output);
225                append_gateway_calls_to_new_input(&mut ctx, &current_output, &registry);
226                append_tool_outputs(
227                    &mut ctx,
228                    gateway_results.into_iter().map(|result| result.input_item).collect(),
229                );
230            }
231        }
232    }
233
234    unreachable!("the final round returns Done, RequiresClientAction, or Incomplete");
235}
236
237/// Codex CLI remote-compaction V2: the client appends a `compaction_trigger`
238/// item to the input and expects the server to run its own summarization turn
239/// and stream back exactly one `compaction` output item plus `response.completed`.
240/// The trigger never reaches the upstream model; the summary inference is a
241/// normal blocking call against the same backend as standalone compaction.
242async fn run_compaction_trigger(
243    mut ctx: RequestContext,
244    exec_ctx: &ExecutionContext,
245    auth: Option<&str>,
246) -> ExecutorResult<(ResponsePayload, RequestContext)> {
247    let model = ctx.enriched_request.model.clone();
248    let instructions = ctx.enriched_request.instructions.clone();
249    let input = std::mem::replace(&mut ctx.enriched_request.input, ResponsesInput::Items(Vec::new()));
250    let (mut compacted, usage) = compact_items(&model, input, instructions.as_deref(), exec_ctx, auth).await?;
251    let Some(InputItem::Compaction(compaction)) = compacted.pop() else {
252        unreachable!("compact_items always appends a compaction item");
253    };
254    ctx.new_input_items = compacted;
255    let mut payload = ResponsePayload {
256        id: ctx.response_id.clone(),
257        object: "response".to_owned(),
258        created_at: utcnow_str(),
259        model,
260        status: "completed".to_owned(),
261        output: vec![OutputItem::Compaction(compaction)],
262        usage: Some(usage),
263        incomplete_details: None,
264        error: None,
265        previous_response_id: ctx.original_request.previous_response_id.clone(),
266        conversation_id: ctx.conversation_id.clone(),
267        instructions,
268    };
269    ctx.inject_ids(&mut payload);
270    Ok((payload, ctx))
271}
272
273async fn execute_and_emit_round_output_calls(
274    output_items: &[OutputItem],
275    registry: &ToolRegistry,
276    output_offset: usize,
277    deferred_events: Vec<EventFrame>,
278    ctx: &RequestContext,
279    stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
280) -> ExecutorResult<Vec<GatewayCallResult>> {
281    match (deferred_events.is_empty(), stream) {
282        (true, stream) => execute_and_emit_output_calls(output_items, registry, output_offset, stream).await,
283        (false, Some((stream_accumulator, stream_sender))) => {
284            execute_and_emit_ordered_output_calls(
285                output_items,
286                registry,
287                output_offset,
288                deferred_events,
289                ctx,
290                stream_accumulator,
291                stream_sender,
292            )
293            .await
294        }
295        (false, None) => execute_and_emit_output_calls(output_items, registry, output_offset, None).await,
296    }
297}
298
299async fn execute_and_emit_ordered_output_calls(
300    output_items: &[OutputItem],
301    registry: &ToolRegistry,
302    output_offset: usize,
303    deferred_events: Vec<EventFrame>,
304    ctx: &RequestContext,
305    stream_accumulator: &mut GatewayStreamAccumulator,
306    stream_sender: &mpsc::UnboundedSender<StreamEvent>,
307) -> ExecutorResult<Vec<GatewayCallResult>> {
308    let mut events_by_output = Vec::with_capacity(output_items.len());
309    events_by_output.resize_with(output_items.len(), Vec::new);
310    let mut remaining_events = Vec::new();
311    for frame in deferred_events {
312        let Some(output_index) = frame
313            .wire
314            .output_index
315            .and_then(|index| usize::try_from(index).ok())
316            .filter(|index| *index < events_by_output.len())
317        else {
318            remaining_events.push(frame);
319            continue;
320        };
321        events_by_output[output_index].push(frame);
322    }
323
324    let mut event_plans = gateway_event_plans(output_items, registry, output_offset);
325    let first_gateway_index = output_items
326        .iter()
327        .position(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)));
328    let first_gateway_run_end = first_gateway_index
329        .filter(|start| {
330            !output_items[..*start]
331                .iter()
332                .any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry)))
333        })
334        .map_or(0, |start| {
335            output_items[start..]
336                .iter()
337                .take_while(
338                    |item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)),
339                )
340                .count()
341                .saturating_add(start)
342        });
343    let first_gateway_run_len = first_gateway_run_end.saturating_sub(first_gateway_index.unwrap_or(0));
344    emit_gateway_start_events(&event_plans[..first_gateway_run_len], stream_accumulator, stream_sender)?;
345
346    let gateway_results = execute_output_calls(output_items, registry).await?;
347    complete_gateway_event_plans(&mut event_plans, &gateway_results);
348    let mut gateway_index = 0;
349    for (index, item) in output_items.iter().enumerate() {
350        if matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)) {
351            let plan = &event_plans[gateway_index..=gateway_index];
352            let result = &gateway_results[gateway_index..=gateway_index];
353            if index >= first_gateway_run_end {
354                emit_gateway_start_events(plan, stream_accumulator, stream_sender)?;
355            }
356            emit_gateway_completed_events(result, plan, stream_accumulator, stream_sender)?;
357            emit_deferred_stream_events(
358                std::mem::take(&mut events_by_output[index]),
359                ctx,
360                registry,
361                stream_accumulator,
362                stream_sender,
363                output_offset,
364            )?;
365            gateway_index += 1;
366        } else {
367            emit_deferred_stream_events(
368                std::mem::take(&mut events_by_output[index]),
369                ctx,
370                registry,
371                stream_accumulator,
372                stream_sender,
373                output_offset,
374            )?;
375        }
376    }
377    emit_deferred_stream_events(
378        remaining_events,
379        ctx,
380        registry,
381        stream_accumulator,
382        stream_sender,
383        output_offset,
384    )?;
385    Ok(gateway_results)
386}
387
388/// Move accumulated output/usage onto the terminating round's payload and
389/// inject the response/conversation IDs. The payload's `model`/`created_at`/
390/// `status` from the latest inference turn are preserved.
391fn finalize_loop(
392    payload: &mut ResponsePayload,
393    combined_output: Vec<crate::types::io::OutputItem>,
394    combined_usage: Option<ResponseUsage>,
395    ctx: &RequestContext,
396) {
397    payload.output = combined_output;
398    payload.usage = combined_usage;
399    ctx.inject_ids(payload);
400}
401
402async fn run_blocking(
403    ctx: RequestContext,
404    exec_ctx: &ExecutionContext,
405    auth: Option<&str>,
406) -> ExecutorResult<ResponsePayload> {
407    let (payload, ctx) = run_until_gateway_tools_complete(ctx, exec_ctx, auth, false, None).await?;
408
409    let ch = exec_ctx.conv_handler.clone();
410    let rh = exec_ctx.resp_handler.clone();
411    persist_if_needed(payload.clone(), ctx, ch, rh).await?;
412
413    Ok(payload)
414}
415
416fn run_stream(ctx: RequestContext, exec_ctx: Arc<ExecutionContext>, auth: Option<String>) -> BoxStream {
417    Box::pin(stream! {
418        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
419        let exec_ctx_for_run = Arc::clone(&exec_ctx);
420        let event_tx_for_run = event_tx.clone();
421        let stream_accumulator = GatewayStreamAccumulator::new();
422        let mut run_handle = AbortOnDrop::new(tokio::spawn(async move {
423            let mut stream_accumulator = stream_accumulator;
424            let result = run_until_gateway_tools_complete(
425                ctx,
426                exec_ctx_for_run.as_ref(),
427                auth.as_deref(),
428                true,
429                Some((&mut stream_accumulator, &event_tx_for_run)),
430            )
431            .await;
432            (result, stream_accumulator)
433        }));
434
435        let mut next_sequence_number = 0;
436        loop {
437            tokio::select! {
438                Some(event) = event_rx.recv() => {
439                    yield consume_stream_event(event, &mut next_sequence_number);
440                }
441                result = &mut run_handle.handle => {
442                    match result {
443                        Err(e) => {
444                            for chunk in panicked_stream_chunks(&e, &mut event_rx, &mut next_sequence_number) {
445                                yield chunk;
446                            }
447                        }
448                        Ok((Err(e), mut stream_accumulator)) => {
449                            while let Ok(event) = event_rx.try_recv() {
450                                yield consume_stream_event(event, &mut next_sequence_number);
451                            }
452                            yield stream_accumulator.executor_error_chunk(&e);
453                            yield DONE_MARKER.to_string();
454                        }
455                        Ok((Ok((payload, ctx)), mut stream_accumulator)) => {
456                            while let Ok(event) = event_rx.try_recv() {
457                                yield consume_stream_event(event, &mut next_sequence_number);
458                            }
459                            // Codex may close its WebSocket as soon as it receives
460                            // `response.completed`. Persist before exposing that
461                            // event so a custom call/output continuation cannot be
462                            // cancelled by the client disconnect.
463                            let ch = exec_ctx.conv_handler.clone();
464                            let rh = exec_ctx.resp_handler.clone();
465                            let mut terminal_accumulator = stream_accumulator.clone();
466                            let terminal_chunk = terminal_accumulator.terminal_response_chunk(&payload);
467                            match persist_if_needed(payload, ctx, ch, rh).await {
468                                Ok(()) => match terminal_chunk {
469                                    Ok(chunk) => yield chunk,
470                                    Err(e) => yield stream_accumulator.executor_error_chunk(&e),
471                                },
472                                Err(e) => yield stream_accumulator.executor_error_chunk(&e),
473                            }
474                            yield DONE_MARKER.to_string();
475                        }
476                    }
477                    break;
478                }
479            }
480        }
481    })
482}
483
484fn consume_stream_event(event: StreamEvent, next_sequence_number: &mut u64) -> String {
485    *next_sequence_number = event.sequence_number.saturating_add(1);
486    event.content
487}
488
489fn stream_task_failure_chunk(error: &tokio::task::JoinError, sequence_number: u64) -> String {
490    error_sse_chunk(&format!("stream task failed: {error}"), sequence_number)
491}
492
493fn panicked_stream_chunks(
494    error: &tokio::task::JoinError,
495    event_rx: &mut mpsc::UnboundedReceiver<StreamEvent>,
496    next_sequence_number: &mut u64,
497) -> Vec<String> {
498    let mut chunks = Vec::new();
499    while let Ok(event) = event_rx.try_recv() {
500        chunks.push(consume_stream_event(event, next_sequence_number));
501    }
502    chunks.push(stream_task_failure_chunk(error, *next_sequence_number));
503    chunks.push(DONE_MARKER.to_owned());
504    chunks
505}
506
507/// Create a new conversation and return its data.
508///
509/// Exposes the conversation-creation step as a standalone function so callers
510/// (e.g. `agentic-server`, Praxis filters, or tests) can pre-create a
511/// conversation before submitting response turns.
512///
513/// # Errors
514/// Returns [`ExecutorError`] if the conversation store is unavailable.
515pub async fn create_conversation(exec_ctx: &ExecutionContext) -> ExecutorResult<crate::ConversationData> {
516    exec_ctx.conv_handler.create().await
517}
518
519/// Builder for a stateful conversation turn.
520///
521/// ```ignore
522/// ExecuteRequest::new(payload, exec_ctx).with_auth(token).run().await
523/// ```
524pub struct ExecuteRequest {
525    payload: RequestPayload,
526    exec_ctx: Arc<ExecutionContext>,
527    client_auth: Option<String>,
528}
529
530impl ExecuteRequest {
531    #[must_use]
532    pub fn new(payload: RequestPayload, exec_ctx: Arc<ExecutionContext>) -> Self {
533        Self {
534            payload,
535            exec_ctx,
536            client_auth: None,
537        }
538    }
539
540    /// Override the bearer token for this request only; does not touch the shared [`ExecutionContext`].
541    #[must_use]
542    pub fn with_auth(mut self, token: Option<String>) -> Self {
543        self.client_auth = token;
544        self
545    }
546
547    /// Execute one stateful conversation turn.
548    ///
549    /// Returns `Either::Left(ResponsePayload)` for non-streaming requests, or
550    /// `Either::Right(BoxStream)` for streaming, where each yielded `String` is
551    /// a complete SSE frame ready to forward to the client.
552    ///
553    /// # Errors
554    /// Returns [`ExecutorError`] if rehydration or (non-streaming) LLM inference fails.
555    pub async fn run(self) -> ExecutorResult<Either<ResponsePayload, BoxStream>> {
556        debug!(
557            model = %self.payload.model,
558            store = self.payload.store,
559            stream = self.payload.stream,
560            has_previous_response_id = self.payload.previous_response_id.is_some(),
561            has_conversation_id = self.payload.conversation_id.is_some(),
562            tools = self.payload.tools.as_ref().map_or(0, Vec::len),
563            "executor received responses request"
564        );
565        let ctx = rehydrate_conversation(self.payload, &self.exec_ctx).await?;
566        if ctx.original_request.stream {
567            Ok(Either::Right(run_stream(ctx, self.exec_ctx, self.client_auth)))
568        } else {
569            Ok(Either::Left(
570                run_blocking(ctx, &self.exec_ctx, self.client_auth.as_deref()).await?,
571            ))
572        }
573    }
574}
575
576/// Execute one stateful conversation turn.
577///
578/// Thin shim over [`ExecuteRequest`] for callers that don't need per-request auth override.
579///
580/// # Errors
581/// Returns [`ExecutorError`] if rehydration or (non-streaming) LLM inference fails.
582pub async fn execute(
583    request: RequestPayload,
584    exec_ctx: Arc<ExecutionContext>,
585) -> ExecutorResult<Either<ResponsePayload, BoxStream>> {
586    ExecuteRequest::new(request, exec_ctx).run().await
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::executor::modes::{ConversationHandler, ResponseHandler};
593    use crate::storage::{ConversationStore, InOutItem, ResponseStore, create_pool_with_schema};
594    use futures::StreamExt;
595    use std::sync::Arc;
596    use tokio::sync::Mutex;
597
598    fn summary_upstream_response() -> serde_json::Value {
599        serde_json::json!({
600            "id": "resp_upstream",
601            "object": "response",
602            "created_at": 0,
603            "model": "test-model",
604            "status": "completed",
605            "output": [{
606                "id": "msg_upstream",
607                "type": "message",
608                "role": "assistant",
609                "status": "completed",
610                "content": [{
611                    "type": "output_text",
612                    "text": "durable summary",
613                    "annotations": []
614                }]
615            }],
616            "usage": {
617                "input_tokens": 12,
618                "output_tokens": 3,
619                "total_tokens": 15
620            },
621            "incomplete_details": null,
622            "error": null,
623            "previous_response_id": null,
624            "conversation_id": null,
625            "instructions": null
626        })
627    }
628
629    async fn trigger_execution_context(
630        captured: Arc<Mutex<Option<serde_json::Value>>>,
631    ) -> (ExecutionContext, tokio::task::JoinHandle<()>) {
632        let captured_for_route = Arc::clone(&captured);
633        let app = axum::Router::new().route(
634            "/v1/responses",
635            axum::routing::post(move |body: axum::body::Bytes| async move {
636                let value =
637                    serde_json::from_slice::<serde_json::Value>(&body).expect("captured upstream body must be JSON");
638                *captured_for_route.lock().await = Some(value);
639                axum::Json(summary_upstream_response())
640            }),
641        );
642        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
643            .await
644            .expect("bind mock inference server");
645        let address = listener.local_addr().expect("mock server address");
646        let server = tokio::spawn(async move {
647            axum::serve(listener, app).await.ok();
648        });
649        let exec_ctx = ExecutionContext::new(
650            ConversationHandler::new(ConversationStore::disabled()),
651            ResponseHandler::new(ResponseStore::disabled()),
652            Arc::new(reqwest::Client::new()),
653            format!("http://{address}"),
654        );
655        (exec_ctx, server)
656    }
657
658    #[tokio::test]
659    async fn compaction_trigger_returns_single_compaction_item_without_upstream_trigger() {
660        let captured = Arc::new(Mutex::new(None));
661        let (exec_ctx, server) = trigger_execution_context(Arc::clone(&captured)).await;
662
663        let payload: RequestPayload = serde_json::from_value(serde_json::json!({
664            "model": "test-model",
665            "stream": false,
666            "store": false,
667            "input": [
668                {"role": "user", "content": "remember banana"},
669                {"type": "compaction_trigger"}
670            ]
671        }))
672        .expect("valid trigger request");
673        let Either::Left(response) = ExecuteRequest::new(payload, Arc::new(exec_ctx))
674            .run()
675            .await
676            .expect("trigger request succeeds")
677        else {
678            panic!("non-streaming trigger request must return a payload");
679        };
680
681        assert_eq!(response.status, "completed");
682        assert_eq!(response.output.len(), 1);
683        let OutputItem::Compaction(item) = &response.output[0] else {
684            panic!("expected exactly one compaction output item");
685        };
686        assert_eq!(item.encrypted_content, "durable summary");
687        assert!(item.id.as_deref().is_some_and(|id| id.starts_with("cmp_")));
688        assert_eq!(response.usage.as_ref().map(|usage| usage.total_tokens), Some(15));
689
690        let upstream = captured.lock().await.take().expect("summary inference ran");
691        assert!(
692            !upstream.to_string().contains("compaction_trigger"),
693            "trigger must never reach the upstream model"
694        );
695        assert!(upstream.to_string().contains("CONTEXT CHECKPOINT COMPACTION"));
696        server.abort();
697    }
698
699    #[tokio::test]
700    async fn compaction_trigger_persists_checkpoint_only_as_output() {
701        let captured = Arc::new(Mutex::new(None));
702        let (mut exec_ctx, server) = trigger_execution_context(Arc::clone(&captured)).await;
703        let pool = create_pool_with_schema(Some("sqlite::memory:"))
704            .await
705            .expect("create response store");
706        let response_store = ResponseStore::new(pool);
707        exec_ctx.resp_handler = ResponseHandler::new(response_store.clone());
708
709        let payload: RequestPayload = serde_json::from_value(serde_json::json!({
710            "model": "test-model",
711            "store": true,
712            "input": [
713                {"role": "user", "content": "remember banana"},
714                {"type": "compaction_trigger"}
715            ]
716        }))
717        .expect("valid trigger request");
718        let Either::Left(response) = ExecuteRequest::new(payload, Arc::new(exec_ctx))
719            .run()
720            .await
721            .expect("trigger request succeeds")
722        else {
723            panic!("non-streaming trigger request must return a payload");
724        };
725
726        let history = response_store
727            .rehydrate(&response.id)
728            .await
729            .expect("compaction trigger response rehydrates");
730        assert_eq!(history.len(), 2);
731        assert!(matches!(history[0], InOutItem::Input(InputItem::Message(_))));
732        assert!(matches!(history[1], InOutItem::Output(OutputItem::Compaction(_))));
733
734        let model_input = ResponsesInput::Items(InOutItem::into_input_items(history));
735        let serialized = serde_json::to_value(model_input.model_input()).expect("model input serializes");
736        assert_eq!(serialized.as_array().map(Vec::len), Some(2));
737        assert_eq!(serialized[0]["content"], "remember banana");
738        assert_eq!(serialized[1]["role"], "assistant");
739        assert_eq!(serialized[1]["content"][0]["text"], "durable summary");
740        server.abort();
741    }
742
743    #[tokio::test]
744    async fn compaction_trigger_streams_one_compaction_item_then_completed() {
745        let captured = Arc::new(Mutex::new(None));
746        let (exec_ctx, server) = trigger_execution_context(Arc::clone(&captured)).await;
747
748        let payload: RequestPayload = serde_json::from_value(serde_json::json!({
749            "model": "test-model",
750            "stream": true,
751            "store": false,
752            "input": [
753                {"role": "user", "content": "remember banana"},
754                {"type": "compaction_trigger"}
755            ]
756        }))
757        .expect("valid trigger request");
758        let Either::Right(stream) = ExecuteRequest::new(payload, Arc::new(exec_ctx))
759            .run()
760            .await
761            .expect("trigger request succeeds")
762        else {
763            panic!("streaming trigger request must return a stream");
764        };
765
766        let chunks: Vec<String> = stream.collect().await;
767        let mut event_types = Vec::new();
768        let mut compaction_done_count = 0;
769        for chunk in chunks {
770            let body = chunk.strip_suffix("\n\n").expect("SSE frame terminator");
771            let data = body
772                .lines()
773                .find_map(|line| line.strip_prefix("data: "))
774                .expect("SSE data line");
775            let Ok(event) = serde_json::from_str::<serde_json::Value>(data) else {
776                continue; // terminal [DONE] marker
777            };
778            let event_type = event["type"].as_str().expect("event type");
779            event_types.push(event_type.to_owned());
780            if matches!(event_type, "response.created" | "response.in_progress") {
781                assert_eq!(event["response"]["output"], serde_json::json!([]));
782                assert!(event["response"]["usage"].is_null());
783            }
784            if event_type == "response.output_item.done" && event["item"]["type"] == "compaction" {
785                compaction_done_count += 1;
786                assert_eq!(event["item"]["encrypted_content"], "durable summary");
787            }
788        }
789
790        assert_eq!(
791            event_types,
792            [
793                "response.created",
794                "response.in_progress",
795                "response.output_item.added",
796                "response.output_item.done",
797                "response.completed",
798            ]
799        );
800        assert_eq!(compaction_done_count, 1);
801        assert!(
802            !captured
803                .lock()
804                .await
805                .take()
806                .expect("summary inference ran")
807                .to_string()
808                .contains("compaction_trigger")
809        );
810        server.abort();
811    }
812
813    #[tokio::test]
814    async fn stream_task_panic_after_event_uses_next_sequence_number_for_error() {
815        let accumulator = GatewayStreamAccumulator::new();
816        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
817        let task = tokio::spawn(async move {
818            let mut accumulator = accumulator;
819            let event = accumulator
820                .process_sse_line(r#"data: {"type":"response.created"}"#, 0)
821                .expect("event should be emitted");
822            event_tx
823                .send(StreamEvent {
824                    content: "event".to_owned(),
825                    sequence_number: event.sequence_number().expect("event should be numbered"),
826                })
827                .expect("test receiver should remain open");
828            panic!("test task panic");
829        });
830
831        let error = task.await.expect_err("task should panic");
832        let mut next_sequence_number = 0;
833        let chunks = panicked_stream_chunks(&error, &mut event_rx, &mut next_sequence_number);
834        let mut error_lines = chunks[1].lines();
835        assert_eq!(error_lines.next(), Some("event: error"));
836        let error_data = error_lines
837            .next()
838            .and_then(|line| line.strip_prefix("data: "))
839            .expect("SSE data");
840        assert!(error_lines.all(str::is_empty), "unexpected SSE frame content");
841        let error_event: serde_json::Value =
842            serde_json::from_str(error_data).expect("error chunk should be valid JSON");
843
844        assert_eq!(chunks[0], "event");
845        assert_eq!(error_event["type"], "error");
846        assert_eq!(error_event["sequence_number"], 1);
847        assert_eq!(chunks[2], DONE_MARKER);
848    }
849}