1use 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 ®istry,
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 ¤t_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(¤t_output, ®istry);
173 let gateway_results = execute_and_emit_round_output_calls(
174 ¤t_output,
175 ®istry,
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(¤t_output, ®istry, &gateway_results);
185 combined_output.extend(public_output);
186
187 match classify_round(has_client_owned, &gateway_results, round, MAX_GATEWAY_TOOL_ROUNDS) {
188 LoopDecision::RequiresClientAction => {
192 append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry);
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 LoopDecision::Done => {
202 finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
203 return Ok((payload, ctx));
204 }
205 LoopDecision::Incomplete(reason) => {
211 append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry);
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 LoopDecision::Continue => {
223 ctx.enriched_request.tool_choice = Some(ToolChoice::Auto);
224 append_output_items_to_input(&mut ctx.enriched_request.input, ¤t_output);
225 append_gateway_calls_to_new_input(&mut ctx, ¤t_output, ®istry);
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
237async 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
388fn 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 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
507pub async fn create_conversation(exec_ctx: &ExecutionContext) -> ExecutorResult<crate::ConversationData> {
516 exec_ctx.conv_handler.create().await
517}
518
519pub 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 #[must_use]
542 pub fn with_auth(mut self, token: Option<String>) -> Self {
543 self.client_auth = token;
544 self
545 }
546
547 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
576pub 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; };
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}