mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
Wire bounded output collection into the code mode runtime (#57850)
## Why Bounding collected output also requires limiting the raw event backlog between V8 and the cell actor. Previously, runtime events and cell output accumulated in an unbounded channel and vector. ## What changed - Select the event transport and `OutputCollector` from the cell's `output_truncation` mode. Server mode uses an ordered, bounded channel with backpressure; client mode retains untruncated output. - Transfer the collector with each observation and restore it on failed delivery, preserving retained media and omission metadata. - Include server-mode script errors in the observation's output budget. Build completion output only after completion wins against termination, so a rejected error cannot evict output returned by termination. - Keep the public service on client-side truncation. ## Testing Add regression coverage for channel backpressure, event ordering, receiver closure, failed observation delivery, termination during completion commit, and oversized errors across UTF-8 and omission boundaries. GitOrigin-RevId: 7604b60416e33c636ca41138be65dc0fd452774a
This commit is contained in:
@@ -40,7 +40,7 @@ impl CellHost for PanickingCallbackHost {
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
_event: CellEvent,
|
||||
_event: impl FnOnce() -> CellEvent + Send,
|
||||
_cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
panic!("unexpected completion commit");
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
use codex_code_mode_protocol::CodeModeToolKind;
|
||||
use codex_code_mode_protocol::ExecuteRequest;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::ImageDetail;
|
||||
use codex_code_mode_protocol::ToolDefinition;
|
||||
use codex_protocol::ToolName;
|
||||
|
||||
use crate::session_runtime::CreateCellRequest as CellRequest;
|
||||
use crate::session_runtime::ImageDetail as CellImageDetail;
|
||||
use crate::session_runtime::OutputItem as CellOutputItem;
|
||||
use crate::session_runtime::ToolKind as CellToolKind;
|
||||
|
||||
pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest {
|
||||
@@ -44,21 +40,3 @@ pub(super) fn cell_tool_kind(kind: CodeModeToolKind) -> CellToolKind {
|
||||
CodeModeToolKind::Freeform => CellToolKind::Freeform,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_item(item: FunctionCallOutputContentItem) -> CellOutputItem {
|
||||
match item {
|
||||
FunctionCallOutputContentItem::InputText { text } => CellOutputItem::Text { text },
|
||||
FunctionCallOutputContentItem::InputImage { image_url, detail } => CellOutputItem::Image {
|
||||
image_url,
|
||||
detail: detail.map(|detail| match detail {
|
||||
ImageDetail::Auto => CellImageDetail::Auto,
|
||||
ImageDetail::Low => CellImageDetail::Low,
|
||||
ImageDetail::High => CellImageDetail::High,
|
||||
ImageDetail::Original => CellImageDetail::Original,
|
||||
}),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url } => {
|
||||
CellOutputItem::Audio { audio_url }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ use self::callbacks::report_task_result;
|
||||
use self::callbacks::spawn_notification;
|
||||
use self::callbacks::spawn_tool;
|
||||
use self::conversions::cell_tool_kind;
|
||||
use self::conversions::output_item;
|
||||
use self::conversions::runtime_request;
|
||||
use self::types::CellCommand;
|
||||
pub(crate) use self::types::CellError;
|
||||
@@ -31,16 +30,18 @@ pub(crate) use self::types::CompletionCommit;
|
||||
use self::types::CompletionDelivery;
|
||||
use self::types::ObservationDelivery;
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::output::OutputCollector;
|
||||
use crate::runtime::PendingRuntimeMode;
|
||||
use crate::runtime::RuntimeCommand;
|
||||
use crate::runtime::RuntimeControlCommand;
|
||||
use crate::runtime::RuntimeEvent;
|
||||
use crate::runtime::RuntimeEventReceiver;
|
||||
use crate::runtime::runtime_event_channel;
|
||||
use crate::runtime::spawn_runtime;
|
||||
use crate::session_runtime::CellEvent;
|
||||
use crate::session_runtime::CreateCellRequest as CellRequest;
|
||||
use crate::session_runtime::Observation;
|
||||
use crate::session_runtime::ObserveMode;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use crate::session_runtime::ToolName as CellToolName;
|
||||
|
||||
pub(crate) struct CellActor;
|
||||
@@ -61,7 +62,8 @@ impl CellActor {
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (event_tx, event_rx) = runtime_event_channel(request.output_truncation);
|
||||
let output = OutputCollector::new(request.output_truncation);
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
let (initial_response_tx, initial_response_rx) = oneshot::channel();
|
||||
let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
|
||||
@@ -79,6 +81,7 @@ impl CellActor {
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
output,
|
||||
},
|
||||
event_rx,
|
||||
command_rx,
|
||||
@@ -99,6 +102,7 @@ struct CellContext {
|
||||
runtime_control_tx: std::sync::mpsc::Sender<RuntimeControlCommand>,
|
||||
runtime_terminate_handle: v8::IsolateHandle,
|
||||
cell_state: Arc<CellState>,
|
||||
output: OutputCollector,
|
||||
}
|
||||
|
||||
struct Observer {
|
||||
@@ -109,7 +113,7 @@ struct Observer {
|
||||
async fn run_cell<H: CellHost>(
|
||||
host: Arc<H>,
|
||||
context: CellContext,
|
||||
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
|
||||
mut event_rx: RuntimeEventReceiver,
|
||||
command_rx: mpsc::UnboundedReceiver<CellCommand>,
|
||||
initial_observer: Observer,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
@@ -119,10 +123,10 @@ async fn run_cell<H: CellHost>(
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
mut output,
|
||||
} = context;
|
||||
let cancellation_token = cell_state.cancellation_token();
|
||||
let callback_cancellation_token = cancellation_token.child_token();
|
||||
let mut content_items = Vec::new();
|
||||
let mut pending_tool_call_ids = Vec::new();
|
||||
let mut pending_frontier_ready = false;
|
||||
let mut yield_signal = Some(initial_observer.observation.yield_signal.clone());
|
||||
@@ -164,7 +168,7 @@ async fn run_cell<H: CellHost>(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
content_items: output.take(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -202,27 +206,8 @@ async fn run_cell<H: CellHost>(
|
||||
continue;
|
||||
}
|
||||
if matches!(mode, ObserveMode::PendingFrontier) && pending_frontier_ready {
|
||||
pending_frontier_ready = false;
|
||||
match send_cell_event(
|
||||
response_tx,
|
||||
CellEvent::Pending {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
pending_tool_call_ids: std::mem::take(&mut pending_tool_call_ids),
|
||||
},
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(CellEvent::Pending {
|
||||
content_items: undelivered_items,
|
||||
pending_tool_call_ids: undelivered_tool_call_ids,
|
||||
}) => {
|
||||
content_items = undelivered_items;
|
||||
pending_tool_call_ids = undelivered_tool_call_ids;
|
||||
pending_frontier_ready = true;
|
||||
}
|
||||
Err(event) => {
|
||||
panic!("pending delivery returned an unexpected event: {event:?}")
|
||||
}
|
||||
}
|
||||
pending_frontier_ready =
|
||||
!send_pending(&mut output, response_tx, &mut pending_tool_call_ids);
|
||||
continue;
|
||||
}
|
||||
yield_signal = Some(observation.yield_signal.clone());
|
||||
@@ -259,19 +244,13 @@ async fn run_cell<H: CellHost>(
|
||||
} => {
|
||||
yield_timer = None;
|
||||
yield_signal = None;
|
||||
restore_undelivered_yield(
|
||||
send_observer_event(
|
||||
observer.take(),
|
||||
CellEvent::Yielded {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
),
|
||||
&mut content_items,
|
||||
);
|
||||
if let Some(observer) = observer.take() {
|
||||
send_yield(&mut output, observer.response_tx);
|
||||
}
|
||||
}
|
||||
maybe_event = async {
|
||||
if runtime_closed {
|
||||
std::future::pending::<Option<RuntimeEvent>>().await
|
||||
std::future::pending().await
|
||||
} else {
|
||||
event_rx.recv().await
|
||||
}
|
||||
@@ -292,7 +271,7 @@ async fn run_cell<H: CellHost>(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
content_items: output.take(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -313,22 +292,17 @@ async fn run_cell<H: CellHost>(
|
||||
task_failure_handler.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let event = CellEvent::Completed {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
result_buffered_for: std::time::Duration::ZERO,
|
||||
error_text: Some("exec runtime ended unexpectedly".to_string()),
|
||||
};
|
||||
let rejected_event = match host
|
||||
host
|
||||
.commit_completion(
|
||||
HashMap::new(),
|
||||
event,
|
||||
|| {
|
||||
take_completed_event(&mut output, Some(
|
||||
"exec runtime ended unexpectedly".to_string(),
|
||||
))
|
||||
},
|
||||
Arc::clone(&cell_state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
CompletionCommit::Committed => None,
|
||||
CompletionCommit::Rejected(event) => Some(event),
|
||||
};
|
||||
.await;
|
||||
match cell_state.deliver_completion(
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
) {
|
||||
@@ -339,7 +313,7 @@ async fn run_cell<H: CellHost>(
|
||||
&cell_state,
|
||||
response_tx,
|
||||
CellEvent::Terminated {
|
||||
content_items: rejected_completion_content(rejected_event),
|
||||
content_items: output.take(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -356,59 +330,29 @@ async fn run_cell<H: CellHost>(
|
||||
}
|
||||
RuntimeEvent::Pending => {
|
||||
runtime_paused = true;
|
||||
if matches!(
|
||||
observer.as_ref().map(|observer| observer.observation.mode),
|
||||
Some(ObserveMode::PendingFrontier)
|
||||
) {
|
||||
if let Some(observer) = observer.take_if(|observer| {
|
||||
matches!(observer.observation.mode, ObserveMode::PendingFrontier)
|
||||
}) {
|
||||
yield_timer = None;
|
||||
yield_signal = None;
|
||||
pending_frontier_ready = false;
|
||||
match send_observer_event(
|
||||
observer.take(),
|
||||
CellEvent::Pending {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
pending_tool_call_ids: std::mem::take(
|
||||
&mut pending_tool_call_ids,
|
||||
),
|
||||
},
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(CellEvent::Pending {
|
||||
content_items: undelivered_items,
|
||||
pending_tool_call_ids: undelivered_tool_call_ids,
|
||||
}) => {
|
||||
content_items = undelivered_items;
|
||||
pending_tool_call_ids = undelivered_tool_call_ids;
|
||||
pending_frontier_ready = true;
|
||||
}
|
||||
Err(event) => {
|
||||
panic!("pending delivery returned an unexpected event: {event:?}")
|
||||
}
|
||||
}
|
||||
pending_frontier_ready =
|
||||
!send_pending(&mut output, observer.response_tx, &mut pending_tool_call_ids);
|
||||
} else {
|
||||
pending_tool_call_ids.clear();
|
||||
let _ = runtime_control_tx.send(RuntimeControlCommand::Continue);
|
||||
runtime_paused = false;
|
||||
}
|
||||
}
|
||||
RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)),
|
||||
RuntimeEvent::ContentItem(item) => {
|
||||
output.push(item);
|
||||
}
|
||||
RuntimeEvent::YieldRequested => {
|
||||
let yield_observer = matches!(
|
||||
observer.as_ref().map(|observer| observer.observation.mode),
|
||||
Some(ObserveMode::YieldAfter(_))
|
||||
);
|
||||
if yield_observer {
|
||||
if let Some(observer) = observer.take_if(|observer| {
|
||||
matches!(observer.observation.mode, ObserveMode::YieldAfter(_))
|
||||
}) {
|
||||
yield_timer = None;
|
||||
yield_signal = None;
|
||||
restore_undelivered_yield(
|
||||
send_observer_event(
|
||||
observer.take(),
|
||||
CellEvent::Yielded {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
},
|
||||
),
|
||||
&mut content_items,
|
||||
);
|
||||
send_yield(&mut output, observer.response_tx);
|
||||
}
|
||||
}
|
||||
RuntimeEvent::Notify { call_id, text } => {
|
||||
@@ -456,7 +400,7 @@ async fn run_cell<H: CellHost>(
|
||||
&cell_state,
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
CellEvent::Terminated {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
content_items: output.take(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -469,22 +413,13 @@ async fn run_cell<H: CellHost>(
|
||||
task_failure_handler.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let event = CellEvent::Completed {
|
||||
content_items: std::mem::take(&mut content_items),
|
||||
result_buffered_for: std::time::Duration::ZERO,
|
||||
error_text,
|
||||
};
|
||||
let rejected_event = match host
|
||||
host
|
||||
.commit_completion(
|
||||
stored_value_writes,
|
||||
event,
|
||||
|| take_completed_event(&mut output, error_text),
|
||||
Arc::clone(&cell_state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
CompletionCommit::Committed => None,
|
||||
CompletionCommit::Rejected(event) => Some(event),
|
||||
};
|
||||
.await;
|
||||
match cell_state.deliver_completion(
|
||||
observer.take().map(|observer| observer.response_tx),
|
||||
) {
|
||||
@@ -495,7 +430,7 @@ async fn run_cell<H: CellHost>(
|
||||
&cell_state,
|
||||
response_tx,
|
||||
CellEvent::Terminated {
|
||||
content_items: rejected_completion_content(rejected_event),
|
||||
content_items: output.take(),
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -539,11 +474,14 @@ async fn run_cell<H: CellHost>(
|
||||
host.closed().await;
|
||||
}
|
||||
|
||||
fn send_observer_event(observer: Option<Observer>, event: CellEvent) -> Result<(), CellEvent> {
|
||||
let Some(observer) = observer else {
|
||||
return Err(event);
|
||||
};
|
||||
send_cell_event(observer.response_tx, event)
|
||||
/// Construct completion only after it has won the terminal decision.
|
||||
fn take_completed_event(output: &mut OutputCollector, error_text: Option<String>) -> CellEvent {
|
||||
let error_text = error_text.map(|error_text| output.collect_error(error_text));
|
||||
CellEvent::Completed {
|
||||
content_items: output.take(),
|
||||
error_text,
|
||||
result_buffered_for: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_cell_event(
|
||||
@@ -557,25 +495,42 @@ fn send_cell_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_undelivered_yield(delivery: Result<(), CellEvent>, content_items: &mut Vec<OutputItem>) {
|
||||
match delivery {
|
||||
Ok(()) => {}
|
||||
Err(CellEvent::Yielded {
|
||||
content_items: mut undelivered_items,
|
||||
}) => {
|
||||
undelivered_items.append(content_items);
|
||||
*content_items = undelivered_items;
|
||||
}
|
||||
Err(event) => panic!("yield delivery returned an unexpected event: {event:?}"),
|
||||
}
|
||||
fn send_yield(
|
||||
output: &mut OutputCollector,
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
) {
|
||||
output.try_deliver(|content_items| {
|
||||
send_cell_event(response_tx, CellEvent::Yielded { content_items }).map_err(|event| {
|
||||
let CellEvent::Yielded { content_items } = event else {
|
||||
unreachable!("yield delivery returned a different event");
|
||||
};
|
||||
content_items
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn rejected_completion_content(event: Option<CellEvent>) -> Vec<OutputItem> {
|
||||
match event {
|
||||
Some(CellEvent::Completed { content_items, .. }) => content_items,
|
||||
None => Vec::new(),
|
||||
Some(event) => panic!("completion commit rejected an unexpected event: {event:?}"),
|
||||
}
|
||||
fn send_pending(
|
||||
output: &mut OutputCollector,
|
||||
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
|
||||
pending_tool_call_ids: &mut Vec<String>,
|
||||
) -> bool {
|
||||
output.try_deliver(|content_items| {
|
||||
let pending = CellEvent::Pending {
|
||||
content_items,
|
||||
pending_tool_call_ids: std::mem::take(pending_tool_call_ids),
|
||||
};
|
||||
send_cell_event(response_tx, pending).map_err(|event| {
|
||||
let CellEvent::Pending {
|
||||
content_items,
|
||||
pending_tool_call_ids: returned_ids,
|
||||
} = event
|
||||
else {
|
||||
unreachable!("pending delivery returned a different event");
|
||||
};
|
||||
*pending_tool_call_ids = returned_ids;
|
||||
content_items
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_termination(
|
||||
|
||||
@@ -5,8 +5,9 @@ use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_code_mode_protocol::CodeModeOutputTruncation;
|
||||
use codex_code_mode_protocol::ExecuteRequest;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::host::BOUNDED_OUTPUT_MAX_IMAGE_BYTES;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -14,9 +15,14 @@ use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::runtime::RuntimeEventSender;
|
||||
use crate::session_runtime::OutputItem;
|
||||
|
||||
struct TestHost;
|
||||
#[derive(Default)]
|
||||
struct TestHost {
|
||||
commit_started: CancellationToken,
|
||||
resume_commit: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingHost {
|
||||
@@ -44,9 +50,13 @@ impl CellHost for TestHost {
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
event: CellEvent,
|
||||
event: impl FnOnce() -> CellEvent + Send,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
self.commit_started.cancel();
|
||||
if let Some(resume) = &self.resume_commit {
|
||||
resume.cancelled().await;
|
||||
}
|
||||
cell_state.commit_completion(event, || {})
|
||||
}
|
||||
|
||||
@@ -75,7 +85,7 @@ impl CellHost for RecordingHost {
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
_stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
event: CellEvent,
|
||||
event: impl FnOnce() -> CellEvent + Send,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
cell_state.commit_completion(event, || {})
|
||||
@@ -85,7 +95,7 @@ impl CellHost for RecordingHost {
|
||||
}
|
||||
|
||||
struct CellActorHarness {
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: RuntimeEventSender,
|
||||
handle: CellHandle,
|
||||
yield_signal: CancellationToken,
|
||||
initial_event_rx: oneshot::Receiver<Result<CellEvent, CellError>>,
|
||||
@@ -95,7 +105,7 @@ struct CellActorHarness {
|
||||
}
|
||||
|
||||
fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness {
|
||||
spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost))
|
||||
spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost::default()))
|
||||
}
|
||||
|
||||
fn spawn_cell_actor_harness_with_host<H: CellHost>(
|
||||
@@ -105,6 +115,7 @@ fn spawn_cell_actor_harness_with_host<H: CellHost>(
|
||||
spawn_cell_actor_harness_with_host_and_failure_handler(
|
||||
initial_observe_mode,
|
||||
host,
|
||||
CodeModeOutputTruncation::Client,
|
||||
/*task_failure_handler*/ None,
|
||||
)
|
||||
}
|
||||
@@ -112,9 +123,11 @@ fn spawn_cell_actor_harness_with_host<H: CellHost>(
|
||||
fn spawn_cell_actor_harness_with_host_and_failure_handler<H: CellHost>(
|
||||
initial_observe_mode: ObserveMode,
|
||||
host: Arc<H>,
|
||||
output_truncation: CodeModeOutputTruncation,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> CellActorHarness {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (event_tx, event_rx) = runtime_event_channel(output_truncation);
|
||||
let output = OutputCollector::new(output_truncation);
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
let (initial_event_tx, initial_event_rx) = oneshot::channel();
|
||||
let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel();
|
||||
@@ -143,6 +156,7 @@ fn spawn_cell_actor_harness_with_host_and_failure_handler<H: CellHost>(
|
||||
runtime_control_tx,
|
||||
runtime_terminate_handle,
|
||||
cell_state,
|
||||
output,
|
||||
},
|
||||
event_rx,
|
||||
command_rx,
|
||||
@@ -169,7 +183,8 @@ async fn unexpected_runtime_thread_exit_is_reported_to_the_session_owner() {
|
||||
let (failure_tx, mut failure_rx) = mpsc::unbounded_channel();
|
||||
let harness = spawn_cell_actor_harness_with_host_and_failure_handler(
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::new(TestHost),
|
||||
Arc::new(TestHost::default()),
|
||||
CodeModeOutputTruncation::Client,
|
||||
Some(Arc::new(move |reason| {
|
||||
let _ = failure_tx.send(reason);
|
||||
})),
|
||||
@@ -193,19 +208,74 @@ async fn unexpected_runtime_thread_exit_is_reported_to_the_session_owner() {
|
||||
harness.task.await.expect("cell task");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn termination_during_completion_commit_preserves_output_and_discards_losing_error() {
|
||||
for mode in [
|
||||
CodeModeOutputTruncation::Client,
|
||||
CodeModeOutputTruncation::Server,
|
||||
] {
|
||||
// The actor has received Result but cannot commit until the host releases
|
||||
// the session store. Termination must decide which output is published.
|
||||
let resume_commit = CancellationToken::new();
|
||||
let host = Arc::new(TestHost {
|
||||
resume_commit: Some(resume_commit.clone()),
|
||||
..TestHost::default()
|
||||
});
|
||||
let harness = spawn_cell_actor_harness_with_host_and_failure_handler(
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
mode,
|
||||
/*task_failure_handler*/ None,
|
||||
);
|
||||
// Simulate V8's dedicated thread, where server-mode sends can block
|
||||
// while the event queue is full.
|
||||
let event_tx = harness.event_tx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "output that must survive termination".to_string(),
|
||||
}))
|
||||
.expect("output");
|
||||
event_tx
|
||||
.blocking_send(RuntimeEvent::Result {
|
||||
stored_value_writes: HashMap::new(),
|
||||
error_text: Some("uncommitted error".repeat(128 * 1024)),
|
||||
})
|
||||
.expect("completion");
|
||||
})
|
||||
.await
|
||||
.expect("runtime producer");
|
||||
tokio::time::timeout(Duration::from_secs(5), host.commit_started.cancelled())
|
||||
.await
|
||||
.expect("commit reached");
|
||||
let termination = harness.handle.terminate();
|
||||
resume_commit.cancel();
|
||||
let mut expected_output = OutputCollector::new(mode);
|
||||
expected_output.push(OutputItem::InputText {
|
||||
text: "output that must survive termination".to_string(),
|
||||
});
|
||||
let expected = Ok(CellEvent::Terminated {
|
||||
content_items: expected_output,
|
||||
});
|
||||
assert_eq!(termination.await, expected, "{mode:?}");
|
||||
assert_eq!(harness.initial_event_rx.await.expect("observer"), expected);
|
||||
harness.task.await.expect("cell task");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_thread_panic_remains_a_cell_error_without_owner_supervision() {
|
||||
let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::from_secs(60)));
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ThreadPanicked)
|
||||
.blocking_send(RuntimeEvent::ThreadPanicked)
|
||||
.expect("runtime panic event");
|
||||
drop(harness.event_tx);
|
||||
|
||||
assert_eq!(
|
||||
harness.initial_event_rx.await.expect("initial event"),
|
||||
Ok(CellEvent::Completed {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: Some("exec runtime ended unexpectedly".to_string()),
|
||||
})
|
||||
@@ -226,20 +296,21 @@ async fn wait_for_notification(host: &RecordingHost) {
|
||||
#[tokio::test]
|
||||
async fn yield_timer_preempts_buffered_runtime_output() {
|
||||
let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO));
|
||||
harness.event_tx.send(RuntimeEvent::Started).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
},
|
||||
))
|
||||
.blocking_send(RuntimeEvent::Started)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
harness.initial_event_rx.await.unwrap(),
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -248,9 +319,9 @@ async fn yield_timer_preempts_buffered_runtime_output() {
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
}],
|
||||
}]),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
@@ -259,19 +330,23 @@ async fn yield_timer_preempts_buffered_runtime_output() {
|
||||
#[tokio::test]
|
||||
async fn queued_termination_preempts_unobserved_runtime_completion() {
|
||||
let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO));
|
||||
harness.event_tx.send(RuntimeEvent::Started).unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
},
|
||||
))
|
||||
.blocking_send(RuntimeEvent::Started)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Result {
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::Result {
|
||||
stored_value_writes: HashMap::new(),
|
||||
error_text: None,
|
||||
})
|
||||
@@ -280,9 +355,9 @@ async fn queued_termination_preempts_unobserved_runtime_completion() {
|
||||
let termination = harness.handle.terminate();
|
||||
|
||||
let terminated = Ok(CellEvent::Terminated {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "queued output".to_string(),
|
||||
}],
|
||||
}]),
|
||||
});
|
||||
assert_eq!(termination.await, terminated.clone());
|
||||
assert_eq!(harness.initial_event_rx.await.unwrap(), terminated);
|
||||
@@ -296,7 +371,10 @@ async fn observation_dropped_before_dequeue_does_not_consume_output() {
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
drop(
|
||||
@@ -306,16 +384,17 @@ async fn observation_dropped_before_dequeue_does_not_consume_output() {
|
||||
);
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "survives pre-dequeue cancellation".to_string(),
|
||||
},
|
||||
))
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "survives pre-dequeue cancellation".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-command".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
@@ -328,9 +407,9 @@ async fn observation_dropped_before_dequeue_does_not_consume_output() {
|
||||
.observe(ObserveMode::YieldAfter(Duration::ZERO))
|
||||
.await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "survives pre-dequeue cancellation".to_string(),
|
||||
}],
|
||||
}]),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -339,7 +418,7 @@ async fn observation_dropped_before_dequeue_does_not_consume_output() {
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
@@ -352,7 +431,10 @@ async fn dropped_yield_observer_preserves_output_for_the_next_observation() {
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
let dropped_observation = harness
|
||||
@@ -368,16 +450,17 @@ async fn dropped_yield_observer_preserves_output_for_the_next_observation() {
|
||||
drop(dropped_observation);
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "survives active cancellation".to_string(),
|
||||
},
|
||||
))
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "survives active cancellation".to_string(),
|
||||
}))
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-observer".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
@@ -390,9 +473,9 @@ async fn dropped_yield_observer_preserves_output_for_the_next_observation() {
|
||||
.observe(ObserveMode::YieldAfter(Duration::ZERO))
|
||||
.await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "survives active cancellation".to_string(),
|
||||
}],
|
||||
}]),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -401,7 +484,7 @@ async fn dropped_yield_observer_preserves_output_for_the_next_observation() {
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
@@ -414,7 +497,10 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
ObserveMode::YieldAfter(Duration::from_secs(60)),
|
||||
Arc::clone(&host),
|
||||
);
|
||||
harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::YieldRequested)
|
||||
.unwrap();
|
||||
assert!(harness.initial_event_rx.await.unwrap().is_ok());
|
||||
|
||||
let dropped_observation = harness.handle.observe(ObserveMode::PendingFrontier);
|
||||
@@ -423,9 +509,16 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
Err(CellError::Busy)
|
||||
);
|
||||
drop(dropped_observation);
|
||||
let item = OutputItem::InputText {
|
||||
text: "before frontier".to_owned(),
|
||||
};
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::ToolCall {
|
||||
.blocking_send(RuntimeEvent::ContentItem(item.clone()))
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ToolCall {
|
||||
id: "tool-1".to_string(),
|
||||
name: codex_protocol::ToolName {
|
||||
name: "echo".to_string(),
|
||||
@@ -435,10 +528,13 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
input: Some(serde_json::json!({})),
|
||||
})
|
||||
.unwrap();
|
||||
harness.event_tx.send(RuntimeEvent::Pending).unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.send(RuntimeEvent::Notify {
|
||||
.blocking_send(RuntimeEvent::Pending)
|
||||
.unwrap();
|
||||
harness
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::Notify {
|
||||
call_id: "after-dropped-pending".to_string(),
|
||||
text: "barrier".to_string(),
|
||||
})
|
||||
@@ -448,7 +544,7 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
assert_eq!(
|
||||
harness.handle.observe(ObserveMode::PendingFrontier).await,
|
||||
Ok(CellEvent::Pending {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(vec![item]),
|
||||
pending_tool_call_ids: vec!["tool-1".to_string()],
|
||||
})
|
||||
);
|
||||
@@ -462,7 +558,7 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
assert_eq!(
|
||||
termination.await,
|
||||
Ok(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
})
|
||||
);
|
||||
harness.task.await.unwrap();
|
||||
@@ -472,12 +568,12 @@ async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observatio
|
||||
async fn only_the_first_termination_claims_a_buffered_completion() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(completion.clone(), || {}),
|
||||
cell_state.commit_completion(|| completion.clone(), || {}),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
assert!(matches!(
|
||||
@@ -493,7 +589,7 @@ async fn only_the_first_termination_claims_a_buffered_completion() {
|
||||
assert_eq!(first_termination.await, Ok(completion.clone()));
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
}),
|
||||
Some(completion)
|
||||
);
|
||||
@@ -504,20 +600,17 @@ async fn termination_claim_prevents_stored_value_commit() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let termination = cell_state.request_termination();
|
||||
let mut commit_ran = false;
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: Vec::new(),
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(completion.clone(), || commit_ran = true),
|
||||
CompletionCommit::Rejected(completion)
|
||||
cell_state.commit_completion(
|
||||
|| panic!("terminated cell must not construct completion output"),
|
||||
|| commit_ran = true
|
||||
),
|
||||
CompletionCommit::Rejected
|
||||
);
|
||||
assert!(!commit_ran);
|
||||
|
||||
let terminated = CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(terminated.clone()),
|
||||
@@ -530,14 +623,14 @@ async fn termination_claim_prevents_stored_value_commit() {
|
||||
async fn failed_completion_delivery_rebuffers_the_event() {
|
||||
let cell_state = CellState::new(CancellationToken::new());
|
||||
let completion = |result_buffered_for| CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
}]),
|
||||
result_buffered_for,
|
||||
error_text: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.commit_completion(completion(Duration::ZERO), || {}),
|
||||
cell_state.commit_completion(|| completion(Duration::ZERO), || {}),
|
||||
CompletionCommit::Committed
|
||||
);
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
@@ -565,3 +658,49 @@ async fn failed_completion_delivery_rebuffers_the_event() {
|
||||
));
|
||||
assert_eq!(response_rx.try_recv(), Ok(Ok(completion(2 * interval))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_yield_restores_image_allocation_and_omission_order() {
|
||||
let mut output = OutputCollector::new(CodeModeOutputTruncation::Server);
|
||||
let image_url = "i".repeat(BOUNDED_OUTPUT_MAX_IMAGE_BYTES);
|
||||
let pointer = image_url.as_ptr();
|
||||
output.push(OutputItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
});
|
||||
output.push(OutputItem::InputImage {
|
||||
image_url: "excess".to_string(),
|
||||
detail: None,
|
||||
});
|
||||
let (tx, rx) = oneshot::channel();
|
||||
drop(rx);
|
||||
send_yield(&mut output, tx);
|
||||
|
||||
output.push(OutputItem::InputText {
|
||||
text: "after".to_string(),
|
||||
});
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
send_yield(&mut output, tx);
|
||||
let CellEvent::Yielded { content_items } = rx.try_recv().unwrap().unwrap() else {
|
||||
panic!("expected a yield");
|
||||
};
|
||||
let items = crate::output::render_output(
|
||||
content_items.into_collected_output(),
|
||||
/*max_output_tokens*/ None,
|
||||
);
|
||||
let [
|
||||
OutputItem::InputImage { image_url, .. },
|
||||
OutputItem::InputText { text: notice },
|
||||
OutputItem::InputText { text },
|
||||
] = items.as_slice()
|
||||
else {
|
||||
panic!("expected image, omission notice, and subsequent text");
|
||||
};
|
||||
assert_eq!(image_url.as_ptr(), pointer);
|
||||
assert!(notice.contains("1 images"), "{notice}");
|
||||
assert_eq!(text, "after");
|
||||
assert_eq!(
|
||||
output.take(),
|
||||
OutputCollector::new(CodeModeOutputTruncation::Server)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ pub(crate) trait CellHost: Send + Sync + 'static {
|
||||
fn commit_completion(
|
||||
&self,
|
||||
stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
event: CellEvent,
|
||||
event: impl FnOnce() -> CellEvent + Send,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> impl Future<Output = CompletionCommit> + Send;
|
||||
|
||||
@@ -136,7 +136,7 @@ pub(crate) enum CompletionDelivery {
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum CompletionCommit {
|
||||
Committed,
|
||||
Rejected(CellEvent),
|
||||
Rejected,
|
||||
}
|
||||
|
||||
pub(crate) enum ObservationDelivery {
|
||||
@@ -200,7 +200,7 @@ impl CellState {
|
||||
|
||||
pub(crate) fn commit_completion(
|
||||
&self,
|
||||
event: CellEvent,
|
||||
event: impl FnOnce() -> CellEvent,
|
||||
commit: impl FnOnce(),
|
||||
) -> CompletionCommit {
|
||||
let mut phase = self
|
||||
@@ -208,11 +208,13 @@ impl CellState {
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() {
|
||||
return CompletionCommit::Rejected(event);
|
||||
return CompletionCommit::Rejected;
|
||||
}
|
||||
commit();
|
||||
*phase = CellPhase::Completed {
|
||||
event,
|
||||
// Construct terminal output only after completion wins. In bounded
|
||||
// mode, an error may evict user output; termination must not see it.
|
||||
event: event(),
|
||||
completed_at: Instant::now(),
|
||||
};
|
||||
CompletionCommit::Committed
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
mod cell_actor;
|
||||
#[allow(
|
||||
dead_code,
|
||||
unused_imports,
|
||||
reason = "Wired into the runtime in the next stack layer"
|
||||
)]
|
||||
mod output;
|
||||
mod runtime;
|
||||
mod service;
|
||||
|
||||
@@ -11,6 +11,7 @@ mod output_collection;
|
||||
mod output_render;
|
||||
|
||||
pub(crate) use output_collection::BoundedOutput;
|
||||
#[allow(unused_imports, reason = "Renderer is wired in the next stack layer")]
|
||||
pub(crate) use output_render::render_output;
|
||||
|
||||
/// The actor transfers the whole collector at each observation, so a failed
|
||||
@@ -38,6 +39,20 @@ impl OutputCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Server errors share the observation's budget; preserve their presence
|
||||
/// for completion status. Client errors retain their original formatting.
|
||||
pub(crate) fn collect_error(&mut self, error_text: String) -> String {
|
||||
match self {
|
||||
Self::Client(_) => error_text,
|
||||
Self::Server(output) => {
|
||||
output
|
||||
.get_or_insert_with(Box::default)
|
||||
.push_error(&error_text);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take(&mut self) -> Self {
|
||||
match self {
|
||||
Self::Client(items) => Self::Client(std::mem::take(items)),
|
||||
|
||||
@@ -435,6 +435,34 @@ impl HeadTailOutput {
|
||||
}
|
||||
|
||||
impl BoundedOutput {
|
||||
/// A completed error is one logical text item, including its label. Do not
|
||||
/// allocate an arbitrarily large formatted copy before selecting its output.
|
||||
pub(crate) fn push_error(&mut self, error: &str) {
|
||||
const LABEL: &str = "Script error:\n";
|
||||
// No selection can retain more than MAX_OUTPUT_BYTES from either end
|
||||
// of this item. Keep both possible spans until push() decides how the
|
||||
// existing head, tail and item pressure affect them. Round outward so
|
||||
// a UTF-8 boundary cannot pull bytes from the prefix across the gap.
|
||||
let head_end = error.ceil_char_boundary(error.len().min(MAX_OUTPUT_BYTES - LABEL.len()));
|
||||
let tail_start =
|
||||
error.floor_char_boundary(error.len().saturating_sub(MAX_OUTPUT_BYTES).max(head_end));
|
||||
self.push(OutputItem::InputText {
|
||||
text: format!("{LABEL}{}{}", &error[..head_end], &error[tail_start..]),
|
||||
});
|
||||
let skipped = tail_start - head_end;
|
||||
if skipped > 0 {
|
||||
// This item alone exceeds the content budget. Its newest, retained
|
||||
// suffix owns the gap, even when an image separates it from older loss.
|
||||
let Retention::HeadTail(output) = &mut self.state else {
|
||||
unreachable!("an oversized error must enter head/tail retention");
|
||||
};
|
||||
let Some(RetainedItem::Partial(text)) = output.tail.back_mut() else {
|
||||
unreachable!("the tail retains the end of the oversized error");
|
||||
};
|
||||
text.omitted = text.omitted.saturating_add(skipped as u64);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, item: OutputItem) {
|
||||
if let Err(loss) = self.media.admit(&item) {
|
||||
self.push_retained(RetainedItem::Gap(loss));
|
||||
|
||||
@@ -299,3 +299,34 @@ fn repeated_eviction_keeps_recent_writes_and_gaps_on_each_side_of_an_image() {
|
||||
assert_eq!(after.omitted.text_bytes, (writes * chunk.len()) as u64);
|
||||
assert_eq!(after.omitted.text_items, (1 + writes - recent) as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_error_matches_ordinary_text_across_loss_boundaries() {
|
||||
let label = "Script error:\n";
|
||||
// Rounding inward at the emoji can wrongly pull XYZ into the retained suffix.
|
||||
let error = format!(
|
||||
"{}XYZ{}💡{}",
|
||||
"p".repeat(MAX_OUTPUT_BYTES - label.len() - 3),
|
||||
"m".repeat(22),
|
||||
"s".repeat(MAX_OUTPUT_BYTES - 3),
|
||||
);
|
||||
let half = text(&"p".repeat(MAX_OUTPUT_BYTES / 2));
|
||||
for (prefix, gap_count) in [
|
||||
(Vec::new(), 1),
|
||||
(vec![text(""); MAX_OUTPUT_ITEMS / 2], 1),
|
||||
(vec![half.clone(), text("lost"), image("i".into()), half], 2),
|
||||
] {
|
||||
let mut terminal = BoundedOutput::default();
|
||||
let mut ordinary = BoundedOutput::default();
|
||||
for item in prefix {
|
||||
terminal.push(item.clone());
|
||||
ordinary.push(item);
|
||||
}
|
||||
terminal.push_error(&error);
|
||||
ordinary.push(text(&format!("{label}{error}")));
|
||||
assert!(retained_capacity(&terminal) < MAX_OUTPUT_BYTES + TEXT_BLOCK_BYTES);
|
||||
let actual = terminal.into_collected_output();
|
||||
assert_eq!(actual.gaps.len(), gap_count);
|
||||
assert!(actual == ordinary.into_collected_output());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ const MAX_POSITIONED_NOTICES: usize = 4;
|
||||
/// budget: Core's warning describes the retained text alone. The shared
|
||||
/// formatter moves collector boundaries along with their source content.
|
||||
/// If losses are too scattered for individual notices, report their totals once.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
allow(dead_code, reason = "Renderer is wired in the next stack layer")
|
||||
)]
|
||||
pub(crate) fn render_output(
|
||||
output: CollectedOutput,
|
||||
max_output_tokens: Option<usize>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::RuntimeEvent;
|
||||
@@ -67,7 +67,7 @@ pub(super) fn tool_callback(
|
||||
state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
|
||||
let event_tx = state.event_tx.clone();
|
||||
state.pending_tool_calls.insert(id.clone(), resolver);
|
||||
let _ = event_tx.send(RuntimeEvent::ToolCall {
|
||||
let _ = event_tx.blocking_send(RuntimeEvent::ToolCall {
|
||||
id,
|
||||
name: tool_name,
|
||||
kind: tool_kind,
|
||||
@@ -96,9 +96,9 @@ pub(super) fn text_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
let _ = state
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText { text }));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
@@ -120,7 +120,9 @@ pub(super) fn audio_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(audio_item));
|
||||
let _ = state
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(audio_item));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
@@ -155,7 +157,9 @@ pub(super) fn image_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
let _ = state
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(image_item));
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
@@ -184,11 +188,13 @@ pub(super) fn generated_image_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
let _ = state
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(image_item));
|
||||
if let Some(text) = output_hint {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
let _ = state
|
||||
.event_tx
|
||||
.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText { text }));
|
||||
}
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
@@ -303,7 +309,7 @@ pub(super) fn notify_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::Notify {
|
||||
let _ = state.event_tx.blocking_send(RuntimeEvent::Notify {
|
||||
call_id: state.tool_call_id.clone(),
|
||||
text,
|
||||
});
|
||||
@@ -349,7 +355,7 @@ pub(super) fn yield_control_callback(
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>()
|
||||
&& !state.exit_requested
|
||||
{
|
||||
let _ = state.event_tx.send(RuntimeEvent::YieldRequested);
|
||||
let _ = state.event_tx.blocking_send(RuntimeEvent::YieldRequested);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::super::RuntimeEvent;
|
||||
use super::super::RuntimeState;
|
||||
use super::super::ToolCallbackMetadata;
|
||||
use super::super::globals;
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use crate::v8_init::ensure_v8_initialized;
|
||||
|
||||
// Deliberately leave JS running: the test must verify host-side admission even
|
||||
@@ -109,7 +109,7 @@ clearTimeout(earlyTimer); setTimeout(() => {}, 60000);
|
||||
)
|
||||
.expect("install globals");
|
||||
scope.set_slot(RuntimeState {
|
||||
event_tx,
|
||||
event_tx: event_tx.into(),
|
||||
pending_tool_calls: HashMap::new(),
|
||||
pending_timeouts: HashMap::new(),
|
||||
stored_values: HashMap::new(),
|
||||
@@ -152,7 +152,7 @@ const earlyTimer = setTimeout(() => text("early timer"), 60000);
|
||||
|
||||
assert!(
|
||||
matches!(event_rx.try_recv(), Ok(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text }
|
||||
OutputItem::InputText { text }
|
||||
)) if text == "before"),
|
||||
"{label}: pre-exit output"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Ordered runtime event transport with backpressure for bounded collectors.
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::RuntimeEvent;
|
||||
use codex_code_mode_protocol::CodeModeOutputTruncation;
|
||||
|
||||
// Amortize small-write handoffs while keeping the backlog of raw payloads
|
||||
// ahead of the bounded collector small.
|
||||
const EVENT_CAPACITY: usize = 4;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum RuntimeEventSender {
|
||||
Legacy(mpsc::UnboundedSender<RuntimeEvent>),
|
||||
Bounded(mpsc::Sender<RuntimeEvent>),
|
||||
}
|
||||
|
||||
pub(crate) enum RuntimeEventReceiver {
|
||||
Legacy(mpsc::UnboundedReceiver<RuntimeEvent>),
|
||||
Bounded(mpsc::Receiver<RuntimeEvent>),
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_event_channel(
|
||||
mode: CodeModeOutputTruncation,
|
||||
) -> (RuntimeEventSender, RuntimeEventReceiver) {
|
||||
match mode {
|
||||
CodeModeOutputTruncation::Client => {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
RuntimeEventSender::Legacy(tx),
|
||||
RuntimeEventReceiver::Legacy(rx),
|
||||
)
|
||||
}
|
||||
CodeModeOutputTruncation::Server => {
|
||||
let (tx, rx) = mpsc::channel(EVENT_CAPACITY);
|
||||
(
|
||||
RuntimeEventSender::Bounded(tx),
|
||||
RuntimeEventReceiver::Bounded(rx),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mpsc::UnboundedSender<RuntimeEvent>> for RuntimeEventSender {
|
||||
fn from(value: mpsc::UnboundedSender<RuntimeEvent>) -> Self {
|
||||
Self::Legacy(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEventReceiver {
|
||||
pub(crate) async fn recv(&mut self) -> Option<RuntimeEvent> {
|
||||
match self {
|
||||
Self::Legacy(rx) => rx.recv().await,
|
||||
Self::Bounded(rx) => rx.recv().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEventSender {
|
||||
/// Bounded sends run only on the dedicated V8 thread, preserving event order
|
||||
/// without making synchronous JavaScript output helpers return promises.
|
||||
/// Capacity limits the backlog between V8 and the cell actor.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// In server mode, panics if called from an async execution context.
|
||||
pub(crate) fn blocking_send(&self, event: RuntimeEvent) -> Result<(), ()> {
|
||||
match self {
|
||||
Self::Legacy(tx) => tx.send(event).map_err(|_| ()),
|
||||
Self::Bounded(tx) => tx.blocking_send(event).map_err(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "event_channel_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Raw emissions and control events share one bounded FIFO before collection.
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::*;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use codex_code_mode_protocol::host::BOUNDED_OUTPUT_MAX_BYTES as MAX_OUTPUT_BYTES;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_blocked_send_uses_the_released_slot_before_the_receiver_takes_the_item() {
|
||||
let (tx, mut rx) = runtime_event_channel(CodeModeOutputTruncation::Server);
|
||||
let RuntimeEventSender::Bounded(raw) = tx.clone() else {
|
||||
panic!("expected bounded sender");
|
||||
};
|
||||
let mut permits = raw.reserve_many(EVENT_CAPACITY).await.unwrap();
|
||||
let (done_tx, mut done_rx) = oneshot::channel();
|
||||
let text = "😀".repeat(MAX_OUTPUT_BYTES);
|
||||
let producer_text = text.clone();
|
||||
let producer = std::thread::spawn(move || {
|
||||
let result = tx.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: producer_text,
|
||||
}));
|
||||
let _ = done_tx.send(result);
|
||||
});
|
||||
// Queue a competing waiter before releasing the slot. A blocking producer can
|
||||
// win the slot in FIFO order; a try_send can never find an unreserved slot.
|
||||
let sent = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
let mut slot = permits.next().unwrap();
|
||||
loop {
|
||||
let mut next = std::pin::pin!(raw.reserve());
|
||||
assert!(futures::poll!(next.as_mut()).is_pending());
|
||||
drop(slot);
|
||||
tokio::select! {
|
||||
result = &mut done_rx => break result.unwrap(),
|
||||
permit = &mut next => slot = permit.unwrap(),
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the producer must eventually acquire the released slot");
|
||||
assert_eq!(sent, Ok(()));
|
||||
// Close the test's sender so a missing event fails the receive immediately.
|
||||
drop(permits);
|
||||
drop(raw);
|
||||
let event = rx.recv().await.unwrap();
|
||||
let RuntimeEvent::ContentItem(item) = event else {
|
||||
panic!("expected raw content");
|
||||
};
|
||||
assert_eq!(item, OutputItem::InputText { text });
|
||||
producer.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_and_control_are_received_in_send_order() {
|
||||
let (tx, mut rx) = runtime_event_channel(CodeModeOutputTruncation::Server);
|
||||
let producer = std::thread::spawn(move || {
|
||||
tx.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputImage {
|
||||
image_url: "image".to_owned(),
|
||||
detail: None,
|
||||
}))
|
||||
.unwrap();
|
||||
tx.blocking_send(RuntimeEvent::YieldRequested).unwrap();
|
||||
tx.blocking_send(RuntimeEvent::ContentItem(OutputItem::InputText {
|
||||
text: "after yield".to_owned(),
|
||||
}))
|
||||
.unwrap();
|
||||
});
|
||||
assert!(matches!(
|
||||
rx.recv().await,
|
||||
Some(RuntimeEvent::ContentItem(OutputItem::InputImage { .. }))
|
||||
));
|
||||
assert!(matches!(
|
||||
rx.recv().await,
|
||||
Some(RuntimeEvent::YieldRequested)
|
||||
));
|
||||
assert!(matches!(
|
||||
rx.recv().await,
|
||||
Some(RuntimeEvent::ContentItem(OutputItem::InputText { text }))
|
||||
if text == "after yield"
|
||||
));
|
||||
producer.join().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_channel_blocks_the_producer_and_receiver_drop_releases_it() {
|
||||
let (tx, rx) = runtime_event_channel(CodeModeOutputTruncation::Server);
|
||||
let RuntimeEventSender::Bounded(tx) = tx else {
|
||||
panic!("expected bounded sender");
|
||||
};
|
||||
for _ in 0..EVENT_CAPACITY {
|
||||
tx.try_send(RuntimeEvent::Started).unwrap();
|
||||
}
|
||||
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
let producer = tokio::spawn(async move {
|
||||
// Tokio's blocking_send runs this same send future. Polling it first
|
||||
// proves the full channel has registered this producer as a waiter.
|
||||
let mut send = std::pin::pin!(tx.send(RuntimeEvent::YieldRequested));
|
||||
assert!(futures::poll!(send.as_mut()).is_pending());
|
||||
ready_tx.send(()).unwrap();
|
||||
send.await
|
||||
});
|
||||
// This current-thread runtime cannot resume us until the producer yields.
|
||||
// Closing must wake that already-waiting task, not just reject a new send.
|
||||
ready_rx.await.unwrap();
|
||||
drop(rx);
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(1), producer)
|
||||
.await
|
||||
.expect("receiver drop must wake the blocked producer")
|
||||
.expect("producer task");
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(mpsc::error::SendError(RuntimeEvent::YieldRequested))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sending_after_receiver_drop_rejects_content() {
|
||||
let (tx, rx) = runtime_event_channel(CodeModeOutputTruncation::Server);
|
||||
drop(rx);
|
||||
assert_eq!(
|
||||
std::thread::spawn(move || tx.blocking_send(RuntimeEvent::ContentItem(
|
||||
OutputItem::InputAudio {
|
||||
audio_url: "large audio".repeat(MAX_OUTPUT_BYTES)
|
||||
}
|
||||
)))
|
||||
.join()
|
||||
.unwrap(),
|
||||
Err(())
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod audio;
|
||||
mod callbacks;
|
||||
mod event_channel;
|
||||
mod globals;
|
||||
mod module_loader;
|
||||
mod timers;
|
||||
@@ -12,17 +13,18 @@ use std::sync::Arc;
|
||||
use std::sync::mpsc as std_mpsc;
|
||||
use std::thread;
|
||||
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::session_runtime::OutputItem;
|
||||
use crate::v8_init::ensure_v8_initialized;
|
||||
use codex_code_mode_protocol::CodeModeToolKind;
|
||||
use codex_code_mode_protocol::EnabledToolMetadata;
|
||||
use codex_code_mode_protocol::ExecuteRequest;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::normalize_code_mode_identifier;
|
||||
use codex_protocol::ToolName;
|
||||
pub(crate) use event_channel::RuntimeEventReceiver;
|
||||
pub(crate) use event_channel::RuntimeEventSender;
|
||||
pub(crate) use event_channel::runtime_event_channel;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::TaskFailureHandler;
|
||||
use crate::v8_init::ensure_v8_initialized;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum RuntimeCommand {
|
||||
@@ -51,7 +53,7 @@ pub(crate) enum RuntimeControlCommand {
|
||||
pub(crate) enum RuntimeEvent {
|
||||
Started,
|
||||
Pending,
|
||||
ContentItem(FunctionCallOutputContentItem),
|
||||
ContentItem(OutputItem),
|
||||
YieldRequested,
|
||||
ToolCall {
|
||||
id: String,
|
||||
@@ -73,7 +75,7 @@ pub(crate) enum RuntimeEvent {
|
||||
pub(crate) fn spawn_runtime(
|
||||
stored_values: HashMap<String, Arc<JsonValue>>,
|
||||
request: ExecuteRequest,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: impl Into<RuntimeEventSender>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
) -> Result<
|
||||
@@ -84,6 +86,7 @@ pub(crate) fn spawn_runtime(
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let event_tx = event_tx.into();
|
||||
ensure_v8_initialized()?;
|
||||
|
||||
let (command_tx, command_rx) = std_mpsc::channel();
|
||||
@@ -128,16 +131,17 @@ pub(crate) fn spawn_runtime(
|
||||
}
|
||||
|
||||
fn spawn_supervised_runtime_thread(
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: impl Into<RuntimeEventSender>,
|
||||
task_failure_handler: Option<TaskFailureHandler>,
|
||||
runtime: impl FnOnce() + Send + 'static,
|
||||
) {
|
||||
let event_tx = event_tx.into();
|
||||
thread::spawn(move || {
|
||||
if catch_unwind(AssertUnwindSafe(runtime)).is_err() {
|
||||
if let Some(task_failure_handler) = task_failure_handler {
|
||||
task_failure_handler("code-mode V8 runtime thread panicked".to_string());
|
||||
}
|
||||
let _ = event_tx.send(RuntimeEvent::ThreadPanicked);
|
||||
let _ = event_tx.blocking_send(RuntimeEvent::ThreadPanicked);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -157,7 +161,7 @@ struct ToolCallbackMetadata {
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeState {
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: RuntimeEventSender,
|
||||
pending_tool_calls: HashMap<String, v8::Global<v8::PromiseResolver>>,
|
||||
pending_timeouts: HashMap<u64, timers::ScheduledTimeout>,
|
||||
stored_values: HashMap<String, Arc<JsonValue>>,
|
||||
@@ -177,7 +181,7 @@ pub(super) enum CompletionState {
|
||||
|
||||
fn run_runtime(
|
||||
config: RuntimeConfig,
|
||||
event_tx: mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: RuntimeEventSender,
|
||||
command_rx: std_mpsc::Receiver<RuntimeCommand>,
|
||||
control_rx: std_mpsc::Receiver<RuntimeControlCommand>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
@@ -221,7 +225,7 @@ fn run_runtime(
|
||||
exit_requested: false,
|
||||
});
|
||||
|
||||
let _ = event_tx.send(RuntimeEvent::Started);
|
||||
let _ = event_tx.blocking_send(RuntimeEvent::Started);
|
||||
|
||||
let pending_promise = match module_loader::evaluate_main_module(scope, &config.source) {
|
||||
Ok(pending_promise) => pending_promise,
|
||||
@@ -289,7 +293,7 @@ fn run_runtime(
|
||||
}
|
||||
|
||||
fn next_runtime_command(
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: &RuntimeEventSender,
|
||||
command_rx: &std_mpsc::Receiver<RuntimeCommand>,
|
||||
control_rx: &std_mpsc::Receiver<RuntimeControlCommand>,
|
||||
pending_mode: PendingRuntimeMode,
|
||||
@@ -301,7 +305,7 @@ fn next_runtime_command(
|
||||
Err(std_mpsc::TryRecvError::Empty) => {}
|
||||
}
|
||||
|
||||
let _ = event_tx.send(RuntimeEvent::Pending);
|
||||
let _ = event_tx.blocking_send(RuntimeEvent::Pending);
|
||||
match pending_mode {
|
||||
#[cfg(test)]
|
||||
PendingRuntimeMode::Continue => return command_rx.recv().ok(),
|
||||
@@ -316,7 +320,7 @@ fn next_runtime_command(
|
||||
|
||||
fn send_scope_result(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: &RuntimeEventSender,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
// Error rendering can itself run JS getters, including exit(). Capture
|
||||
@@ -330,11 +334,11 @@ fn send_scope_result(
|
||||
}
|
||||
|
||||
fn send_result(
|
||||
event_tx: &mpsc::UnboundedSender<RuntimeEvent>,
|
||||
event_tx: &RuntimeEventSender,
|
||||
stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
error_text: Option<String>,
|
||||
) {
|
||||
let _ = event_tx.send(RuntimeEvent::Result {
|
||||
let _ = event_tx.blocking_send(RuntimeEvent::Result {
|
||||
stored_value_writes,
|
||||
error_text,
|
||||
});
|
||||
@@ -355,7 +359,7 @@ mod tests {
|
||||
use super::RuntimeEvent;
|
||||
use super::spawn_runtime;
|
||||
use super::spawn_supervised_runtime_thread;
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
use crate::session_runtime::OutputItem;
|
||||
|
||||
fn execute_request(source: &str) -> ExecuteRequest {
|
||||
ExecuteRequest {
|
||||
@@ -530,9 +534,7 @@ await new Promise(() => {});
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let RuntimeEvent::ContentItem(FunctionCallOutputContentItem::InputText { text }) =
|
||||
content_event
|
||||
else {
|
||||
let RuntimeEvent::ContentItem(OutputItem::InputText { text }) = content_event else {
|
||||
panic!("expected resumed runtime output");
|
||||
};
|
||||
assert_eq!(text, "after");
|
||||
@@ -602,7 +604,7 @@ setTimeout(() => text("late"), 3_600_000);
|
||||
}
|
||||
assert_eq!(
|
||||
output,
|
||||
vec![FunctionCallOutputContentItem::InputText {
|
||||
vec![OutputItem::InputText {
|
||||
text: "done".to_string()
|
||||
}]
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ fn discarded_tool_response_is_collectible_before_cell_ends() {
|
||||
let (event_tx, _event_rx) = mpsc::unbounded_channel();
|
||||
let (runtime_command_tx, _runtime_command_rx) = std_mpsc::channel();
|
||||
scope.set_slot(RuntimeState {
|
||||
event_tx,
|
||||
event_tx: event_tx.into(),
|
||||
pending_tool_calls: HashMap::new(),
|
||||
pending_timeouts: HashMap::new(),
|
||||
stored_values: HashMap::new(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::session_runtime::OutputItem;
|
||||
use codex_code_mode_protocol::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::ImageDetail;
|
||||
|
||||
use super::RuntimeState;
|
||||
@@ -48,8 +48,8 @@ pub(super) fn normalize_output_image(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
detail_override: Option<String>,
|
||||
) -> Result<FunctionCallOutputContentItem, ()> {
|
||||
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
|
||||
) -> Result<OutputItem, ()> {
|
||||
let result = (|| -> Result<OutputItem, String> {
|
||||
let (image_url, detail) = if value.is_string() {
|
||||
(value.to_rust_string_lossy(scope), None)
|
||||
} else if value.is_object() && !value.is_array() {
|
||||
@@ -96,7 +96,7 @@ pub(super) fn normalize_output_image(
|
||||
None => Some(DEFAULT_IMAGE_DETAIL),
|
||||
};
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputImage { image_url, detail })
|
||||
Ok(OutputItem::InputImage { image_url, detail })
|
||||
})();
|
||||
|
||||
match result {
|
||||
@@ -191,8 +191,8 @@ fn parse_image_detail_value<'s>(
|
||||
pub(super) fn normalize_output_audio(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<FunctionCallOutputContentItem, ()> {
|
||||
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
|
||||
) -> Result<OutputItem, ()> {
|
||||
let result = (|| -> Result<OutputItem, String> {
|
||||
let audio_url = if value.is_string() {
|
||||
value.to_rust_string_lossy(scope)
|
||||
} else if value.is_object() && !value.is_array() {
|
||||
@@ -219,13 +219,13 @@ pub(super) fn normalize_output_audio(
|
||||
|
||||
// Tiny tool-generated clips cannot be encoded reliably by audio models.
|
||||
if wav_duration_seconds(&audio_url).is_some_and(|duration| duration < 0.025) {
|
||||
return Ok(FunctionCallOutputContentItem::InputText {
|
||||
return Ok(OutputItem::InputText {
|
||||
text: "Audio output omitted because the clip is shorter than 25 ms; use a longer clip."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputAudio { audio_url })
|
||||
Ok(OutputItem::InputAudio { audio_url })
|
||||
})();
|
||||
|
||||
match result {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
||||
|
||||
use codex_code_mode_protocol::CellId;
|
||||
use codex_code_mode_protocol::CodeModeNestedToolCall;
|
||||
use codex_code_mode_protocol::CodeModeOutputTruncation;
|
||||
use codex_code_mode_protocol::CodeModeSession;
|
||||
use codex_code_mode_protocol::CodeModeSessionCellExecutionLimits;
|
||||
use codex_code_mode_protocol::CodeModeSessionDelegate;
|
||||
@@ -11,8 +12,8 @@ use codex_code_mode_protocol::CodeModeToolKind;
|
||||
use codex_code_mode_protocol::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
use codex_code_mode_protocol::ExecuteRequest;
|
||||
use codex_code_mode_protocol::ExecuteToPendingOutcome;
|
||||
#[cfg(test)]
|
||||
use codex_code_mode_protocol::FunctionCallOutputContentItem;
|
||||
use codex_code_mode_protocol::ImageDetail;
|
||||
use codex_code_mode_protocol::RuntimeResponse;
|
||||
use codex_code_mode_protocol::StartedCell;
|
||||
use codex_code_mode_protocol::TerminateRequest;
|
||||
@@ -319,6 +320,7 @@ impl runtime::SessionRuntimeDelegate for ProtocolDelegate {
|
||||
|
||||
fn runtime_request(request: ExecuteRequest) -> runtime::CreateCellRequest {
|
||||
runtime::CreateCellRequest {
|
||||
output_truncation: CodeModeOutputTruncation::Client,
|
||||
tool_call_id: request.tool_call_id,
|
||||
enabled_tools: request
|
||||
.enabled_tools
|
||||
@@ -358,7 +360,9 @@ fn pending_outcome(
|
||||
pending_tool_call_ids,
|
||||
} => Ok(ExecuteToPendingOutcome::Pending {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
content_items: content_items
|
||||
.into_collected_output()
|
||||
.into_untruncated_items(),
|
||||
pending_tool_call_ids,
|
||||
}),
|
||||
event => Ok(ExecuteToPendingOutcome::Completed(runtime_response(
|
||||
@@ -374,7 +378,9 @@ fn runtime_response(
|
||||
match event {
|
||||
runtime::CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
content_items: content_items
|
||||
.into_collected_output()
|
||||
.into_untruncated_items(),
|
||||
code_mode_host_duration: None,
|
||||
}),
|
||||
runtime::CellEvent::Completed {
|
||||
@@ -383,14 +389,18 @@ fn runtime_response(
|
||||
result_buffered_for,
|
||||
} => Ok(RuntimeResponse::Result {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
content_items: content_items
|
||||
.into_collected_output()
|
||||
.into_untruncated_items(),
|
||||
error_text,
|
||||
result_buffered_for,
|
||||
code_mode_host_duration: None,
|
||||
}),
|
||||
runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated {
|
||||
cell_id: cell_id.clone(),
|
||||
content_items: content_items.into_iter().map(output_item).collect(),
|
||||
content_items: content_items
|
||||
.into_collected_output()
|
||||
.into_untruncated_items(),
|
||||
code_mode_host_duration: None,
|
||||
}),
|
||||
runtime::CellEvent::Pending { .. } => {
|
||||
@@ -399,26 +409,6 @@ fn runtime_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn output_item(item: runtime::OutputItem) -> FunctionCallOutputContentItem {
|
||||
match item {
|
||||
runtime::OutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text },
|
||||
runtime::OutputItem::Image { image_url, detail } => {
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: detail.map(|detail| match detail {
|
||||
runtime::ImageDetail::Auto => ImageDetail::Auto,
|
||||
runtime::ImageDetail::Low => ImageDetail::Low,
|
||||
runtime::ImageDetail::High => ImageDetail::High,
|
||||
runtime::ImageDetail::Original => ImageDetail::Original,
|
||||
}),
|
||||
}
|
||||
}
|
||||
runtime::OutputItem::Audio { audio_url } => {
|
||||
FunctionCallOutputContentItem::InputAudio { audio_url }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_cell_response(cell_id: CellId) -> RuntimeResponse {
|
||||
RuntimeResponse::Result {
|
||||
error_text: Some(format!("exec cell {cell_id} not found")),
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
pub(crate) use codex_code_mode_protocol::FunctionCallOutputContentItem as OutputItem;
|
||||
use opentelemetry::context::FutureExt;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -17,11 +18,9 @@ pub(crate) use self::types::CellEvent;
|
||||
pub(crate) use self::types::CellId;
|
||||
pub(crate) use self::types::CreateCellRequest;
|
||||
pub(crate) use self::types::Error;
|
||||
pub(crate) use self::types::ImageDetail;
|
||||
pub(crate) use self::types::NestedToolCall;
|
||||
pub(crate) use self::types::Observation;
|
||||
pub(crate) use self::types::ObserveMode;
|
||||
pub(crate) use self::types::OutputItem;
|
||||
pub(crate) use self::types::SessionRuntimeDelegate;
|
||||
pub(crate) use self::types::ToolDefinition;
|
||||
pub(crate) use self::types::ToolKind;
|
||||
@@ -286,19 +285,20 @@ impl<D: SessionRuntimeDelegate> CellHost for RuntimeCellHost<D> {
|
||||
async fn commit_completion(
|
||||
&self,
|
||||
stored_value_writes: HashMap<String, Arc<JsonValue>>,
|
||||
event: CellEvent,
|
||||
event: impl FnOnce() -> CellEvent + Send,
|
||||
cell_state: Arc<CellState>,
|
||||
) -> CompletionCommit {
|
||||
let cancellation_token = cell_state.cancellation_token();
|
||||
let mut stored_values = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation_token.cancelled() => {
|
||||
return CompletionCommit::Rejected(event);
|
||||
return CompletionCommit::Rejected;
|
||||
}
|
||||
stored_values = self.inner.stored_values.lock() => stored_values,
|
||||
};
|
||||
cell_state.commit_completion(event, || {
|
||||
stored_values.extend(stored_value_writes);
|
||||
drop(stored_values);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ use std::task::Poll;
|
||||
use std::task::Waker;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_code_mode_protocol::CodeModeOutputTruncation;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::*;
|
||||
use crate::cell_actor::CompletionCommit;
|
||||
use crate::output::OutputCollector;
|
||||
|
||||
struct RecordingDelegate;
|
||||
|
||||
@@ -80,9 +82,9 @@ async fn reports_cell_actor_panics_to_the_owner() {
|
||||
assert_eq!(
|
||||
started.initial_event().await,
|
||||
Ok(CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "done".to_string(),
|
||||
}],
|
||||
}]),
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: None,
|
||||
})
|
||||
@@ -104,21 +106,13 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa
|
||||
inner: Arc::clone(&runtime.inner),
|
||||
execution_context: opentelemetry::Context::new(),
|
||||
};
|
||||
let completion = CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
text: "uncommitted output".to_string(),
|
||||
}],
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: None,
|
||||
};
|
||||
|
||||
let stored_values = runtime.inner.stored_values.lock().await;
|
||||
let commit = host.commit_completion(
|
||||
HashMap::from([(
|
||||
"candidate".to_string(),
|
||||
Arc::new(JsonValue::String("lost".to_string())),
|
||||
)]),
|
||||
completion.clone(),
|
||||
|| panic!("terminated cell must not construct completion output"),
|
||||
Arc::clone(&cell_state),
|
||||
);
|
||||
tokio::pin!(commit);
|
||||
@@ -128,9 +122,9 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa
|
||||
|
||||
let termination = cell_state.request_termination();
|
||||
drop(stored_values);
|
||||
assert_eq!(commit.await, CompletionCommit::Rejected(completion));
|
||||
assert_eq!(commit.await, CompletionCommit::Rejected);
|
||||
let terminated = CellEvent::Terminated {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
};
|
||||
assert_eq!(
|
||||
cell_state.finish_termination(terminated.clone()),
|
||||
@@ -149,6 +143,7 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa
|
||||
let reader = runtime
|
||||
.execute(
|
||||
CreateCellRequest {
|
||||
output_truncation: CodeModeOutputTruncation::Client,
|
||||
tool_call_id: "reader".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: r#"text(String(load("candidate")));"#.to_string(),
|
||||
@@ -161,9 +156,9 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa
|
||||
assert_eq!(
|
||||
reader.initial_event().await,
|
||||
Ok(CellEvent::Completed {
|
||||
content_items: vec![OutputItem::Text {
|
||||
content_items: OutputCollector::Client(vec![OutputItem::InputText {
|
||||
text: "undefined".to_string(),
|
||||
}],
|
||||
}]),
|
||||
result_buffered_for: Duration::ZERO,
|
||||
error_text: None,
|
||||
})
|
||||
@@ -173,6 +168,7 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa
|
||||
|
||||
fn execute_request(source: &str) -> CreateCellRequest {
|
||||
CreateCellRequest {
|
||||
output_truncation: CodeModeOutputTruncation::Client,
|
||||
tool_call_id: "call-1".to_string(),
|
||||
enabled_tools: Vec::new(),
|
||||
source: source.to_string(),
|
||||
@@ -255,7 +251,7 @@ async fn drop_terminates_cells_when_the_registry_is_locked() {
|
||||
assert_eq!(
|
||||
started.initial_event().await,
|
||||
Ok(CellEvent::Yielded {
|
||||
content_items: Vec::new(),
|
||||
content_items: OutputCollector::Client(Vec::new()),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::time::Duration;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::output::OutputCollector;
|
||||
|
||||
/// Identifies one execution cell within a session runtime.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub(crate) struct CellId(String);
|
||||
@@ -54,54 +56,32 @@ impl From<ObserveMode> for Observation {
|
||||
}
|
||||
|
||||
/// An observable cell lifecycle event.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
#[cfg_attr(test, derive(PartialEq))]
|
||||
pub(crate) enum CellEvent {
|
||||
Yielded {
|
||||
content_items: Vec<OutputItem>,
|
||||
content_items: OutputCollector,
|
||||
},
|
||||
Pending {
|
||||
content_items: Vec<OutputItem>,
|
||||
content_items: OutputCollector,
|
||||
pending_tool_call_ids: Vec<String>,
|
||||
},
|
||||
Completed {
|
||||
content_items: Vec<OutputItem>,
|
||||
content_items: OutputCollector,
|
||||
error_text: Option<String>,
|
||||
/// Time the completed result spent waiting for an observer.
|
||||
result_buffered_for: Duration,
|
||||
},
|
||||
Terminated {
|
||||
content_items: Vec<OutputItem>,
|
||||
content_items: OutputCollector,
|
||||
},
|
||||
}
|
||||
|
||||
/// Output emitted by a cell since its preceding observation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum OutputItem {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Image {
|
||||
image_url: String,
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
Audio {
|
||||
audio_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Requested image fidelity for an output image.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ImageDetail {
|
||||
Auto,
|
||||
Low,
|
||||
High,
|
||||
Original,
|
||||
}
|
||||
|
||||
/// Transport-neutral input for creating a cell.
|
||||
///
|
||||
/// The owning session assigns the cell ID when it admits the request.
|
||||
pub(crate) struct CreateCellRequest {
|
||||
pub(crate) output_truncation: codex_code_mode_protocol::CodeModeOutputTruncation,
|
||||
pub(crate) tool_call_id: String,
|
||||
pub(crate) enabled_tools: Vec<ToolDefinition>,
|
||||
pub(crate) source: String,
|
||||
|
||||
Reference in New Issue
Block a user